36 Commits

Author SHA1 Message Date
bf2c7bb785 Advanced to version 0.9.1. 2023-03-24 09:16:54 +08:00
93ba086548 Simplified the code in the query_values pseudo property of the JournalEntryLineItem data model. 2023-03-24 09:15:29 +08:00
5c4f6017b8 Removed the redundant partial time in the query_values pseudo property of the JournalEntryLineItem data model. They are redundant since it is always partial match now. 2023-03-24 09:13:26 +08:00
cb16b2f0ff Updated the translation of the test site. 2023-03-24 08:53:35 +08:00
d2f11e8779 Replaced the "editor" and "editor2" accounts in the test site with "admin" and "editor", to be clear. 2023-03-24 08:53:35 +08:00
4ccaf01b3c Revised the template of the option detail to be visually different from the option edit form, to avoid confusion. 2023-03-24 08:52:58 +08:00
7c512b1c15 Revised the JavaScript DebitCreditSubForm to have a better visual effect when the line item editor is opened and closed with no line items. 2023-03-24 07:58:32 +08:00
dc432da398 Revised the coding style in the constructor of the JavaScript JournalEntryLineItemEditor class. 2023-03-24 07:49:58 +08:00
c8504bcbf5 Revised the #isQueryMatched method to match the current net balance instead of the net balance but the current form in the JavaScript OriginalLineItem class. 2023-03-24 07:47:41 +08:00
c865141583 Revised the #isQueryMatched method to always does partial match in the JavaScript OriginalLineItem class. Removed the full match from the query values. It is really wierd to type in the half with no match until you type the full term. It may create misunderstanding that there is no further match if you keep typing. 2023-03-24 07:38:17 +08:00
8c1ecd6eac Renamed the #isDebitCreditMatches and #isQueryMatches methods in the JavaScript OriginalLineItem class to #isDebitCreditMatched and #isQueryMatched, respectively. 2023-03-24 07:32:23 +08:00
e8e4100677 Revised the documentation of the JavaScript #isQueryMatches method of the OriginalLineItem class. 2023-03-24 07:31:13 +08:00
6a8773c531 Revised the code in the constructor of the JavaScript OriginalLineItemSelector class. 2023-03-24 07:30:09 +08:00
30e0c7682c Renamed the JavaScript AccountSelector class to JournalEntryAccountSelector, to avoid confusion. There is a RecurringAccountSelector in the option form now. 2023-03-24 07:27:52 +08:00
eb5a7bef7e Added the JavaScript AccountOption class to object-ize the account options in the journal entry form. 2023-03-24 07:23:56 +08:00
8a174d8847 Renamed the setBaseAccount method to saveBaseAccount in the JavaScript AccountForm form, for consistency. 2023-03-24 07:20:40 +08:00
7459afd63a Renamed the hasAnyMatched variable to isAnyMatched in the JavaScript #filterOptions method of the BaseAccountSelector, RecurringAccountSelector, and OriginalLineItemSelector classes. 2023-03-24 06:52:14 +08:00
a9afc385e9 Added the "baseCode" getter to the JavaScript AccountForm form, and removed the "baseCode" parameter from the onOpen method of the BaseAccountSelector class. It can retrieve the base code directly from the parent account form now. 2023-03-24 06:46:22 +08:00
a8be739ec7 Fixed the documentation of the JavaScript BaseAccountOption class. 2023-03-24 06:35:44 +08:00
0130bc58a9 Added prefix to the constructor of the BaseAccountSelector class, to simplify the code. 2023-03-24 00:37:59 +08:00
821059fa80 Added the JavaScript BaseAccountOption class to object-ize the base account options in the account form. 2023-03-24 00:35:50 +08:00
5b4f57d0b3 Removed a debugging log from the onOpen method of the RecurringAccountSelector class. 2023-03-24 00:27:37 +08:00
4bfac2d545 Removed an unused "noinspection JSValidateTypes" comment from the constructor of the JavaScript DebitCreditSubForm class. 2023-03-24 00:21:33 +08:00
f105f0cf7b Removed an orphan comment from the JavaScript RecurringTransactionTab class. 2023-03-24 00:20:59 +08:00
5e320729d7 Removed an excess blank line in testlib.py. 2023-03-23 17:30:38 +08:00
7515032082 Moved the Accounts shortcut from testlib_journal_entry.py to testlib.py. 2023-03-23 17:26:27 +08:00
361b18e411 Moved the duplicated NEXT_URI constant from test_account.py and testlib_journal_entry.py to testlib.py. 2023-03-23 17:22:57 +08:00
7d084e570e Revised the debit-credit content to have a better look when it is still empty. 2023-03-23 09:13:52 +08:00
cb397910f8 Added the "resetNo" method to the RecurringItemSubForm, CurrencySubForm, and LineItemSubForm forms to provide a simpler way to reset the order number, and removed the "elementId" getter and "no" setter. 2023-03-23 08:55:16 +08:00
5f8b0dec98 Renamed the JavaScript "lineItemIndex" property to "index" in the LineItemSubForm form. 2023-03-23 08:44:20 +08:00
8398d1e8bb Fixed a type error in the constructor of the JavaScript LineItemSubForm form. 2023-03-23 08:42:48 +08:00
562801692a Added the JavaScript setDeleteButtonShown method to the CurrencySubForm and LineItemSubForm forms, and hides the implementation of the delete buttons from outside. Changed the delete buttons to private. 2023-03-23 08:40:19 +08:00
faee1e61c6 Added the JavaScript elementId getter and no setter to the RecurringItemSubForm, CurrencySubForm, and LineItemSubForm forms, to hide the actual implementation of the element ID and order number. 2023-03-23 08:24:58 +08:00
57a4177037 Replaced the JavaScript getXXX methods with the "get XXX" getters. 2023-03-23 08:11:11 +08:00
fa1dedf207 Unified the documentation of the JavaScript prefix attribute. 2023-03-23 07:10:16 +08:00
7ed13dc0af Replaced the JavaScript prefix attributes that are only used in the class constructors with the prefix constant variables in the constructor. 2023-03-23 07:06:58 +08:00
31 changed files with 897 additions and 699 deletions

View File

@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath('../../src/'))
project = 'Mia! Accounting Flask' project = 'Mia! Accounting Flask'
copyright = '2023, imacat' copyright = '2023, imacat'
author = 'imacat' author = 'imacat'
release = '0.9.0' release = '0.9.1'
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

View File

@ -17,7 +17,7 @@
[metadata] [metadata]
name = mia-accounting-flask name = mia-accounting-flask
version = 0.9.0 version = 0.9.1
author = imacat author = imacat
author_email = imacat@mail.imacat.idv.tw author_email = imacat@mail.imacat.idv.tw
description = The Mia! Accounting Flask project. description = The Mia! Accounting Flask project.

View File

@ -760,7 +760,7 @@ class JournalEntryLineItem(db.Model):
setattr(self, "__net_balance", net_balance) setattr(self, "__net_balance", net_balance)
@property @property
def query_values(self) -> tuple[list[str], list[str]]: def query_values(self) -> list[str]:
"""Returns the values to be queried. """Returns the values to be queried.
:return: The values to be queried. :return: The values to be queried.
@ -770,19 +770,11 @@ class JournalEntryLineItem(db.Model):
frac: Decimal = (value - whole).normalize() frac: Decimal = (value - whole).normalize()
return str(whole) + str(abs(frac))[1:] return str(whole) + str(abs(frac))[1:]
journal_entry_day: date = self.journal_entry.date return ["{}/{}/{}".format(self.journal_entry.date.year,
description: str = "" if self.description is None else self.description self.journal_entry.date.month,
return ([description], self.journal_entry.date.day),
[str(journal_entry_day.year), "" if self.description is None else self.description,
"{}/{}".format(journal_entry_day.year, format_amount(self.amount)]
journal_entry_day.month),
"{}/{}".format(journal_entry_day.month,
journal_entry_day.day),
"{}/{}/{}".format(journal_entry_day.year,
journal_entry_day.month,
journal_entry_day.day),
format_amount(self.amount),
format_amount(self.net_balance)])
class Option(db.Model): class Option(db.Model):

View File

@ -114,10 +114,19 @@ class AccountForm {
}; };
this.#baseControl.onclick = () => { this.#baseControl.onclick = () => {
this.#baseControl.classList.add("accounting-not-empty"); this.#baseControl.classList.add("accounting-not-empty");
this.#baseAccountSelector.onOpen(this.#baseCode.value); this.#baseAccountSelector.onOpen();
}; };
} }
/**
* Returns the base code.
*
* @return {string|null}
*/
get baseCode() {
return this.#baseCode.value === ""? null: this.#baseCode.value;
}
/** /**
* The callback when the base account selector is closed. * The callback when the base account selector is closed.
* *
@ -129,15 +138,14 @@ class AccountForm {
} }
/** /**
* Sets the base account. * Saves the selected base account.
* *
* @param code {string} the base account code * @param account {BaseAccountOption} the selected base account
* @param text {string} the text for the base account
*/ */
setBaseAccount(code, text) { saveBaseAccount(account) {
this.#baseCode.value = code; this.#baseCode.value = account.code;
this.#base.innerText = text; this.#base.innerText = account.text;
if (["1", "2", "3"].includes(code.substring(0, 1))) { if (["1", "2", "3"].includes(account.code.substring(0, 1))) {
this.#isNeedOffsetControl.classList.remove("d-none"); this.#isNeedOffsetControl.classList.remove("d-none");
this.#isNeedOffset.disabled = false; this.#isNeedOffset.disabled = false;
} else { } else {
@ -225,7 +233,7 @@ class BaseAccountSelector {
* The account form * The account form
* @type {AccountForm} * @type {AccountForm}
*/ */
#form; form;
/** /**
* The selector modal * The selector modal
@ -253,7 +261,7 @@ class BaseAccountSelector {
/** /**
* The options * The options
* @type {HTMLLIElement[]} * @type {BaseAccountOption[]}
*/ */
#options; #options;
@ -269,83 +277,54 @@ class BaseAccountSelector {
* @param form {AccountForm} the form * @param form {AccountForm} the form
*/ */
constructor(form) { constructor(form) {
this.#form = form; this.form = form;
this.#modal = document.getElementById("accounting-base-selector-modal"); const prefix = "accounting-base-selector";
this.#query = document.getElementById("accounting-base-selector-query"); this.#modal = document.getElementById(`${prefix}-modal`);
this.#optionList = document.getElementById("accounting-base-selector-option-list"); this.#query = document.getElementById(`${prefix}-query`);
// noinspection JSValidateTypes this.#queryNoResult = document.getElementById(`${prefix}-option-no-result`);
this.#options = Array.from(document.getElementsByClassName("accounting-base-selector-option")); this.#optionList = document.getElementById(`${prefix}-option-list`);
this.#clearButton = document.getElementById("accounting-base-selector-clear"); this.#options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new BaseAccountOption(this, element));
this.#queryNoResult = document.getElementById("accounting-base-selector-option-no-result"); this.#clearButton = document.getElementById(`${prefix}-clear`);
this.#modal.addEventListener("hidden.bs.modal", () => {
this.#form.onBaseAccountSelectorClosed(); this.#modal.addEventListener("hidden.bs.modal", () => this.form.onBaseAccountSelectorClosed());
}); this.#query.oninput = () => this.#filterOptions();
for (const option of this.#options) { this.#clearButton.onclick = () => this.form.clearBaseAccount();
option.onclick = () => {
this.#form.setBaseAccount(option.dataset.code, option.dataset.text);
};
}
this.#clearButton.onclick = () => {
this.#form.clearBaseAccount();
};
this.#initializeBaseAccountQuery();
} }
/** /**
* Initializes the query. * Filters the options.
* *
*/ */
#initializeBaseAccountQuery() { #filterOptions() {
this.#query.addEventListener("input", () => { let isAnyMatched = false;
if (this.#query.value === "") { for (const option of this.#options) {
for (const option of this.#options) { if (option.isMatched(this.#query.value)) {
option.classList.remove("d-none"); option.setShown(true);
} isAnyMatched = true;
this.#optionList.classList.remove("d-none");
this.#queryNoResult.classList.add("d-none");
return
}
let hasAnyMatched = false;
for (const option of this.#options) {
const queryValues = JSON.parse(option.dataset.queryValues);
let isMatched = false;
for (const queryValue of queryValues) {
if (queryValue.toLowerCase().includes(this.#query.value.toLowerCase())) {
isMatched = true;
break;
}
}
if (isMatched) {
option.classList.remove("d-none");
hasAnyMatched = true;
} else {
option.classList.add("d-none");
}
}
if (!hasAnyMatched) {
this.#optionList.classList.add("d-none");
this.#queryNoResult.classList.remove("d-none");
} else { } else {
this.#optionList.classList.remove("d-none"); option.setShown(false);
this.#queryNoResult.classList.add("d-none");
} }
}); }
if (!isAnyMatched) {
this.#optionList.classList.add("d-none");
this.#queryNoResult.classList.remove("d-none");
} else {
this.#optionList.classList.remove("d-none");
this.#queryNoResult.classList.add("d-none");
}
} }
/** /**
* The callback when the base account selector is shown. * The callback when the base account selector is shown.
* *
* @param baseCode {string} the active base code
*/ */
onOpen(baseCode) { onOpen() {
this.#query.value = "";
this.#filterOptions();
for (const option of this.#options) { for (const option of this.#options) {
if (option.dataset.code === baseCode) { option.setActive(option.code === this.form.baseCode);
option.classList.add("active");
} else {
option.classList.remove("active");
}
} }
if (baseCode === "") { if (this.form.baseCode === null) {
this.#clearButton.classList.add("btn-secondary") this.#clearButton.classList.add("btn-secondary")
this.#clearButton.classList.remove("btn-danger"); this.#clearButton.classList.remove("btn-danger");
this.#clearButton.disabled = true; this.#clearButton.disabled = true;
@ -356,3 +335,100 @@ class BaseAccountSelector {
} }
} }
} }
/**
* A base account option.
*
*/
class BaseAccountOption {
/**
* The base account selector
* @type {BaseAccountSelector}
*/
#selector;
/**
* The element
* @type {HTMLLIElement}
*/
#element;
/**
* The account code
* @type {string}
*/
code;
/**
* The account text
* @type {string}
*/
text;
/**
* The values to query against
* @type {string[]}
*/
#queryValues;
/**
* Constructs the account in the base account selector.
*
* @param selector {BaseAccountSelector} the base account selector
* @param element {HTMLLIElement} the element
*/
constructor(selector, element) {
this.#selector = selector;
this.#element = element;
this.code = element.dataset.code;
this.text = element.dataset.text;
this.#queryValues = JSON.parse(element.dataset.queryValues);
this.#element.onclick = () => this.#selector.form.saveBaseAccount(this);
}
/**
* Returns whether the account matches the query.
*
* @param query {string} the query term
* @return {boolean} true if the option matches, or false otherwise
*/
isMatched(query) {
if (query === "") {
return true;
}
for (const queryValue of this.#queryValues) {
if (queryValue.toLowerCase().includes(query.toLowerCase())) {
return true;
}
}
return false;
}
/**
* Sets whether the option is shown.
*
* @param isShown {boolean} true to show, or false otherwise
*/
setShown(isShown) {
if (isShown) {
this.#element.classList.remove("d-none");
} else {
this.#element.classList.add("d-none");
}
}
/**
* Sets whether the option is active.
*
* @param isActive {boolean} true if active, or false otherwise
*/
setActive(isActive) {
if (isActive) {
this.#element.classList.add("active");
} else {
this.#element.classList.remove("active");
}
}
}

View File

@ -1,224 +0,0 @@
/* The Mia! Accounting Flask Project
* account-selector.js: The JavaScript for the account selector
*/
/* Copyright (c) 2023 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/28
*/
"use strict";
/**
* The account selector.
*
*/
class AccountSelector {
/**
* The line item editor
* @type {JournalEntryLineItemEditor}
*/
#lineItemEditor;
/**
* Either "debit" or "credit"
* @type {string}
*/
#debitCredit;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix;
/**
* The button to clear the account
* @type {HTMLButtonElement}
*/
#clearButton
/**
* The query input
* @type {HTMLInputElement}
*/
#query;
/**
* The error message when the query has no result
* @type {HTMLParagraphElement}
*/
#queryNoResult;
/**
* The option list
* @type {HTMLUListElement}
*/
#optionList;
/**
* The options
* @type {HTMLLIElement[]}
*/
#options;
/**
* The more item to show all accounts
* @type {HTMLLIElement}
*/
#more;
/**
* Constructs an account selector.
*
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
* @param debitCredit {string} either "debit" or "credit"
*/
constructor(lineItemEditor, debitCredit) {
this.#lineItemEditor = lineItemEditor
this.#debitCredit = debitCredit;
this.#prefix = "accounting-account-selector-" + debitCredit;
this.#query = document.getElementById(this.#prefix + "-query");
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
this.#optionList = document.getElementById(this.#prefix + "-option-list");
// noinspection JSValidateTypes
this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
this.#more = document.getElementById(this.#prefix + "-more");
this.#clearButton = document.getElementById(this.#prefix + "-btn-clear");
this.#more.onclick = () => {
this.#more.classList.add("d-none");
this.#filterOptions();
};
this.#clearButton.onclick = () => this.#lineItemEditor.clearAccount();
for (const option of this.#options) {
option.onclick = () => this.#lineItemEditor.saveAccount(option.dataset.code, option.dataset.text, option.classList.contains("accounting-account-is-need-offset"));
}
this.#query.addEventListener("input", () => {
this.#filterOptions();
});
}
/**
* Filters the options.
*
*/
#filterOptions() {
const codesInUse = this.#getCodesUsedInForm();
let shouldAnyShow = false;
for (const option of this.#options) {
const shouldShow = this.#shouldOptionShow(option, this.#more, codesInUse, this.#query);
if (shouldShow) {
option.classList.remove("d-none");
shouldAnyShow = true;
} else {
option.classList.add("d-none");
}
}
if (!shouldAnyShow && this.#more.classList.contains("d-none")) {
this.#optionList.classList.add("d-none");
this.#queryNoResult.classList.remove("d-none");
} else {
this.#optionList.classList.remove("d-none");
this.#queryNoResult.classList.add("d-none");
}
}
/**
* Returns the account codes that are used in the form.
*
* @return {string[]} the account codes that are used in the form
*/
#getCodesUsedInForm() {
const inUse = this.#lineItemEditor.form.getAccountCodesUsed(this.#debitCredit);
if (this.#lineItemEditor.accountCode !== null) {
inUse.push(this.#lineItemEditor.accountCode);
}
return inUse
}
/**
* Returns whether an option should show.
*
* @param option {HTMLLIElement} the option
* @param more {HTMLLIElement} the more element
* @param inUse {string[]} the account codes that are used in the form
* @param query {HTMLInputElement} the query element, if any
* @return {boolean} true if the option should show, or false otherwise
*/
#shouldOptionShow(option, more, inUse, query) {
const isQueryMatched = () => {
if (query.value === "") {
return true;
}
const queryValues = JSON.parse(option.dataset.queryValues);
for (const queryValue of queryValues) {
if (queryValue.toLowerCase().includes(query.value.toLowerCase())) {
return true;
}
}
return false;
};
const isMoreMatched = () => {
if (more.classList.contains("d-none")) {
return true;
}
return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code);
};
return isMoreMatched() && isQueryMatched();
}
/**
* The callback when the account selector is shown.
*
*/
onOpen() {
this.#query.value = "";
this.#more.classList.remove("d-none");
this.#filterOptions();
for (const option of this.#options) {
if (option.dataset.code === this.#lineItemEditor.accountCode) {
option.classList.add("active");
} else {
option.classList.remove("active");
}
}
if (this.#lineItemEditor.accountCode === null) {
this.#clearButton.classList.add("btn-secondary");
this.#clearButton.classList.remove("btn-danger");
this.#clearButton.disabled = true;
} else {
this.#clearButton.classList.add("btn-danger");
this.#clearButton.classList.remove("btn-secondary");
this.#clearButton.disabled = false;
}
}
/**
* Returns the account selector instances.
*
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
* @return {{debit: AccountSelector, credit: AccountSelector}}
*/
static getInstances(lineItemEditor) {
const selectors = {}
const modals = Array.from(document.getElementsByClassName("accounting-account-selector"));
for (const modal of modals) {
selectors[modal.dataset.debitCredit] = new AccountSelector(lineItemEditor, modal.dataset.debitCredit);
}
return selectors;
}
}

View File

@ -41,7 +41,7 @@ class DescriptionEditor {
#form; #form;
/** /**
* The prefix of the HTML ID and class * The prefix of the HTML ID and class names
* @type {string} * @type {string}
*/ */
prefix; prefix;
@ -278,7 +278,7 @@ class TabPlane {
editor; editor;
/** /**
* The prefix of the HTML ID and classes * The prefix of the HTML ID and class names
* @type {string} * @type {string}
*/ */
prefix; prefix;
@ -984,7 +984,6 @@ class RecurringTransactionTab extends TabPlane {
*/ */
#itemButtons; #itemButtons;
// noinspection JSValidateTypes
/** /**
* Constructs a tab plane. * Constructs a tab plane.
* *
@ -1019,7 +1018,7 @@ class RecurringTransactionTab extends TabPlane {
* @return {string} the description of the recurring item * @return {string} the description of the recurring item
*/ */
#getDescription(itemButton) { #getDescription(itemButton) {
const today = new Date(this.editor.lineItemEditor.form.getDate()); const today = new Date(this.editor.lineItemEditor.form.date);
const thisMonth = today.getMonth() + 1; const thisMonth = today.getMonth() + 1;
const lastMonth = (thisMonth + 10) % 12 + 1; const lastMonth = (thisMonth + 10) % 12 + 1;
const lastBimonthlyFrom = ((thisMonth + thisMonth % 2 + 8) % 12 + 1); const lastBimonthlyFrom = ((thisMonth + thisMonth % 2 + 8) % 12 + 1);

View File

@ -0,0 +1,319 @@
/* The Mia! Accounting Flask Project
* journal-entry-account-selector.js: The JavaScript for the account selector of the journal entry form
*/
/* Copyright (c) 2023 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/28
*/
"use strict";
/**
* The account selector.
*
*/
class JournalEntryAccountSelector {
/**
* The line item editor
* @type {JournalEntryLineItemEditor}
*/
lineItemEditor;
/**
* Either "debit" or "credit"
* @type {string}
*/
#debitCredit;
/**
* The button to clear the account
* @type {HTMLButtonElement}
*/
#clearButton
/**
* The query input
* @type {HTMLInputElement}
*/
#query;
/**
* The error message when the query has no result
* @type {HTMLParagraphElement}
*/
#queryNoResult;
/**
* The option list
* @type {HTMLUListElement}
*/
#optionList;
/**
* The options
* @type {JournalEntryAccountOption[]}
*/
#options;
/**
* The more item to show all accounts
* @type {HTMLLIElement}
*/
#more;
/**
* Whether to show all accounts
* @type {boolean}
*/
#isShowMore = false;
/**
* Constructs an account selector.
*
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
* @param debitCredit {string} either "debit" or "credit"
*/
constructor(lineItemEditor, debitCredit) {
this.lineItemEditor = lineItemEditor
this.#debitCredit = debitCredit;
const prefix = "accounting-account-selector-" + debitCredit;
this.#query = document.getElementById(prefix + "-query");
this.#queryNoResult = document.getElementById(prefix + "-option-no-result");
this.#optionList = document.getElementById(prefix + "-option-list");
this.#options = Array.from(document.getElementsByClassName(prefix + "-option")).map((element) => new JournalEntryAccountOption(this, element));
this.#more = document.getElementById(prefix + "-more");
this.#clearButton = document.getElementById(prefix + "-btn-clear");
this.#more.onclick = () => {
this.#isShowMore = true;
this.#more.classList.add("d-none");
this.#filterOptions();
};
this.#query.oninput = () => this.#filterOptions();
this.#clearButton.onclick = () => this.lineItemEditor.clearAccount();
}
/**
* Filters the options.
*
*/
#filterOptions() {
const codesInUse = this.#getCodesUsedInForm();
let isAnyMatched = false;
for (const option of this.#options) {
if (option.isMatched(this.#isShowMore, codesInUse, this.#query.value)) {
option.setShown(true);
isAnyMatched = true;
} else {
option.setShown(false);
}
}
if (!isAnyMatched) {
this.#optionList.classList.add("d-none");
this.#queryNoResult.classList.remove("d-none");
} else {
this.#optionList.classList.remove("d-none");
this.#queryNoResult.classList.add("d-none");
}
}
/**
* Returns the account codes that are used in the form.
*
* @return {string[]} the account codes that are used in the form
*/
#getCodesUsedInForm() {
const inUse = this.lineItemEditor.form.getAccountCodesUsed(this.#debitCredit);
if (this.lineItemEditor.accountCode !== null) {
inUse.push(this.lineItemEditor.accountCode);
}
return inUse
}
/**
* The callback when the account selector is shown.
*
*/
onOpen() {
this.#query.value = "";
this.#isShowMore = false;
this.#more.classList.remove("d-none");
this.#filterOptions();
for (const option of this.#options) {
option.setActive(option.code === this.lineItemEditor.accountCode);
}
if (this.lineItemEditor.accountCode === null) {
this.#clearButton.classList.add("btn-secondary");
this.#clearButton.classList.remove("btn-danger");
this.#clearButton.disabled = true;
} else {
this.#clearButton.classList.add("btn-danger");
this.#clearButton.classList.remove("btn-secondary");
this.#clearButton.disabled = false;
}
}
/**
* Returns the account selector instances.
*
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
* @return {{debit: JournalEntryAccountSelector, credit: JournalEntryAccountSelector}}
*/
static getInstances(lineItemEditor) {
const selectors = {}
const modals = Array.from(document.getElementsByClassName("accounting-account-selector"));
for (const modal of modals) {
selectors[modal.dataset.debitCredit] = new JournalEntryAccountSelector(lineItemEditor, modal.dataset.debitCredit);
}
return selectors;
}
}
/**
* An account option
*
*/
class JournalEntryAccountOption {
/**
* The account selector
* @type {JournalEntryAccountSelector}
*/
#selector;
/**
* The element
* @type {HTMLLIElement}
*/
#element;
/**
* The account code
* @type {string}
*/
code;
/**
* The account text
* @type {string}
*/
text;
/**
* Whether the account is in use
* @type {boolean}
*/
#isInUse;
/**
* Whether line items in the account need offset
* @type {boolean}
*/
isNeedOffset;
/**
* The values to query against
* @type {string[]}
*/
#queryValues;
/**
* Constructs the account in the account selector.
*
* @param selector {JournalEntryAccountSelector} the account selector
* @param element {HTMLLIElement} the element
*/
constructor(selector, element) {
this.#selector = selector;
this.#element = element;
this.code = element.dataset.code;
this.text = element.dataset.text;
this.#isInUse = element.classList.contains("accounting-account-is-in-use");
this.isNeedOffset = element.classList.contains("accounting-account-is-need-offset");
this.#queryValues = JSON.parse(element.dataset.queryValues);
this.#element.onclick = () => this.#selector.lineItemEditor.saveAccount(this);
}
/**
* Returns whether the account matches the query.
*
* @param isShowMore {boolean} true to show all accounts, or false to show only those in use
* @param codesInUse {string[]} the account codes that are used in the form
* @param query {string} the query term
* @return {boolean} true if the option matches, or false otherwise
*/
isMatched(isShowMore, codesInUse, query) {
return this.#isInUseMatched(isShowMore, codesInUse) && this.#isQueryMatched(query);
}
/**
* Returns whether the account matches the "in-use" condition.
*
* @param isShowMore {boolean} true to show all accounts, or false to show only those in use
* @param codesInUse {string[]} the account codes that are used in the form
* @return {boolean} true if the option matches, or false otherwise
*/
#isInUseMatched(isShowMore, codesInUse) {
return isShowMore || this.#isInUse || codesInUse.includes(this.code);
}
/**
* Returns whether the account matches the query term.
*
* @param query {string} the query term
* @return {boolean} true if the option matches, or false otherwise
*/
#isQueryMatched(query) {
if (query === "") {
return true;
}
for (const queryValue of this.#queryValues) {
if (queryValue.toLowerCase().includes(query.toLowerCase())) {
return true;
}
}
return false;
}
/**
* Sets whether the option is shown.
*
* @param isShown {boolean} true to show, or false otherwise
*/
setShown(isShown) {
if (isShown) {
this.#element.classList.remove("d-none");
} else {
this.#element.classList.add("d-none");
}
}
/**
* Sets whether the option is active.
*
* @param isActive {boolean} true if active, or false otherwise
*/
setActive(isActive) {
if (isActive) {
this.#element.classList.add("active");
} else {
this.#element.classList.remove("active");
}
}
}

View File

@ -111,6 +111,7 @@ class JournalEntryForm {
constructor() { constructor() {
this.#element = document.getElementById("accounting-form"); this.#element = document.getElementById("accounting-form");
this.lineItemTemplate = this.#element.dataset.lineItemTemplate; this.lineItemTemplate = this.#element.dataset.lineItemTemplate;
this.lineItemEditor = new JournalEntryLineItemEditor(this);
this.#date = document.getElementById("accounting-date"); this.#date = document.getElementById("accounting-date");
this.#dateError = document.getElementById("accounting-date-error"); this.#dateError = document.getElementById("accounting-date-error");
this.#currencyControl = document.getElementById("accounting-currencies"); this.#currencyControl = document.getElementById("accounting-currencies");
@ -121,7 +122,6 @@ class JournalEntryForm {
this.#addCurrencyButton = document.getElementById("accounting-add-currency"); this.#addCurrencyButton = document.getElementById("accounting-add-currency");
this.#note = document.getElementById("accounting-note"); this.#note = document.getElementById("accounting-note");
this.#noteError = document.getElementById("accounting-note-error"); this.#noteError = document.getElementById("accounting-note-error");
this.lineItemEditor = new JournalEntryLineItemEditor(this);
this.#addCurrencyButton.onclick = () => { this.#addCurrencyButton.onclick = () => {
const newIndex = 1 + (this.#currencies.length === 0? 0: Math.max(...this.#currencies.map((currency) => currency.index))); const newIndex = 1 + (this.#currencies.length === 0? 0: Math.max(...this.#currencies.map((currency) => currency.index)));
@ -159,7 +159,7 @@ class JournalEntryForm {
*/ */
#resetDeleteCurrencyButtons() { #resetDeleteCurrencyButtons() {
if (this.#currencies.length === 1) { if (this.#currencies.length === 1) {
this.#currencies[0].deleteButton.classList.add("d-none"); this.#currencies[0].setDeleteButtonShown(false);
} else { } else {
for (const currency of this.#currencies) { for (const currency of this.#currencies) {
let isAnyLineItemMatched = false; let isAnyLineItemMatched = false;
@ -169,11 +169,7 @@ class JournalEntryForm {
break; break;
} }
} }
if (isAnyLineItemMatched) { currency.setDeleteButtonShown(!isAnyLineItemMatched);
currency.deleteButton.classList.add("d-none");
} else {
currency.deleteButton.classList.remove("d-none");
}
} }
} }
} }
@ -184,10 +180,8 @@ class JournalEntryForm {
*/ */
#initializeDragAndDropReordering() { #initializeDragAndDropReordering() {
initializeDragAndDropReordering(this.#currencyList, () => { initializeDragAndDropReordering(this.#currencyList, () => {
const currencyId = Array.from(this.#currencyList.children).map((currency) => currency.id); for (const currency of this.#currencies) {
this.#currencies.sort((a, b) => currencyId.indexOf(a.element.id) - currencyId.indexOf(b.element.id)); currency.resetNo();
for (let i = 0; i < this.#currencies.length; i++) {
this.#currencies[i].no.value = String(i + 1);
} }
}); });
} }
@ -213,7 +207,7 @@ class JournalEntryForm {
* @return {string[]} the account codes used in the form * @return {string[]} the account codes used in the form
*/ */
getAccountCodesUsed(debitCredit) { getAccountCodesUsed(debitCredit) {
return this.getLineItems(debitCredit).map((lineItem) => lineItem.getAccountCode()) return this.getLineItems(debitCredit).map((lineItem) => lineItem.accountCode)
.filter((code) => code !== null); .filter((code) => code !== null);
} }
@ -222,7 +216,7 @@ class JournalEntryForm {
* *
* @return {string} the date * @return {string} the date
*/ */
getDate() { get date() {
return this.#date.value; return this.#date.value;
} }
@ -233,7 +227,7 @@ class JournalEntryForm {
updateMinDate() { updateMinDate() {
let lastOriginalLineItemDate = null; let lastOriginalLineItemDate = null;
for (const lineItem of this.getLineItems()) { for (const lineItem of this.getLineItems()) {
const date = lineItem.getOriginalLineItemDate(); const date = lineItem.originalLineItemDate;
if (date !== null) { if (date !== null) {
if (lastOriginalLineItemDate === null || lastOriginalLineItemDate < date) { if (lastOriginalLineItemDate === null || lastOriginalLineItemDate < date) {
lastOriginalLineItemDate = date; lastOriginalLineItemDate = date;
@ -349,7 +343,7 @@ class CurrencySubForm {
* The element * The element
* @type {HTMLDivElement} * @type {HTMLDivElement}
*/ */
element; #element;
/** /**
* The journal entry form * The journal entry form
@ -363,12 +357,6 @@ class CurrencySubForm {
*/ */
index; index;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix;
/** /**
* The control * The control
* @type {HTMLDivElement} * @type {HTMLDivElement}
@ -385,7 +373,7 @@ class CurrencySubForm {
* The number * The number
* @type {HTMLInputElement} * @type {HTMLInputElement}
*/ */
no; #no;
/** /**
* The currency code * The currency code
@ -403,7 +391,7 @@ class CurrencySubForm {
* The button to delete the currency * The button to delete the currency
* @type {HTMLButtonElement} * @type {HTMLButtonElement}
*/ */
deleteButton; #deleteButton;
/** /**
* The debit sub-form * The debit sub-form
@ -424,36 +412,58 @@ class CurrencySubForm {
* @param element {HTMLDivElement} the currency sub-form element * @param element {HTMLDivElement} the currency sub-form element
*/ */
constructor(form, element) { constructor(form, element) {
this.element = element; this.#element = element;
this.form = form; this.form = form;
this.index = parseInt(this.element.dataset.index); this.index = parseInt(this.#element.dataset.index);
this.#prefix = "accounting-currency-" + String(this.index); const prefix = "accounting-currency-" + String(this.index);
this.#control = document.getElementById(this.#prefix + "-control"); this.#control = document.getElementById(prefix + "-control");
this.#error = document.getElementById(this.#prefix + "-error"); this.#error = document.getElementById(prefix + "-error");
this.no = document.getElementById(this.#prefix + "-no"); this.#no = document.getElementById(prefix + "-no");
this.#code = document.getElementById(this.#prefix + "-code"); this.#code = document.getElementById(prefix + "-code");
this.#codeSelect = document.getElementById(this.#prefix + "-code-select"); this.#codeSelect = document.getElementById(prefix + "-code-select");
this.deleteButton = document.getElementById(this.#prefix + "-delete"); this.#deleteButton = document.getElementById(prefix + "-delete");
const debitElement = document.getElementById(this.#prefix + "-debit"); const debitElement = document.getElementById(prefix + "-debit");
this.#debit = debitElement === null? null: new DebitCreditSubForm(this, debitElement, "debit"); this.#debit = debitElement === null? null: new DebitCreditSubForm(this, debitElement, "debit");
const creditElement = document.getElementById(this.#prefix + "-credit"); const creditElement = document.getElementById(prefix + "-credit");
this.#credit = creditElement == null? null: new DebitCreditSubForm(this, creditElement, "credit"); this.#credit = creditElement == null? null: new DebitCreditSubForm(this, creditElement, "credit");
this.#codeSelect.onchange = () => this.#code.value = this.#codeSelect.value; this.#codeSelect.onchange = () => this.#code.value = this.#codeSelect.value;
this.deleteButton.onclick = () => { this.#deleteButton.onclick = () => {
this.element.parentElement.removeChild(this.element); this.#element.parentElement.removeChild(this.#element);
this.form.deleteCurrency(this); this.form.deleteCurrency(this);
}; };
} }
/**
* Reset the order number.
*
*/
resetNo() {
const siblings = Array.from(this.#element.parentElement.children);
this.#no.value = String(siblings.indexOf(this.#element) + 1);
}
/** /**
* Returns the currency code. * Returns the currency code.
* *
* @return {string} the currency code * @return {string} the currency code
*/ */
getCurrencyCode() { get currencyCode() {
return this.#code.value; return this.#code.value;
} }
/**
* Sets whether the delete button is shown.
*
* @param isShown {boolean} true to show, or false otherwise
*/
setDeleteButtonShown(isShown) {
if (isShown) {
this.#deleteButton.classList.remove("d-none");
} else {
this.#deleteButton.classList.add("d-none");
}
}
/** /**
* Returns all the line items in the form. * Returns all the line items in the form.
* *
@ -479,7 +489,7 @@ class CurrencySubForm {
updateCodeSelectorStatus() { updateCodeSelectorStatus() {
let isEnabled = true; let isEnabled = true;
for (const lineItem of this.getLineItems()) { for (const lineItem of this.getLineItems()) {
if (lineItem.getOriginalLineItemId() !== null) { if (lineItem.originalLineItemId !== null) {
isEnabled = false; isEnabled = false;
break; break;
} }
@ -511,7 +521,7 @@ class CurrencySubForm {
*/ */
validateBalance() { validateBalance() {
if (this.#debit !== null && this.#credit !== null) { if (this.#debit !== null && this.#credit !== null) {
if (!this.#debit.getTotal().equals(this.#credit.getTotal())) { if (!this.#debit.total.equals(this.#credit.total)) {
this.#control.classList.add("is-invalid"); this.#control.classList.add("is-invalid");
this.#error.innerText = A_("The totals of the debit and credit amounts do not match."); this.#error.innerText = A_("The totals of the debit and credit amounts do not match.");
return false; return false;
@ -541,6 +551,12 @@ class DebitCreditSubForm {
*/ */
#element; #element;
/**
* The content
* @type {HTMLDivElement}
*/
#content;
/** /**
* The currencyIndex * The currencyIndex
* @type {number} * @type {number}
@ -554,7 +570,7 @@ class DebitCreditSubForm {
debitCredit; debitCredit;
/** /**
* The prefix of the HTML ID and class * The prefix of the HTML ID and class names
* @type {string} * @type {string}
*/ */
#prefix; #prefix;
@ -602,24 +618,36 @@ class DebitCreditSubForm {
this.#currencyIndex = currency.index; this.#currencyIndex = currency.index;
this.debitCredit = debitCredit; this.debitCredit = debitCredit;
this.#prefix = "accounting-currency-" + String(this.#currencyIndex) + "-" + debitCredit; this.#prefix = "accounting-currency-" + String(this.#currencyIndex) + "-" + debitCredit;
this.#content = document.getElementById(this.#prefix + "-content");
this.#error = document.getElementById(this.#prefix + "-error"); this.#error = document.getElementById(this.#prefix + "-error");
this.#lineItemList = document.getElementById(this.#prefix + "-list"); this.#lineItemList = document.getElementById(this.#prefix + "-list");
// noinspection JSValidateTypes
this.lineItems = Array.from(document.getElementsByClassName(this.#prefix)).map((element) => new LineItemSubForm(this, element)); this.lineItems = Array.from(document.getElementsByClassName(this.#prefix)).map((element) => new LineItemSubForm(this, element));
this.#total = document.getElementById(this.#prefix + "-total"); this.#total = document.getElementById(this.#prefix + "-total");
this.#addLineItemButton = document.getElementById(this.#prefix + "-add-line-item"); this.#addLineItemButton = document.getElementById(this.#prefix + "-add-line-item");
this.#resetContent();
this.#addLineItemButton.onclick = () => this.currency.form.lineItemEditor.onAddNew(this); this.#addLineItemButton.onclick = () => this.currency.form.lineItemEditor.onAddNew(this);
this.#resetDeleteLineItemButtons(); this.#resetDeleteLineItemButtons();
this.#initializeDragAndDropReordering(); this.#initializeDragAndDropReordering();
} }
/**
* The callback when the line item editor is closed.
*
*/
onLineItemEditorClosed() {
if (this.lineItems.length === 0) {
this.#element.classList.remove("accounting-not-empty");
}
}
/** /**
* Adds a new line item sub-form * Adds a new line item sub-form
* *
* @returns {LineItemSubForm} the newly-added line item sub-form * @returns {LineItemSubForm} the newly-added line item sub-form
*/ */
addLineItem() { addLineItem() {
const newIndex = 1 + (this.lineItems.length === 0? 0: Math.max(...this.lineItems.map((lineItem) => lineItem.lineItemIndex))); const newIndex = 1 + (this.lineItems.length === 0? 0: Math.max(...this.lineItems.map((lineItem) => lineItem.index)));
const html = this.currency.form.lineItemTemplate const html = this.currency.form.lineItemTemplate
.replaceAll("CURRENCY_INDEX", escapeHtml(String(this.#currencyIndex))) .replaceAll("CURRENCY_INDEX", escapeHtml(String(this.#currencyIndex)))
.replaceAll("DEBIT_CREDIT", escapeHtml(this.debitCredit)) .replaceAll("DEBIT_CREDIT", escapeHtml(this.debitCredit))
@ -627,6 +655,7 @@ class DebitCreditSubForm {
this.#lineItemList.insertAdjacentHTML("beforeend", html); this.#lineItemList.insertAdjacentHTML("beforeend", html);
const lineItem = new LineItemSubForm(this, document.getElementById(this.#prefix + "-" + String(newIndex))); const lineItem = new LineItemSubForm(this, document.getElementById(this.#prefix + "-" + String(newIndex)));
this.lineItems.push(lineItem); this.lineItems.push(lineItem);
this.#resetContent();
this.#resetDeleteLineItemButtons(); this.#resetDeleteLineItemButtons();
this.#initializeDragAndDropReordering(); this.#initializeDragAndDropReordering();
this.validate(); this.validate();
@ -644,6 +673,7 @@ class DebitCreditSubForm {
this.updateTotal(); this.updateTotal();
this.currency.updateCodeSelectorStatus(); this.currency.updateCodeSelectorStatus();
this.currency.form.updateMinDate(); this.currency.form.updateMinDate();
this.#resetContent();
this.#resetDeleteLineItemButtons(); this.#resetDeleteLineItemButtons();
} }
@ -653,27 +683,48 @@ class DebitCreditSubForm {
*/ */
#resetDeleteLineItemButtons() { #resetDeleteLineItemButtons() {
if (this.lineItems.length === 1) { if (this.lineItems.length === 1) {
this.lineItems[0].deleteButton.classList.add("d-none"); this.lineItems[0].setDeleteButtonShown(false);
} else { } else {
for (const lineItem of this.lineItems) { for (const lineItem of this.lineItems) {
if (lineItem.isMatched) { lineItem.setDeleteButtonShown(!lineItem.isMatched);
lineItem.deleteButton.classList.add("d-none");
} else {
lineItem.deleteButton.classList.remove("d-none");
}
} }
} }
} }
/**
* Resets the layout of the content.
*
*/
#resetContent() {
if (this.lineItems.length === 0) {
this.#element.classList.remove("accounting-not-empty");
this.#element.classList.add("accounting-clickable");
this.#element.dataset.bsToggle = "modal"
this.#element.dataset.bsTarget = "#" + this.currency.form.lineItemEditor.modal.id;
this.#element.onclick = () => {
this.#element.classList.add("accounting-not-empty");
this.currency.form.lineItemEditor.onAddNew(this);
};
this.#content.classList.add("d-none");
} else {
this.#element.classList.add("accounting-not-empty");
this.#element.classList.remove("accounting-clickable");
delete this.#element.dataset.bsToggle;
delete this.#element.dataset.bsTarget;
this.#element.onclick = null;
this.#content.classList.remove("d-none");
}
}
/** /**
* Returns the total amount. * Returns the total amount.
* *
* @return {Decimal} the total amount * @return {Decimal} the total amount
*/ */
getTotal() { get total() {
let total = new Decimal("0"); let total = new Decimal("0");
for (const lineItem of this.lineItems) { for (const lineItem of this.lineItems) {
const amount = lineItem.getAmount(); const amount = lineItem.amount;
if (amount !== null) { if (amount !== null) {
total = total.plus(amount); total = total.plus(amount);
} }
@ -686,7 +737,7 @@ class DebitCreditSubForm {
* *
*/ */
updateTotal() { updateTotal() {
this.#total.innerText = formatDecimal(this.getTotal()); this.#total.innerText = formatDecimal(this.total);
this.currency.validateBalance(); this.currency.validateBalance();
} }
@ -696,10 +747,8 @@ class DebitCreditSubForm {
*/ */
#initializeDragAndDropReordering() { #initializeDragAndDropReordering() {
initializeDragAndDropReordering(this.#lineItemList, () => { initializeDragAndDropReordering(this.#lineItemList, () => {
const lineItemId = Array.from(this.#lineItemList.children).map((lineItem) => lineItem.id); for (const lineItem of this.lineItems) {
this.lineItems.sort((a, b) => lineItemId.indexOf(a.element.id) - lineItemId.indexOf(b.element.id)); lineItem.resetNo();
for (let i = 0; i < this.lineItems.length; i++) {
this.lineItems[i].no.value = String(i + 1);
} }
}); });
} }
@ -751,7 +800,7 @@ class LineItemSubForm {
* The element * The element
* @type {HTMLLIElement} * @type {HTMLLIElement}
*/ */
element; #element;
/** /**
* Either "debit" or "credit" * Either "debit" or "credit"
@ -763,7 +812,7 @@ class LineItemSubForm {
* The line item index * The line item index
* @type {number} * @type {number}
*/ */
lineItemIndex; index;
/** /**
* Whether this is an original line item with offsets * Whether this is an original line item with offsets
@ -771,12 +820,6 @@ class LineItemSubForm {
*/ */
isMatched; isMatched;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix;
/** /**
* The control * The control
* @type {HTMLDivElement} * @type {HTMLDivElement}
@ -793,7 +836,7 @@ class LineItemSubForm {
* The number * The number
* @type {HTMLInputElement} * @type {HTMLInputElement}
*/ */
no; #no;
/** /**
* The account code * The account code
@ -853,7 +896,7 @@ class LineItemSubForm {
* The button to delete line item * The button to delete line item
* @type {HTMLButtonElement} * @type {HTMLButtonElement}
*/ */
deleteButton; #deleteButton;
/** /**
* Constructs the line item sub-form. * Constructs the line item sub-form.
@ -863,38 +906,47 @@ class LineItemSubForm {
*/ */
constructor(debitCredit, element) { constructor(debitCredit, element) {
this.debitCreditSubForm = debitCredit; this.debitCreditSubForm = debitCredit;
this.element = element; this.#element = element;
this.debitCredit = element.dataset.debitCredit; this.debitCredit = element.dataset.debitCredit;
this.lineItemIndex = parseInt(element.dataset.lineItemIndex); this.index = parseInt(element.dataset.lineItemIndex);
this.isMatched = element.classList.contains("accounting-matched-line-item"); this.isMatched = element.classList.contains("accounting-matched-line-item");
this.#prefix = "accounting-currency-" + element.dataset.currencyIndex + "-" + this.debitCredit + "-" + this.lineItemIndex; const prefix = "accounting-currency-" + element.dataset.currencyIndex + "-" + this.debitCredit + "-" + String(this.index);
this.#control = document.getElementById(this.#prefix + "-control"); this.#control = document.getElementById(prefix + "-control");
this.#error = document.getElementById(this.#prefix + "-error"); this.#error = document.getElementById(prefix + "-error");
this.no = document.getElementById(this.#prefix + "-no"); this.#no = document.getElementById(prefix + "-no");
this.#accountCode = document.getElementById(this.#prefix + "-account-code"); this.#accountCode = document.getElementById(prefix + "-account-code");
this.#accountText = document.getElementById(this.#prefix + "-account-text"); this.#accountText = document.getElementById(prefix + "-account-text");
this.#description = document.getElementById(this.#prefix + "-description"); this.#description = document.getElementById(prefix + "-description");
this.#descriptionText = document.getElementById(this.#prefix + "-description-text"); this.#descriptionText = document.getElementById(prefix + "-description-text");
this.#originalLineItemId = document.getElementById(this.#prefix + "-original-line-item-id"); this.#originalLineItemId = document.getElementById(prefix + "-original-line-item-id");
this.#originalLineItemText = document.getElementById(this.#prefix + "-original-line-item-text"); this.#originalLineItemText = document.getElementById(prefix + "-original-line-item-text");
this.#offsets = document.getElementById(this.#prefix + "-offsets"); this.#offsets = document.getElementById(prefix + "-offsets");
this.#amount = document.getElementById(this.#prefix + "-amount"); this.#amount = document.getElementById(prefix + "-amount");
this.#amountText = document.getElementById(this.#prefix + "-amount-text"); this.#amountText = document.getElementById(prefix + "-amount-text");
this.deleteButton = document.getElementById(this.#prefix + "-delete"); this.#deleteButton = document.getElementById(prefix + "-delete");
this.#control.onclick = () => this.debitCreditSubForm.currency.form.lineItemEditor.onEdit(this); this.#control.onclick = () => this.debitCreditSubForm.currency.form.lineItemEditor.onEdit(this);
this.deleteButton.onclick = () => { this.#deleteButton.onclick = () => {
this.element.parentElement.removeChild(this.element); this.#element.parentElement.removeChild(this.#element);
this.debitCreditSubForm.deleteLineItem(this); this.debitCreditSubForm.deleteLineItem(this);
}; };
} }
/**
* Reset the order number.
*
*/
resetNo() {
const siblings = Array.from(this.#element.parentElement.children);
this.#no.value = String(siblings.indexOf(this.#element) + 1);
}
/** /**
* Returns whether the line item needs offset. * Returns whether the line item needs offset.
* *
* @return {boolean} true if the line item needs offset, or false otherwise * @return {boolean} true if the line item needs offset, or false otherwise
*/ */
isNeedOffset() { get isNeedOffset() {
return "isNeedOffset" in this.element.dataset; return "isNeedOffset" in this.#element.dataset;
} }
/** /**
@ -902,7 +954,7 @@ class LineItemSubForm {
* *
* @return {string|null} the ID of the original line item * @return {string|null} the ID of the original line item
*/ */
getOriginalLineItemId() { get originalLineItemId() {
return this.#originalLineItemId.value === ""? null: this.#originalLineItemId.value; return this.#originalLineItemId.value === ""? null: this.#originalLineItemId.value;
} }
@ -911,7 +963,7 @@ class LineItemSubForm {
* *
* @return {string|null} the date of the original line item * @return {string|null} the date of the original line item
*/ */
getOriginalLineItemDate() { get originalLineItemDate() {
return this.#originalLineItemId.dataset.date === ""? null: this.#originalLineItemId.dataset.date; return this.#originalLineItemId.dataset.date === ""? null: this.#originalLineItemId.dataset.date;
} }
@ -920,7 +972,7 @@ class LineItemSubForm {
* *
* @return {string|null} the text of the original line item * @return {string|null} the text of the original line item
*/ */
getOriginalLineItemText() { get originalLineItemText() {
return this.#originalLineItemId.dataset.text === ""? null: this.#originalLineItemId.dataset.text; return this.#originalLineItemId.dataset.text === ""? null: this.#originalLineItemId.dataset.text;
} }
@ -929,7 +981,7 @@ class LineItemSubForm {
* *
* @return {string|null} the description * @return {string|null} the description
*/ */
getDescription() { get description() {
return this.#description.value === ""? null: this.#description.value; return this.#description.value === ""? null: this.#description.value;
} }
@ -938,7 +990,7 @@ class LineItemSubForm {
* *
* @return {string|null} the account code * @return {string|null} the account code
*/ */
getAccountCode() { get accountCode() {
return this.#accountCode.value === ""? null: this.#accountCode.value; return this.#accountCode.value === ""? null: this.#accountCode.value;
} }
@ -947,7 +999,7 @@ class LineItemSubForm {
* *
* @return {string|null} the account text * @return {string|null} the account text
*/ */
getAccountText() { get accountText() {
return this.#accountCode.dataset.text === ""? null: this.#accountCode.dataset.text; return this.#accountCode.dataset.text === ""? null: this.#accountCode.dataset.text;
} }
@ -956,7 +1008,7 @@ class LineItemSubForm {
* *
* @return {Decimal|null} the amount * @return {Decimal|null} the amount
*/ */
getAmount() { get amount() {
return this.#amount.value === ""? null: new Decimal(this.#amount.value); return this.#amount.value === ""? null: new Decimal(this.#amount.value);
} }
@ -965,10 +1017,23 @@ class LineItemSubForm {
* *
* @return {Decimal|null} the minimal amount * @return {Decimal|null} the minimal amount
*/ */
getAmountMin() { get amountMin() {
return this.#amount.dataset.min === ""? null: new Decimal(this.#amount.dataset.min); return this.#amount.dataset.min === ""? null: new Decimal(this.#amount.dataset.min);
} }
/**
* Sets whether the delete button is shown.
*
* @param isShown {boolean} true to show, or false otherwise
*/
setDeleteButtonShown(isShown) {
if (isShown) {
this.#deleteButton.classList.remove("d-none");
} else {
this.#deleteButton.classList.add("d-none");
}
}
/** /**
* Validates the form. * Validates the form.
* *

View File

@ -44,7 +44,7 @@ class JournalEntryLineItemEditor {
* The bootstrap modal * The bootstrap modal
* @type {HTMLDivElement} * @type {HTMLDivElement}
*/ */
#modal; modal;
/** /**
* Either "debit" or "credit" * Either "debit" or "credit"
@ -53,7 +53,7 @@ class JournalEntryLineItemEditor {
debitCredit; debitCredit;
/** /**
* The prefix of the HTML ID and class * The prefix of the HTML ID and class names
* @type {string} * @type {string}
*/ */
#prefix = "accounting-line-item-editor" #prefix = "accounting-line-item-editor"
@ -190,12 +190,6 @@ class JournalEntryLineItemEditor {
*/ */
description = null; description = null;
/**
* The amount
* @type {string}
*/
amount = "";
/** /**
* The description editors * The description editors
* @type {{debit: DescriptionEditor, credit: DescriptionEditor}} * @type {{debit: DescriptionEditor, credit: DescriptionEditor}}
@ -204,7 +198,7 @@ class JournalEntryLineItemEditor {
/** /**
* The account selectors * The account selectors
* @type {{debit: AccountSelector, credit: AccountSelector}} * @type {{debit: JournalEntryAccountSelector, credit: JournalEntryAccountSelector}}
*/ */
#accountSelectors; #accountSelectors;
@ -222,7 +216,7 @@ class JournalEntryLineItemEditor {
constructor(form) { constructor(form) {
this.form = form; this.form = form;
this.#element = document.getElementById(this.#prefix); this.#element = document.getElementById(this.#prefix);
this.#modal = document.getElementById(this.#prefix + "-modal"); this.modal = document.getElementById(this.#prefix + "-modal");
this.#originalLineItemContainer = document.getElementById(this.#prefix + "-original-line-item-container"); this.#originalLineItemContainer = document.getElementById(this.#prefix + "-original-line-item-container");
this.#originalLineItemControl = document.getElementById(this.#prefix + "-original-line-item-control"); this.#originalLineItemControl = document.getElementById(this.#prefix + "-original-line-item-control");
this.#originalLineItemText = document.getElementById(this.#prefix + "-original-line-item"); this.#originalLineItemText = document.getElementById(this.#prefix + "-original-line-item");
@ -237,8 +231,9 @@ class JournalEntryLineItemEditor {
this.#amountInput = document.getElementById(this.#prefix + "-amount"); this.#amountInput = document.getElementById(this.#prefix + "-amount");
this.#amountError = document.getElementById(this.#prefix + "-amount-error"); this.#amountError = document.getElementById(this.#prefix + "-amount-error");
this.#descriptionEditors = DescriptionEditor.getInstances(this); this.#descriptionEditors = DescriptionEditor.getInstances(this);
this.#accountSelectors = AccountSelector.getInstances(this); this.#accountSelectors = JournalEntryAccountSelector.getInstances(this);
this.originalLineItemSelector = new OriginalLineItemSelector(this); this.originalLineItemSelector = new OriginalLineItemSelector(this);
this.#originalLineItemControl.onclick = () => this.originalLineItemSelector.onOpen() this.#originalLineItemControl.onclick = () => this.originalLineItemSelector.onOpen()
this.#originalLineItemDelete.onclick = () => this.clearOriginalLineItem(); this.#originalLineItemDelete.onclick = () => this.clearOriginalLineItem();
this.#descriptionControl.onclick = () => this.#descriptionEditors[this.debitCredit].onOpen(); this.#descriptionControl.onclick = () => this.#descriptionEditors[this.debitCredit].onOpen();
@ -249,12 +244,30 @@ class JournalEntryLineItemEditor {
if (this.lineItem === null) { if (this.lineItem === null) {
this.lineItem = this.#debitCreditSubForm.addLineItem(); this.lineItem = this.#debitCreditSubForm.addLineItem();
} }
this.amount = this.#amountInput.value;
this.lineItem.save(this); this.lineItem.save(this);
bootstrap.Modal.getInstance(this.#modal).hide(); bootstrap.Modal.getInstance(this.modal).hide();
} }
return false; return false;
}; };
this.modal.addEventListener("hidden.bs.modal", () => this.#debitCreditSubForm.onLineItemEditorClosed());
}
/**
* Returns the amount.
*
* @return {string} the amount
*/
get amount() {
return this.#amountInput.value;
}
/**
* Returns the currency code.
*
* @return {string} the currency code
*/
get currencyCode() {
return this.#debitCreditSubForm.currency.currencyCode;
} }
/** /**
@ -308,15 +321,6 @@ class JournalEntryLineItemEditor {
this.#amountInput.max = ""; this.#amountInput.max = "";
} }
/**
* Returns the currency code.
*
* @return {string} the currency code
*/
getCurrencyCode() {
return this.#debitCreditSubForm.currency.getCurrencyCode();
}
/** /**
* Saves the description from the description editor. * Saves the description from the description editor.
* *
@ -331,7 +335,7 @@ class JournalEntryLineItemEditor {
this.description = description === ""? null: description; this.description = description === ""? null: description;
this.#descriptionText.innerText = description; this.#descriptionText.innerText = description;
this.#validateDescription(); this.#validateDescription();
bootstrap.Modal.getOrCreateInstance(this.#modal).show(); bootstrap.Modal.getOrCreateInstance(this.modal).show();
} }
/** /**
@ -366,18 +370,16 @@ class JournalEntryLineItemEditor {
} }
/** /**
* Sets the account. * Saves the selected account.
* *
* @param code {string} the account code * @param account {JournalEntryAccountOption} the selected account
* @param text {string} the account text
* @param isNeedOffset {boolean} true if the line items in the account need offset or false otherwise
*/ */
saveAccount(code, text, isNeedOffset) { saveAccount(account) {
this.isNeedOffset = isNeedOffset; this.isNeedOffset = account.isNeedOffset;
this.#accountControl.classList.add("accounting-not-empty"); this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = code; this.accountCode = account.code;
this.accountText = text; this.accountText = account.text;
this.#accountText.innerText = text; this.#accountText.innerText = account.text;
this.#validateAccount(); this.#validateAccount();
} }
@ -520,10 +522,10 @@ class JournalEntryLineItemEditor {
this.lineItem = lineItem; this.lineItem = lineItem;
this.#debitCreditSubForm = lineItem.debitCreditSubForm; this.#debitCreditSubForm = lineItem.debitCreditSubForm;
this.debitCredit = this.#debitCreditSubForm.debitCredit; this.debitCredit = this.#debitCreditSubForm.debitCredit;
this.isNeedOffset = lineItem.isNeedOffset(); this.isNeedOffset = lineItem.isNeedOffset;
this.originalLineItemId = lineItem.getOriginalLineItemId(); this.originalLineItemId = lineItem.originalLineItemId;
this.originalLineItemDate = lineItem.getOriginalLineItemDate(); this.originalLineItemDate = lineItem.originalLineItemDate;
this.originalLineItemText = lineItem.getOriginalLineItemText(); this.originalLineItemText = lineItem.originalLineItemText;
this.#originalLineItemText.innerText = this.originalLineItemText; this.#originalLineItemText.innerText = this.originalLineItemText;
if (this.originalLineItemId === null) { if (this.originalLineItemId === null) {
this.#originalLineItemContainer.classList.add("d-none"); this.#originalLineItemContainer.classList.add("d-none");
@ -533,25 +535,25 @@ class JournalEntryLineItemEditor {
this.#originalLineItemControl.classList.add("accounting-not-empty"); this.#originalLineItemControl.classList.add("accounting-not-empty");
} }
this.#setEnableDescriptionAccount(!lineItem.isMatched && this.originalLineItemId === null); this.#setEnableDescriptionAccount(!lineItem.isMatched && this.originalLineItemId === null);
this.description = lineItem.getDescription(); this.description = lineItem.description;
if (this.description === null) { if (this.description === null) {
this.#descriptionControl.classList.remove("accounting-not-empty"); this.#descriptionControl.classList.remove("accounting-not-empty");
} else { } else {
this.#descriptionControl.classList.add("accounting-not-empty"); this.#descriptionControl.classList.add("accounting-not-empty");
} }
this.#descriptionText.innerText = this.description === null? "": this.description; this.#descriptionText.innerText = this.description === null? "": this.description;
if (lineItem.getAccountCode() === null) { if (lineItem.accountCode === null) {
this.#accountControl.classList.remove("accounting-not-empty"); this.#accountControl.classList.remove("accounting-not-empty");
} else { } else {
this.#accountControl.classList.add("accounting-not-empty"); this.#accountControl.classList.add("accounting-not-empty");
} }
this.accountCode = lineItem.getAccountCode(); this.accountCode = lineItem.accountCode;
this.accountText = lineItem.getAccountText(); this.accountText = lineItem.accountText;
this.#accountText.innerText = this.accountText; this.#accountText.innerText = this.accountText;
this.#amountInput.value = lineItem.getAmount() === null? "": String(lineItem.getAmount()); this.#amountInput.value = lineItem.amount === null? "": String(lineItem.amount);
const maxAmount = this.#getMaxAmount(); const maxAmount = this.#getMaxAmount();
this.#amountInput.max = maxAmount === null? "": maxAmount; this.#amountInput.max = maxAmount === null? "": maxAmount;
this.#amountInput.min = lineItem.getAmountMin() === null? "": String(lineItem.getAmountMin()); this.#amountInput.min = lineItem.amountMin === null? "": String(lineItem.amountMin);
this.#validate(); this.#validate();
} }

View File

@ -197,7 +197,7 @@ class RecurringExpenseIncomeSubForm {
editor; editor;
/** /**
* The prefix of HTML ID and class * The prefix of the HTML ID and class names
* @type {string} * @type {string}
*/ */
#prefix; #prefix;
@ -313,10 +313,8 @@ class RecurringExpenseIncomeSubForm {
*/ */
#initializeDragAndDropReordering() { #initializeDragAndDropReordering() {
initializeDragAndDropReordering(this.#itemList, () => { initializeDragAndDropReordering(this.#itemList, () => {
const itemId = Array.from(this.#itemList.children).map((item) => item.id); for (const item of this.#items) {
this.#items.sort((a, b) => itemId.indexOf(a.element.id) - itemId.indexOf(b.element.id)); item.resetNo();
for (let i = 0; i < this.#items.length; i++) {
this.#items[i].no.value = String(i + 1);
} }
}); });
} }
@ -365,7 +363,7 @@ class RecurringItemSubForm {
* The element * The element
* @type {HTMLLIElement} * @type {HTMLLIElement}
*/ */
element; #element;
/** /**
* The item index * The item index
@ -386,10 +384,10 @@ class RecurringItemSubForm {
#error; #error;
/** /**
* The number * The order number
* @type {HTMLInputElement} * @type {HTMLInputElement}
*/ */
no; #no;
/** /**
* The name input * The name input
@ -441,12 +439,12 @@ class RecurringItemSubForm {
*/ */
constructor(expenseIncomeSubForm, element) { constructor(expenseIncomeSubForm, element) {
this.#expenseIncomeSubForm = expenseIncomeSubForm this.#expenseIncomeSubForm = expenseIncomeSubForm
this.element = element; this.#element = element;
this.itemIndex = parseInt(element.dataset.itemIndex); this.itemIndex = parseInt(element.dataset.itemIndex);
const prefix = "accounting-recurring-" + expenseIncomeSubForm.expenseIncome + "-" + element.dataset.itemIndex; const prefix = "accounting-recurring-" + expenseIncomeSubForm.expenseIncome + "-" + element.dataset.itemIndex;
this.#control = document.getElementById(prefix + "-control"); this.#control = document.getElementById(prefix + "-control");
this.#error = document.getElementById(prefix + "-error"); this.#error = document.getElementById(prefix + "-error");
this.no = document.getElementById(prefix + "-no"); this.#no = document.getElementById(prefix + "-no");
this.#name = document.getElementById(prefix + "-name"); this.#name = document.getElementById(prefix + "-name");
this.#nameText = document.getElementById(prefix + "-name-text"); this.#nameText = document.getElementById(prefix + "-name-text");
this.#accountCode = document.getElementById(prefix + "-account-code"); this.#accountCode = document.getElementById(prefix + "-account-code");
@ -457,17 +455,26 @@ class RecurringItemSubForm {
this.#control.onclick = () => this.#expenseIncomeSubForm.editor.onEdit(this); this.#control.onclick = () => this.#expenseIncomeSubForm.editor.onEdit(this);
this.deleteButton.onclick = () => { this.deleteButton.onclick = () => {
this.element.parentElement.removeChild(this.element); this.#element.parentElement.removeChild(this.#element);
this.#expenseIncomeSubForm.deleteItem(this); this.#expenseIncomeSubForm.deleteItem(this);
}; };
} }
/**
* Reset the order number.
*
*/
resetNo() {
const siblings = Array.from(this.#element.parentElement.children);
this.#no.value = String(siblings.indexOf(this.#element) + 1);
}
/** /**
* Returns the name. * Returns the name.
* *
* @return {string|null} the name * @return {string|null} the name
*/ */
getName() { get name() {
return this.#name.value === ""? null: this.#name.value; return this.#name.value === ""? null: this.#name.value;
} }
@ -476,7 +483,7 @@ class RecurringItemSubForm {
* *
* @return {string|null} the account code * @return {string|null} the account code
*/ */
getAccountCode() { get accountCode() {
return this.#accountCode.value === ""? null: this.#accountCode.value; return this.#accountCode.value === ""? null: this.#accountCode.value;
} }
@ -485,7 +492,7 @@ class RecurringItemSubForm {
* *
* @return {string|null} the account text * @return {string|null} the account text
*/ */
getAccountText() { get accountText() {
return this.#accountCode.dataset.text === ""? null: this.#accountCode.dataset.text; return this.#accountCode.dataset.text === ""? null: this.#accountCode.dataset.text;
} }
@ -494,7 +501,7 @@ class RecurringItemSubForm {
* *
* @return {string|null} the description template * @return {string|null} the description template
*/ */
getDescriptionTemplate() { get descriptionTemplate() {
return this.#descriptionTemplate.value === ""? null: this.#descriptionTemplate.value; return this.#descriptionTemplate.value === ""? null: this.#descriptionTemplate.value;
} }
@ -504,12 +511,12 @@ class RecurringItemSubForm {
* @param editor {RecurringItemEditor} the recurring item editor * @param editor {RecurringItemEditor} the recurring item editor
*/ */
save(editor) { save(editor) {
this.#name.value = editor.getName() === null? "": editor.getName(); this.#name.value = editor.name === null? "": editor.name;
this.#nameText.innerText = this.#name.value; this.#nameText.innerText = this.#name.value;
this.#accountCode.value = editor.accountCode; this.#accountCode.value = editor.accountCode;
this.#accountCode.dataset.text = editor.accountText; this.#accountCode.dataset.text = editor.accountText;
this.#accountText.innerText = editor.accountText; this.#accountText.innerText = editor.accountText;
this.#descriptionTemplate.value = editor.getDescriptionTemplate() === null? "": editor.getDescriptionTemplate(); this.#descriptionTemplate.value = editor.descriptionTemplate === null? "": editor.descriptionTemplate;
this.#descriptionTemplateText.innerText = this.#descriptionTemplate.value; this.#descriptionTemplateText.innerText = this.#descriptionTemplate.value;
this.validate(); this.validate();
} }
@ -677,7 +684,7 @@ class RecurringItemEditor {
* *
* @return {string|null} the name * @return {string|null} the name
*/ */
getName() { get name() {
return this.#name.value === ""? null: this.#name.value; return this.#name.value === ""? null: this.#name.value;
} }
@ -686,7 +693,7 @@ class RecurringItemEditor {
* *
* @return {string|null} the description template * @return {string|null} the description template
*/ */
getDescriptionTemplate() { get descriptionTemplate() {
return this.#descriptionTemplate.value === ""? null: this.#descriptionTemplate.value; return this.#descriptionTemplate.value === ""? null: this.#descriptionTemplate.value;
} }
@ -742,16 +749,16 @@ class RecurringItemEditor {
*/ */
onEdit(item) { onEdit(item) {
this.#item = item; this.#item = item;
this.#name.value = item.getName() === null? "": item.getName(); this.#name.value = item.name === null? "": item.name;
this.accountCode = item.getAccountCode(); this.accountCode = item.accountCode;
this.accountText = item.getAccountText(); this.accountText = item.accountText;
if (this.accountText === null) { if (this.accountText === null) {
this.#accountControl.classList.remove("accounting-not-empty"); this.#accountControl.classList.remove("accounting-not-empty");
} else { } else {
this.#accountControl.classList.add("accounting-not-empty"); this.#accountControl.classList.add("accounting-not-empty");
} }
this.#accountContainer.innerText = item.getAccountText() == null? "": item.getAccountText(); this.#accountContainer.innerText = this.accountText === null? "": this.accountText;
this.#descriptionTemplate.value = item.getDescriptionTemplate() === null? "": item.getDescriptionTemplate(); this.#descriptionTemplate.value = item.descriptionTemplate === null? "": item.descriptionTemplate;
this.#validate(); this.#validate();
} }
@ -891,16 +898,16 @@ class RecurringAccountSelector {
* *
*/ */
#filterOptions() { #filterOptions() {
let hasAnyMatched = false; let isAnyMatched = false;
for (const option of this.#options) { for (const option of this.#options) {
if (option.isMatched(this.#query.value)) { if (option.isMatched(this.#query.value)) {
option.setShown(true); option.setShown(true);
hasAnyMatched = true; isAnyMatched = true;
} else { } else {
option.setShown(false); option.setShown(false);
} }
} }
if (!hasAnyMatched) { if (!isAnyMatched) {
this.#optionList.classList.add("d-none"); this.#optionList.classList.add("d-none");
this.#queryNoResult.classList.remove("d-none"); this.#queryNoResult.classList.remove("d-none");
} else { } else {
@ -916,7 +923,6 @@ class RecurringAccountSelector {
onOpen() { onOpen() {
this.#query.value = ""; this.#query.value = "";
this.#filterOptions(); this.#filterOptions();
console.log(this.editor.accountCode);
for (const option of this.#options) { for (const option of this.#options) {
option.setActive(option.code === this.editor.accountCode); option.setActive(option.code === this.editor.accountCode);
} }

View File

@ -35,7 +35,7 @@ class OriginalLineItemSelector {
lineItemEditor; lineItemEditor;
/** /**
* The prefix of the HTML ID and class * The prefix of the HTML ID and class names
* @type {string} * @type {string}
*/ */
#prefix = "accounting-original-line-item-selector"; #prefix = "accounting-original-line-item-selector";
@ -96,9 +96,7 @@ class OriginalLineItemSelector {
for (const option of this.#options) { for (const option of this.#options) {
this.#optionById[option.id] = option; this.#optionById[option.id] = option;
} }
this.#query.addEventListener("input", () => { this.#query.oninput = () => this.#filterOptions();
this.#filterOptions();
});
} }
/** /**
@ -113,8 +111,8 @@ class OriginalLineItemSelector {
const otherLineItems = form.getLineItems().filter((lineItem) => lineItem !== currentLineItem); const otherLineItems = form.getLineItems().filter((lineItem) => lineItem !== currentLineItem);
let otherOffset = new Decimal(0); let otherOffset = new Decimal(0);
for (const otherLineItem of otherLineItems) { for (const otherLineItem of otherLineItems) {
if (otherLineItem.getOriginalLineItemId() === originalLineItemId) { if (otherLineItem.originalLineItemId === originalLineItemId) {
const amount = otherLineItem.getAmount(); const amount = otherLineItem.amount;
if (amount !== null) { if (amount !== null) {
otherOffset = otherOffset.plus(amount); otherOffset = otherOffset.plus(amount);
} }
@ -131,8 +129,8 @@ class OriginalLineItemSelector {
const otherLineItems = this.lineItemEditor.form.getLineItems().filter((lineItem) => lineItem !== this.lineItemEditor.lineItem); const otherLineItems = this.lineItemEditor.form.getLineItems().filter((lineItem) => lineItem !== this.lineItemEditor.lineItem);
const otherOffsets = {} const otherOffsets = {}
for (const otherLineItem of otherLineItems) { for (const otherLineItem of otherLineItems) {
const otherOriginalLineItemId = otherLineItem.getOriginalLineItemId(); const otherOriginalLineItemId = otherLineItem.originalLineItemId;
const amount = otherLineItem.getAmount(); const amount = otherLineItem.amount;
if (otherOriginalLineItemId === null || amount === null) { if (otherOriginalLineItemId === null || amount === null) {
continue; continue;
} }
@ -155,16 +153,16 @@ class OriginalLineItemSelector {
* *
*/ */
#filterOptions() { #filterOptions() {
let hasAnyMatched = false; let isAnyMatched = false;
for (const option of this.#options) { for (const option of this.#options) {
if (option.isMatched(this.#debitCredit, this.#currencyCode, this.#query.value)) { if (option.isMatched(this.#debitCredit, this.#currencyCode, this.#query.value)) {
option.setShown(true); option.setShown(true);
hasAnyMatched = true; isAnyMatched = true;
} else { } else {
option.setShown(false); option.setShown(false);
} }
} }
if (!hasAnyMatched) { if (!isAnyMatched) {
this.#optionList.classList.add("d-none"); this.#optionList.classList.add("d-none");
this.#queryNoResult.classList.remove("d-none"); this.#queryNoResult.classList.remove("d-none");
} else { } else {
@ -178,7 +176,7 @@ class OriginalLineItemSelector {
* *
*/ */
onOpen() { onOpen() {
this.#currencyCode = this.lineItemEditor.getCurrencyCode(); this.#currencyCode = this.lineItemEditor.currencyCode;
this.#debitCredit = this.lineItemEditor.debitCredit; this.#debitCredit = this.lineItemEditor.debitCredit;
for (const option of this.#options) { for (const option of this.#options) {
option.setActive(option.id === this.lineItemEditor.originalLineItemId); option.setActive(option.id === this.lineItemEditor.originalLineItemId);
@ -275,7 +273,7 @@ class OriginalLineItem {
/** /**
* The values to query against * The values to query against
* @type {string[][]} * @type {string[]}
*/ */
#queryValues; #queryValues;
@ -341,10 +339,10 @@ class OriginalLineItem {
*/ */
isMatched(debitCredit, currencyCode, query = null) { isMatched(debitCredit, currencyCode, query = null) {
return this.netBalance.greaterThan(0) return this.netBalance.greaterThan(0)
&& this.date <= this.#selector.lineItemEditor.form.getDate() && this.date <= this.#selector.lineItemEditor.form.date
&& this.#isDebitCreditMatches(debitCredit) && this.#isDebitCreditMatched(debitCredit)
&& this.#currencyCode === currencyCode && this.#currencyCode === currencyCode
&& this.#isQueryMatches(query); && this.#isQueryMatched(query);
} }
/** /**
@ -353,34 +351,43 @@ class OriginalLineItem {
* @param debitCredit {string} either "debit" or credit * @param debitCredit {string} either "debit" or credit
* @return {boolean} true if the option matches, or false otherwise * @return {boolean} true if the option matches, or false otherwise
*/ */
#isDebitCreditMatches(debitCredit) { #isDebitCreditMatched(debitCredit) {
return (debitCredit === "debit" && this.#debitCredit === "credit") return (debitCredit === "debit" && this.#debitCredit === "credit")
|| (debitCredit === "credit" && this.#debitCredit === "debit"); || (debitCredit === "credit" && this.#debitCredit === "debit");
} }
/** /**
* Returns whether the original line item matches the query. * Returns whether the original line item matches the query term.
* *
* @param query {string|null} the query term * @param query {string|null} the query term
* @return {boolean} true if the option matches, or false otherwise * @return {boolean} true if the option matches, or false otherwise
*/ */
#isQueryMatches(query) { #isQueryMatched(query) {
if (query === "") { if (query === "") {
return true; return true;
} }
for (const queryValue of this.#queryValues[0]) { if (this.#getNetBalanceForQuery().includes(query.toLowerCase())) {
return true;
}
for (const queryValue of this.#queryValues) {
if (queryValue.toLowerCase().includes(query.toLowerCase())) { if (queryValue.toLowerCase().includes(query.toLowerCase())) {
return true; return true;
} }
} }
for (const queryValue of this.#queryValues[1]) {
if (queryValue === query) {
return true;
}
}
return false; return false;
} }
/**
* Returns the net balance in the format for query match.
*
* @return {string} the net balance in the format for query match
*/
#getNetBalanceForQuery() {
const frac = this.netBalance.modulo(1);
const whole = Number(this.netBalance.minus(frac));
return String(whole) + String(frac).substring(1);
}
/** /**
* Sets whether the option is shown. * Sets whether the option is shown.
* *

View File

@ -33,12 +33,6 @@ document.addEventListener("DOMContentLoaded", () => {
*/ */
class PeriodChooser { class PeriodChooser {
/**
* The prefix of the HTML ID and class
* @type {string}
*/
prefix;
/** /**
* The modal of the period chooser * The modal of the period chooser
* @type {HTMLDivElement} * @type {HTMLDivElement}
@ -56,8 +50,8 @@ class PeriodChooser {
* *
*/ */
constructor() { constructor() {
this.prefix = "accounting-period-chooser"; const prefix = "accounting-period-chooser";
this.modal = document.getElementById(this.prefix + "-modal"); this.modal = document.getElementById(prefix + "-modal");
for (const cls of [MonthTab, YearTab, DayTab, CustomTab]) { for (const cls of [MonthTab, YearTab, DayTab, CustomTab]) {
const tab = new cls(this); const tab = new cls(this);
this.tabPlanes[tab.tabId()] = tab; this.tabPlanes[tab.tabId()] = tab;
@ -94,7 +88,7 @@ class TabPlane {
chooser; chooser;
/** /**
* The prefix of the HTML ID and class * The prefix of the HTML ID and class names
* @type {string} * @type {string}
*/ */
prefix; prefix;

View File

@ -37,7 +37,7 @@ First written: 2023/2/25
<ul id="accounting-account-selector-{{ debit_credit }}-option-list" class="list-group accounting-selector-list"> <ul id="accounting-account-selector-{{ debit_credit }}-option-list" class="list-group accounting-selector-list">
{% for account in account_options %} {% for account in account_options %}
<li id="accounting-account-selector-{{ debit_credit }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ debit_credit }}-option {% if account.is_in_use %} accounting-account-in-use {% endif %} {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" data-code="{{ account.code }}" data-text="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal"> <li id="accounting-account-selector-{{ debit_credit }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ debit_credit }}-option {% if account.is_in_use %} accounting-account-is-in-use {% endif %} {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" data-code="{{ account.code }}" data-text="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
{{ account }} {{ account }}
</li> </li>
{% endfor %} {% endfor %}

View File

@ -20,29 +20,31 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/21 First written: 2023/3/21
#} #}
<div class="mb-2"> <div class="mb-2">
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}"> <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}" class="form-control accounting-material-text-field {% if line_item_forms %} accounting-not-empty {% else %} accounting-clickable {% endif %} {% if debit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-{{ debit_credit }}">{{ header }}</label> <label class="form-label" for="accounting-currency-{{ currency_index }}-{{ debit_credit }}">{{ header }}</label>
<ul id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-list" class="list-group accounting-line-item-list"> <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-content" class="mt-2 {% if not line_item_forms %} d-none {% endif %}">
{% for line_item_form in line_item_forms %} <ul id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-list" class="list-group accounting-line-item-list">
{% with currency_index = currency_index, {% for line_item_form in line_item_forms %}
line_item_index = loop.index, {% with currency_index = currency_index,
only_one_line_item_form = line_item_forms|length == 1, line_item_index = loop.index,
form = line_item_form.form %} only_one_line_item_form = line_item_forms|length == 1,
{% include "accounting/journal-entry/include/form-line-item.html" %} form = line_item_form.form %}
{% endwith %} {% include "accounting/journal-entry/include/form-line-item.html" %}
{% endfor %} {% endwith %}
</ul> {% endfor %}
</ul>
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between mt-2 mb-2">
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>
<div><span id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-total" class="badge rounded-pill bg-primary">{{ debit_credit_total }}</span></div> <div><span id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-total" class="badge rounded-pill bg-primary">{{ debit_credit_total }}</span></div>
</div> </div>
<div> <div>
<button id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="{{ debit_credit }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal"> <button id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="{{ debit_credit }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</button> </button>
</div>
</div> </div>
</div> </div>
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-error" class="invalid-feedback">{% if debit_credit_errors %}{{ debit_credit_errors[0] }}{% endif %}</div> <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-error" class="invalid-feedback">{% if debit_credit_errors %}{{ debit_credit_errors[0] }}{% endif %}</div>

View File

@ -25,7 +25,7 @@ First written: 2023/2/26
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/journal-entry-form.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/journal-entry-form.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/journal-entry-line-item-editor.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/journal-entry-line-item-editor.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/journal-entry-account-selector.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/original-line-item-selector.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/original-line-item-selector.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/description-editor.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/description-editor.js") }}"></script>
{% endblock %} {% endblock %}

View File

@ -38,26 +38,49 @@ First written: 2023/3/22
</a> </a>
</div> </div>
<div class="form-floating mb-3"> <table class="table table-striped table-hover table-light" aria-label="{{ A_("Settings") }}">
<input id="accounting-default-currency" class="form-control" value="{{ obj.default_currency_text }}" readonly="readonly"> <tbody>
<label class="form-label" for="accounting-default-currency">{{ A_("Default Currency") }}</label> <tr>
</div> <th scope="row">{{ A_("Default Currency") }}</th>
<td>{{ obj.default_currency_text }}</td>
</tr>
<tr>
<th scope="row">{{ A_("Default Account for the Income and Expenses Log") }}</th>
<td>{{ obj.default_ie_account_code_text }}</td>
</tr>
</tbody>
</table>
<div class="form-floating mb-3"> <h2>{{ A_("Recurring Expense") }}</h2>
<input id="accounting-default-ie-account" class="form-control" value="{{ obj.default_ie_account_code_text }}" readonly="readonly">
<label class="form-label" for="accounting-default-ie-account">{{ A_("Default Account for the Income and Expenses Log") }}</label>
</div>
{% with expense_income = "expense", {% if obj.recurring.expenses %}
label = A_("Recurring Expense"), <ul class="list-group mb-3 accounting-list-group-stripped accounting-list-group-hover">
recurring_items = obj.recurring.expenses %} {% for recurring_item in obj.recurring.expenses %}
{% include "accounting/option/include/detail-recurring-expense-income.html" %} <li class="list-group-item">
{% endwith %} <div class="small">{{ recurring_item.account_text }}</div>
<div>{{ recurring_item.name }}</div>
<div class="small">{{ recurring_item.description_template }}</div>
</li>
{% endfor %}
</ul>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% with expense_income = "income", <h2>{{ A_("Recurring Income") }}</h2>
label = A_("Recurring Income"),
recurring_items = obj.recurring.incomes %} {% if obj.recurring.incomes %}
{% include "accounting/option/include/detail-recurring-expense-income.html" %} <ul class="list-group mb-3 accounting-list-group-stripped accounting-list-group-hover">
{% endwith %} {% for recurring_item in obj.recurring.incomes %}
<li class="list-group-item">
<div class="small">{{ recurring_item.account_text }}</div>
<div>{{ recurring_item.name }}</div>
<div class="small">{{ recurring_item.description_template }}</div>
</li>
{% endfor %}
</ul>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -1,31 +0,0 @@
{#
The Mia! Accounting Flask Project
detail-recurring-expense-income.html: The recurring expense or income in the option detail
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/22
#}
<div id="accounting-recurring-{{ expense_income }}" class="form-control mb-3 accounting-material-text-field {% if recurring_items %} accounting-not-empty {% endif %}">
<label class="form-label" for="accounting-recurring-{{ expense_income }}">{{ label }}</label>
{% if recurring_items %}
<ul class="list-group mb-2 mt-2">
{% for item in recurring_items %}
{% include "accounting/option/include/detail-recurring-item.html" %}
{% endfor %}
</ul>
{% endif %}
</div>

View File

@ -1,28 +0,0 @@
{#
The Mia! Accounting Flask Project
detail-recurring-item.html: The recurring item in the option detail
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/22
#}
{# <ul> For SonarQube not to complain about incorrect HTML #}
<li class="list-group-item list-group-item-action">
<div class="small">{{ item.account_text }}</div>
<div>{{ item.name }}</div>
<div class="small">{{ item.description_template }}</div>
</li>
{# </ul> For SonarQube not to complain about incorrect HTML #}

View File

@ -27,12 +27,9 @@ from flask import Flask
from flask.testing import FlaskCliRunner from flask.testing import FlaskCliRunner
from test_site import db from test_site import db
from testlib import create_test_app, get_client, set_locale from testlib import NEXT_URI, create_test_app, get_client, set_locale
from testlib_journal_entry import add_journal_entry from testlib_journal_entry import add_journal_entry
NEXT_URI: str = "/_next"
"""The next URI."""
class AccountData: class AccountData:
"""The account data.""" """The account data."""
@ -550,8 +547,8 @@ class AccountTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Account from accounting.models import Account
editor_username, editor2_username = "editor", "editor2" editor_username, admin_username = "editor", "admin"
client, csrf_token = get_client(self.app, editor2_username) client, csrf_token = get_client(self.app, admin_username)
detail_uri: str = f"{PREFIX}/{CASH.code}" detail_uri: str = f"{PREFIX}/{CASH.code}"
update_uri: str = f"{PREFIX}/{CASH.code}/update" update_uri: str = f"{PREFIX}/{CASH.code}/update"
account: Account account: Account
@ -574,7 +571,7 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(account.created_by.username, self.assertEqual(account.created_by.username,
editor_username) editor_username)
self.assertEqual(account.updated_by.username, self.assertEqual(account.updated_by.username,
editor2_username) admin_username)
def test_l10n(self) -> None: def test_l10n(self) -> None:
"""Tests the localization. """Tests the localization.

View File

@ -28,8 +28,8 @@ from flask import Flask
from flask.testing import FlaskCliRunner from flask.testing import FlaskCliRunner
from test_site import db from test_site import db
from testlib import create_test_app, get_client, set_locale from testlib import NEXT_URI, create_test_app, get_client, set_locale
from testlib_journal_entry import add_journal_entry, NEXT_URI from testlib_journal_entry import add_journal_entry
class CurrencyData: class CurrencyData:
@ -471,8 +471,8 @@ class CurrencyTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Currency from accounting.models import Currency
editor_username, editor2_username = "editor", "editor2" editor_username, admin_username = "editor", "admin"
client, csrf_token = get_client(self.app, editor2_username) client, csrf_token = get_client(self.app, admin_username)
detail_uri: str = f"{PREFIX}/{USD.code}" detail_uri: str = f"{PREFIX}/{USD.code}"
update_uri: str = f"{PREFIX}/{USD.code}/update" update_uri: str = f"{PREFIX}/{USD.code}/update"
currency: Currency currency: Currency
@ -493,7 +493,7 @@ class CurrencyTestCase(unittest.TestCase):
with self.app.app_context(): with self.app.app_context():
currency = db.session.get(Currency, USD.code) currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.created_by.username, editor_username) self.assertEqual(currency.created_by.username, editor_username)
self.assertEqual(currency.updated_by.username, editor2_username) self.assertEqual(currency.updated_by.username, admin_username)
def test_api_exists(self) -> None: def test_api_exists(self) -> None:
"""Tests the API to check if a code exists. """Tests the API to check if a code exists.

View File

@ -24,8 +24,8 @@ from click.testing import Result
from flask import Flask from flask import Flask
from flask.testing import FlaskCliRunner from flask.testing import FlaskCliRunner
from testlib import create_test_app, get_client from testlib import NEXT_URI, Accounts, create_test_app, get_client
from testlib_journal_entry import Accounts, NEXT_URI, add_journal_entry from testlib_journal_entry import add_journal_entry
class DescriptionEditorTestCase(unittest.TestCase): class DescriptionEditorTestCase(unittest.TestCase):

View File

@ -27,9 +27,9 @@ from flask import Flask
from flask.testing import FlaskCliRunner from flask.testing import FlaskCliRunner
from test_site import db from test_site import db
from testlib import create_test_app, get_client from testlib import NEXT_URI, Accounts, create_test_app, get_client
from testlib_journal_entry import NEXT_URI, NON_EMPTY_NOTE, EMPTY_NOTE, \ from testlib_journal_entry import NON_EMPTY_NOTE, EMPTY_NOTE, \
Accounts, get_add_form, get_unchanged_update_form, get_update_form, \ get_add_form, get_unchanged_update_form, get_update_form, \
match_journal_entry_detail, set_negative_amount, \ match_journal_entry_detail, set_negative_amount, \
remove_debit_in_a_currency, remove_credit_in_a_currency, add_journal_entry remove_debit_in_a_currency, remove_credit_in_a_currency, add_journal_entry
@ -537,8 +537,8 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry from accounting.models import JournalEntry
journal_entry_id: int \ journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form()) = add_journal_entry(self.client, self.__get_add_form())
editor_username, editor2_username = "editor", "editor2" editor_username, admin_username = "editor", "admin"
client, csrf_token = get_client(self.app, editor2_username) client, csrf_token = get_client(self.app, admin_username)
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next" detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
update_uri: str = f"{PREFIX}/{journal_entry_id}/update" update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
journal_entry: JournalEntry journal_entry: JournalEntry
@ -562,7 +562,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
self.assertEqual(journal_entry.created_by.username, self.assertEqual(journal_entry.created_by.username,
editor_username) editor_username)
self.assertEqual(journal_entry.updated_by.username, self.assertEqual(journal_entry.updated_by.username,
editor2_username) admin_username)
def test_delete(self) -> None: def test_delete(self) -> None:
"""Tests to delete a journal entry. """Tests to delete a journal entry.
@ -1163,8 +1163,8 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry from accounting.models import JournalEntry
journal_entry_id: int \ journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form()) = add_journal_entry(self.client, self.__get_add_form())
editor_username, editor2_username = "editor", "editor2" editor_username, admin_username = "editor", "admin"
client, csrf_token = get_client(self.app, editor2_username) client, csrf_token = get_client(self.app, admin_username)
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next" detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
update_uri: str = f"{PREFIX}/{journal_entry_id}/update" update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
journal_entry: JournalEntry journal_entry: JournalEntry
@ -1188,7 +1188,7 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
self.assertEqual(journal_entry.created_by.username, self.assertEqual(journal_entry.created_by.username,
editor_username) editor_username)
self.assertEqual(journal_entry.updated_by.username, self.assertEqual(journal_entry.updated_by.username,
editor2_username) admin_username)
def test_delete(self) -> None: def test_delete(self) -> None:
"""Tests to delete a journal entry. """Tests to delete a journal entry.
@ -1837,8 +1837,8 @@ class TransferJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry from accounting.models import JournalEntry
journal_entry_id: int \ journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form()) = add_journal_entry(self.client, self.__get_add_form())
editor_username, editor2_username = "editor", "editor2" editor_username, admin_username = "editor", "admin"
client, csrf_token = get_client(self.app, editor2_username) client, csrf_token = get_client(self.app, admin_username)
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next" detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
update_uri: str = f"{PREFIX}/{journal_entry_id}/update" update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
journal_entry: JournalEntry journal_entry: JournalEntry
@ -1862,7 +1862,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
self.assertEqual(journal_entry.created_by.username, self.assertEqual(journal_entry.created_by.username,
editor_username) editor_username)
self.assertEqual(journal_entry.updated_by.username, self.assertEqual(journal_entry.updated_by.username,
editor2_username) admin_username)
def test_save_as_receipt(self) -> None: def test_save_as_receipt(self) -> None:
"""Tests to save a transfer journal entry as a cash receipt journal """Tests to save a transfer journal entry as a cash receipt journal

View File

@ -26,8 +26,8 @@ from flask import Flask
from flask.testing import FlaskCliRunner from flask.testing import FlaskCliRunner
from test_site import db from test_site import db
from testlib import create_test_app, get_client from testlib import Accounts, create_test_app, get_client
from testlib_journal_entry import Accounts, match_journal_entry_detail from testlib_journal_entry import match_journal_entry_detail
from testlib_offset import TestData, JournalEntryLineItemData, \ from testlib_offset import TestData, JournalEntryLineItemData, \
JournalEntryData, CurrencyData JournalEntryData, CurrencyData

View File

@ -26,8 +26,7 @@ from flask import Flask
from flask.testing import FlaskCliRunner from flask.testing import FlaskCliRunner
from test_site import db from test_site import db
from testlib import create_test_app, get_client from testlib import NEXT_URI, Accounts, create_test_app, get_client
from testlib_journal_entry import NEXT_URI, Accounts
from testlib_offset import TestData from testlib_offset import TestData
PREFIX: str = "/accounting/options" PREFIX: str = "/accounting/options"
@ -68,7 +67,7 @@ class OptionTestCase(unittest.TestCase):
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
Option.query.delete() Option.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor") self.client, self.csrf_token = get_client(self.app, "admin")
self.data: TestData = TestData(self.app, self.client, self.csrf_token) self.data: TestData = TestData(self.app, self.client, self.csrf_token)
def test_nobody(self) -> None: def test_nobody(self) -> None:
@ -105,12 +104,12 @@ class OptionTestCase(unittest.TestCase):
response = client.post(UPDATE_URI, data=self.__get_form(csrf_token)) response = client.post(UPDATE_URI, data=self.__get_form(csrf_token))
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_editor2(self) -> None: def test_editor(self) -> None:
"""Test the permission as non-administrator. """Test the permission as editor.
:return: None. :return: None.
""" """
client, csrf_token = get_client(self.app, "editor2") client, csrf_token = get_client(self.app, "editor")
response: httpx.Response response: httpx.Response
response = client.get(DETAIL_URI) response = client.get(DETAIL_URI)
@ -122,7 +121,7 @@ class OptionTestCase(unittest.TestCase):
response = client.post(UPDATE_URI, data=self.__get_form(csrf_token)) response = client.post(UPDATE_URI, data=self.__get_form(csrf_token))
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_editor(self) -> None: def test_admin(self) -> None:
"""Test the permission as administrator. """Test the permission as administrator.
:return: None. :return: None.
@ -344,7 +343,7 @@ class OptionTestCase(unittest.TestCase):
""" """
from accounting.models import Option from accounting.models import Option
from accounting.utils.user import get_user_pk from accounting.utils.user import get_user_pk
editor_username, editor2_username = "editor", "editor2" admin_username, editor_username = "admin", "editor"
option: Option | None option: Option | None
response: httpx.Response response: httpx.Response
@ -353,11 +352,11 @@ class OptionTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], DETAIL_URI) self.assertEqual(response.headers["Location"], DETAIL_URI)
with self.app.app_context(): with self.app.app_context():
editor2_pk: int = get_user_pk(editor2_username) editor_pk: int = get_user_pk(editor_username)
option = db.session.get(Option, "recurring") option = db.session.get(Option, "recurring")
self.assertIsNotNone(option) self.assertIsNotNone(option)
option.created_by_id = editor2_pk option.created_by_id = editor_pk
option.updated_by_id = editor2_pk option.updated_by_id = editor_pk
db.session.commit() db.session.commit()
form: dict[str, str] = self.__get_form() form: dict[str, str] = self.__get_form()
@ -372,8 +371,8 @@ class OptionTestCase(unittest.TestCase):
with self.app.app_context(): with self.app.app_context():
option = db.session.get(Option, "recurring") option = db.session.get(Option, "recurring")
self.assertIsNotNone(option) self.assertIsNotNone(option)
self.assertEqual(option.created_by.username, editor2_username) self.assertEqual(option.created_by.username, editor_username)
self.assertEqual(option.updated_by.username, editor_username) self.assertEqual(option.updated_by.username, admin_username)
def __get_form(self, csrf_token: str | None = None) -> dict[str, str]: def __get_form(self, csrf_token: str | None = None) -> dict[str, str]:
"""Returns the option form. """Returns the option form.

View File

@ -72,15 +72,15 @@ def create_app(is_testing: bool = False) -> Flask:
def can_view(self) -> bool: def can_view(self) -> bool:
return auth.current_user() is not None \ return auth.current_user() is not None \
and auth.current_user().username in ["viewer", "editor", and auth.current_user().username in ["viewer", "editor",
"editor2"] "admin"]
def can_edit(self) -> bool: def can_edit(self) -> bool:
return auth.current_user() is not None \ return auth.current_user() is not None \
and auth.current_user().username in ["editor", "editor2"] and auth.current_user().username in ["editor", "admin"]
def can_admin(self) -> bool: def can_admin(self) -> bool:
return auth.current_user() is not None \ return auth.current_user() is not None \
and auth.current_user().username == "editor" and auth.current_user().username == "admin"
@property @property
def cls(self) -> t.Type[auth.User]: def cls(self) -> t.Type[auth.User]:
@ -112,7 +112,7 @@ def init_db_command() -> None:
"""Initializes the database.""" """Initializes the database."""
db.create_all() db.create_all()
from .auth import User from .auth import User
for username in ["viewer", "editor", "editor2", "nobody"]: for username in ["viewer", "editor", "admin", "nobody"]:
if User.query.filter(User.username == username).first() is None: if User.query.filter(User.username == username).first() is None:
db.session.add(User(username=username)) db.session.add(User(username=username))
db.session.commit() db.session.commit()

View File

@ -58,8 +58,8 @@ def login() -> redirect:
:return: The redirection to the home page. :return: The redirection to the home page.
""" """
if request.form.get("username") not in ["viewer", "editor", "editor2", if request.form.get("username") not in {"viewer", "editor", "admin",
"nobody"]: "nobody"}:
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
session["user"] = request.form.get("username") session["user"] = request.form.get("username")
return redirect(url_for("home.home")) return redirect(url_for("home.home"))

View File

@ -29,7 +29,7 @@ First written: 2023/1/27
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-primary" type="submit" name="username" value="viewer">{{ _("Viewer") }}</button> <button class="btn btn-primary" type="submit" name="username" value="viewer">{{ _("Viewer") }}</button>
<button class="btn btn-primary" type="submit" name="username" value="editor">{{ _("Editor") }}</button> <button class="btn btn-primary" type="submit" name="username" value="editor">{{ _("Editor") }}</button>
<button class="btn btn-primary" type="submit" name="username" value="editor2">{{ _("Editor2") }}</button> <button class="btn btn-primary" type="submit" name="username" value="admin">{{ _("Administrator") }}</button>
<button class="btn btn-primary" type="submit" name="username" value="nobody">{{ _("Nobody") }}</button> <button class="btn btn-primary" type="submit" name="username" value="nobody">{{ _("Nobody") }}</button>
</form> </form>

View File

@ -9,8 +9,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n" "Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n" "Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-02-27 10:07+0800\n" "POT-Creation-Date: 2023-03-24 08:32+0800\n"
"PO-Revision-Date: 2023-02-27 10:08+0800\n" "PO-Revision-Date: 2023-03-24 08:33+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n" "Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language: zh_Hant\n" "Language: zh_Hant\n"
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n" "Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
@ -18,27 +18,27 @@ msgstr ""
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n" "Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.11.0\n" "Generated-By: Babel 2.12.1\n"
#: tests/test_site/templates/base.html:23 #: tests/test_site/templates/base.html:23
msgid "en" msgid "en"
msgstr "zh-Hant" msgstr "zh-Hant"
#: tests/test_site/templates/base.html:43 #: tests/test_site/templates/base.html:46
#: tests/test_site/templates/home.html:24 #: tests/test_site/templates/home.html:24
msgid "Home" msgid "Home"
msgstr "首頁" msgstr "首頁"
#: tests/test_site/templates/base.html:68 #: tests/test_site/templates/base.html:71
msgid "Log Out" msgid "Log Out"
msgstr "登出" msgstr "登出"
#: tests/test_site/templates/base.html:78 #: tests/test_site/templates/base.html:81
#: tests/test_site/templates/login.html:24 #: tests/test_site/templates/login.html:24
msgid "Log In" msgid "Log In"
msgstr "登入" msgstr "登入"
#: tests/test_site/templates/base.html:119 #: tests/test_site/templates/base.html:122
msgid "Error:" msgid "Error:"
msgstr "錯誤:" msgstr "錯誤:"
@ -51,8 +51,8 @@ msgid "Editor"
msgstr "記帳者" msgstr "記帳者"
#: tests/test_site/templates/login.html:32 #: tests/test_site/templates/login.html:32
msgid "Editor2" msgid "Administrator"
msgstr "記帳者2" msgstr "管理者"
#: tests/test_site/templates/login.html:33 #: tests/test_site/templates/login.html:33
msgid "Nobody" msgid "Nobody"

View File

@ -26,6 +26,33 @@ from test_site import create_app
TEST_SERVER: str = "https://testserver" TEST_SERVER: str = "https://testserver"
"""The test server URI.""" """The test server URI."""
NEXT_URI: str = "/_next"
"""The next URI."""
class Accounts:
"""The shortcuts to the common accounts."""
CASH: str = "1111-001"
PETTY_CASH: str = "1112-001"
BANK: str = "1113-001"
NOTES_RECEIVABLE: str = "1131-001"
RECEIVABLE: str = "1141-001"
PREPAID: str = "1258-001"
NOTES_PAYABLE: str = "2131-001"
PAYABLE: str = "2141-001"
SALES: str = "4111-001"
SERVICE: str = "4611-001"
AGENCY: str = "4711-001"
RENT_EXPENSE: str = "6252-001"
OFFICE: str = "6253-001"
TRAVEL: str = "6254-001"
POSTAGE: str = "6256-001"
UTILITIES: str = "6261-001"
INSURANCE: str = "6262-001"
MEAL: str = "6272-001"
INTEREST: str = "7111-001"
DONATION: str = "7481-001"
RENT_INCOME: str = "7482-001"
def create_test_app() -> Flask: def create_test_app() -> Flask:
@ -57,7 +84,6 @@ def get_csrf_token(client: httpx.Client) -> str:
return client.get("/.csrf-token").text return client.get("/.csrf-token").text
def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]: def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]:
"""Returns a user client. """Returns a user client.

View File

@ -26,40 +26,14 @@ import httpx
from flask import Flask from flask import Flask
from test_site import db from test_site import db
from testlib import NEXT_URI, Accounts
NEXT_URI: str = "/_next"
"""The next URI."""
NON_EMPTY_NOTE: str = " This is \n\na test." NON_EMPTY_NOTE: str = " This is \n\na test."
"""The stripped content of an non-empty note.""" """The stripped content of an non-empty note."""
EMPTY_NOTE: str = " \n\n " EMPTY_NOTE: str = " \n\n "
"""The empty note content.""" """The empty note content."""
class Accounts:
"""The shortcuts to the common accounts."""
CASH: str = "1111-001"
PETTY_CASH: str = "1112-001"
BANK: str = "1113-001"
NOTES_RECEIVABLE: str = "1131-001"
RECEIVABLE: str = "1141-001"
PREPAID: str = "1258-001"
NOTES_PAYABLE: str = "2131-001"
PAYABLE: str = "2141-001"
SALES: str = "4111-001"
SERVICE: str = "4611-001"
AGENCY: str = "4711-001"
RENT_EXPENSE: str = "6252-001"
OFFICE: str = "6253-001"
TRAVEL: str = "6254-001"
POSTAGE: str = "6256-001"
UTILITIES: str = "6261-001"
INSURANCE: str = "6262-001"
MEAL: str = "6272-001"
INTEREST: str = "7111-001"
DONATION: str = "7481-001"
RENT_INCOME: str = "7482-001"
def get_add_form(csrf_token: str) -> dict[str, str]: def get_add_form(csrf_token: str) -> dict[str, str]:
"""Returns the form data to add a new journal entry. """Returns the form data to add a new journal entry.

View File

@ -26,8 +26,8 @@ import httpx
from flask import Flask from flask import Flask
from test_site import db from test_site import db
from testlib_journal_entry import Accounts, match_journal_entry_detail, \ from testlib import NEXT_URI, Accounts
NEXT_URI from testlib_journal_entry import match_journal_entry_detail
class JournalEntryLineItemData: class JournalEntryLineItemData: