Compare commits
	
		
			36 Commits
		
	
	
		
			v0.9.0
			...
			bf2c7bb785
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| bf2c7bb785 | |||
| 93ba086548 | |||
| 5c4f6017b8 | |||
| cb16b2f0ff | |||
| d2f11e8779 | |||
| 4ccaf01b3c | |||
| 7c512b1c15 | |||
| dc432da398 | |||
| c8504bcbf5 | |||
| c865141583 | |||
| 8c1ecd6eac | |||
| e8e4100677 | |||
| 6a8773c531 | |||
| 30e0c7682c | |||
| eb5a7bef7e | |||
| 8a174d8847 | |||
| 7459afd63a | |||
| a9afc385e9 | |||
| a8be739ec7 | |||
| 0130bc58a9 | |||
| 821059fa80 | |||
| 5b4f57d0b3 | |||
| 4bfac2d545 | |||
| f105f0cf7b | |||
| 5e320729d7 | |||
| 7515032082 | |||
| 361b18e411 | |||
| 7d084e570e | |||
| cb397910f8 | |||
| 5f8b0dec98 | |||
| 8398d1e8bb | |||
| 562801692a | |||
| faee1e61c6 | |||
| 57a4177037 | |||
| fa1dedf207 | |||
| 7ed13dc0af | 
| @@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath('../../src/')) | ||||
| project = 'Mia! Accounting Flask' | ||||
| copyright = '2023, imacat' | ||||
| author = 'imacat' | ||||
| release = '0.9.0' | ||||
| release = '0.9.1' | ||||
|  | ||||
| # -- General configuration --------------------------------------------------- | ||||
| # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration | ||||
|   | ||||
| @@ -17,7 +17,7 @@ | ||||
|  | ||||
| [metadata] | ||||
| name = mia-accounting-flask | ||||
| version = 0.9.0 | ||||
| version = 0.9.1 | ||||
| author = imacat | ||||
| author_email = imacat@mail.imacat.idv.tw | ||||
| description = The Mia! Accounting Flask project. | ||||
|   | ||||
| @@ -760,7 +760,7 @@ class JournalEntryLineItem(db.Model): | ||||
|         setattr(self, "__net_balance", net_balance) | ||||
|  | ||||
|     @property | ||||
|     def query_values(self) -> tuple[list[str], list[str]]: | ||||
|     def query_values(self) -> list[str]: | ||||
|         """Returns the values to be queried. | ||||
|  | ||||
|         :return: The values to be queried. | ||||
| @@ -770,19 +770,11 @@ class JournalEntryLineItem(db.Model): | ||||
|             frac: Decimal = (value - whole).normalize() | ||||
|             return str(whole) + str(abs(frac))[1:] | ||||
|  | ||||
|         journal_entry_day: date = self.journal_entry.date | ||||
|         description: str = "" if self.description is None else self.description | ||||
|         return ([description], | ||||
|                 [str(journal_entry_day.year), | ||||
|                  "{}/{}".format(journal_entry_day.year, | ||||
|                                 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)]) | ||||
|         return ["{}/{}/{}".format(self.journal_entry.date.year, | ||||
|                                   self.journal_entry.date.month, | ||||
|                                   self.journal_entry.date.day), | ||||
|                 "" if self.description is None else self.description, | ||||
|                 format_amount(self.amount)] | ||||
|  | ||||
|  | ||||
| class Option(db.Model): | ||||
|   | ||||
| @@ -114,10 +114,19 @@ class AccountForm { | ||||
|         }; | ||||
|         this.#baseControl.onclick = () => { | ||||
|             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. | ||||
|      * | ||||
| @@ -129,15 +138,14 @@ class AccountForm { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sets the base account. | ||||
|      * Saves the selected base account. | ||||
|      * | ||||
|      * @param code {string} the base account code | ||||
|      * @param text {string} the text for the base account | ||||
|      * @param account {BaseAccountOption} the selected base account | ||||
|      */ | ||||
|     setBaseAccount(code, text) { | ||||
|         this.#baseCode.value = code; | ||||
|         this.#base.innerText = text; | ||||
|         if (["1", "2", "3"].includes(code.substring(0, 1))) { | ||||
|     saveBaseAccount(account) { | ||||
|         this.#baseCode.value = account.code; | ||||
|         this.#base.innerText = account.text; | ||||
|         if (["1", "2", "3"].includes(account.code.substring(0, 1))) { | ||||
|             this.#isNeedOffsetControl.classList.remove("d-none"); | ||||
|             this.#isNeedOffset.disabled = false; | ||||
|         } else { | ||||
| @@ -225,7 +233,7 @@ class BaseAccountSelector { | ||||
|      * The account form | ||||
|      * @type {AccountForm} | ||||
|      */ | ||||
|     #form; | ||||
|     form; | ||||
|  | ||||
|     /** | ||||
|      * The selector modal | ||||
| @@ -253,7 +261,7 @@ class BaseAccountSelector { | ||||
|  | ||||
|     /** | ||||
|      * The options | ||||
|      * @type {HTMLLIElement[]} | ||||
|      * @type {BaseAccountOption[]} | ||||
|      */ | ||||
|     #options; | ||||
|  | ||||
| @@ -269,83 +277,54 @@ class BaseAccountSelector { | ||||
|      * @param form {AccountForm} the form | ||||
|      */ | ||||
|     constructor(form) { | ||||
|         this.#form = form; | ||||
|         this.#modal = document.getElementById("accounting-base-selector-modal"); | ||||
|         this.#query = document.getElementById("accounting-base-selector-query"); | ||||
|         this.#optionList = document.getElementById("accounting-base-selector-option-list"); | ||||
|         // noinspection JSValidateTypes | ||||
|         this.#options = Array.from(document.getElementsByClassName("accounting-base-selector-option")); | ||||
|         this.#clearButton = document.getElementById("accounting-base-selector-clear"); | ||||
|         this.#queryNoResult = document.getElementById("accounting-base-selector-option-no-result"); | ||||
|         this.#modal.addEventListener("hidden.bs.modal", () => { | ||||
|             this.#form.onBaseAccountSelectorClosed(); | ||||
|         }); | ||||
|         for (const option of this.#options) { | ||||
|             option.onclick = () => { | ||||
|                 this.#form.setBaseAccount(option.dataset.code, option.dataset.text); | ||||
|             }; | ||||
|         } | ||||
|         this.#clearButton.onclick = () => { | ||||
|             this.#form.clearBaseAccount(); | ||||
|         }; | ||||
|         this.#initializeBaseAccountQuery(); | ||||
|         this.form = form; | ||||
|         const prefix = "accounting-base-selector"; | ||||
|         this.#modal = document.getElementById(`${prefix}-modal`); | ||||
|         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 BaseAccountOption(this, element)); | ||||
|         this.#clearButton = document.getElementById(`${prefix}-clear`); | ||||
|  | ||||
|         this.#modal.addEventListener("hidden.bs.modal", () => this.form.onBaseAccountSelectorClosed()); | ||||
|         this.#query.oninput = () => this.#filterOptions(); | ||||
|         this.#clearButton.onclick = () => this.form.clearBaseAccount(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Initializes the query. | ||||
|      * Filters the options. | ||||
|      * | ||||
|      */ | ||||
|     #initializeBaseAccountQuery() { | ||||
|         this.#query.addEventListener("input", () => { | ||||
|             if (this.#query.value === "") { | ||||
|     #filterOptions() { | ||||
|         let isAnyMatched = false; | ||||
|         for (const option of this.#options) { | ||||
|                     option.classList.remove("d-none"); | ||||
|                 } | ||||
|                 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; | ||||
|             if (option.isMatched(this.#query.value)) { | ||||
|                 option.setShown(true); | ||||
|                 isAnyMatched = true; | ||||
|             } else { | ||||
|                     option.classList.add("d-none"); | ||||
|                 option.setShown(false); | ||||
|             } | ||||
|         } | ||||
|             if (!hasAnyMatched) { | ||||
|         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. | ||||
|      * | ||||
|      * @param baseCode {string} the active base code | ||||
|      */ | ||||
|     onOpen(baseCode) { | ||||
|     onOpen() { | ||||
|         this.#query.value = ""; | ||||
|         this.#filterOptions(); | ||||
|         for (const option of this.#options) { | ||||
|             if (option.dataset.code === baseCode) { | ||||
|                 option.classList.add("active"); | ||||
|             } else { | ||||
|                 option.classList.remove("active"); | ||||
|             option.setActive(option.code === this.form.baseCode); | ||||
|         } | ||||
|         } | ||||
|         if (baseCode === "") { | ||||
|         if (this.form.baseCode === null) { | ||||
|             this.#clearButton.classList.add("btn-secondary") | ||||
|             this.#clearButton.classList.remove("btn-danger"); | ||||
|             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"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -41,7 +41,7 @@ class DescriptionEditor { | ||||
|     #form; | ||||
|  | ||||
|     /** | ||||
|      * The prefix of the HTML ID and class | ||||
|      * The prefix of the HTML ID and class names | ||||
|      * @type {string} | ||||
|      */ | ||||
|     prefix; | ||||
| @@ -278,7 +278,7 @@ class TabPlane { | ||||
|     editor; | ||||
|  | ||||
|     /** | ||||
|      * The prefix of the HTML ID and classes | ||||
|      * The prefix of the HTML ID and class names | ||||
|      * @type {string} | ||||
|      */ | ||||
|     prefix; | ||||
| @@ -984,7 +984,6 @@ class RecurringTransactionTab extends TabPlane { | ||||
|      */ | ||||
|     #itemButtons; | ||||
|  | ||||
|     // noinspection JSValidateTypes | ||||
|     /** | ||||
|      * Constructs a tab plane. | ||||
|      * | ||||
| @@ -1019,7 +1018,7 @@ class RecurringTransactionTab extends TabPlane { | ||||
|      * @return {string} the description of the recurring item | ||||
|      */ | ||||
|     #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 lastMonth = (thisMonth + 10) % 12 + 1; | ||||
|         const lastBimonthlyFrom = ((thisMonth + thisMonth % 2 + 8) % 12 + 1); | ||||
|   | ||||
							
								
								
									
										319
									
								
								src/accounting/static/js/journal-entry-account-selector.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										319
									
								
								src/accounting/static/js/journal-entry-account-selector.js
									
									
									
									
									
										Normal 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"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -111,6 +111,7 @@ class JournalEntryForm { | ||||
|     constructor() { | ||||
|         this.#element = document.getElementById("accounting-form"); | ||||
|         this.lineItemTemplate = this.#element.dataset.lineItemTemplate; | ||||
|         this.lineItemEditor = new JournalEntryLineItemEditor(this); | ||||
|         this.#date = document.getElementById("accounting-date"); | ||||
|         this.#dateError = document.getElementById("accounting-date-error"); | ||||
|         this.#currencyControl = document.getElementById("accounting-currencies"); | ||||
| @@ -121,7 +122,6 @@ class JournalEntryForm { | ||||
|         this.#addCurrencyButton = document.getElementById("accounting-add-currency"); | ||||
|         this.#note = document.getElementById("accounting-note"); | ||||
|         this.#noteError = document.getElementById("accounting-note-error"); | ||||
|         this.lineItemEditor = new JournalEntryLineItemEditor(this); | ||||
|  | ||||
|         this.#addCurrencyButton.onclick = () => { | ||||
|             const newIndex = 1 + (this.#currencies.length === 0? 0: Math.max(...this.#currencies.map((currency) => currency.index))); | ||||
| @@ -159,7 +159,7 @@ class JournalEntryForm { | ||||
|      */ | ||||
|     #resetDeleteCurrencyButtons() { | ||||
|         if (this.#currencies.length === 1) { | ||||
|             this.#currencies[0].deleteButton.classList.add("d-none"); | ||||
|             this.#currencies[0].setDeleteButtonShown(false); | ||||
|         } else { | ||||
|             for (const currency of this.#currencies) { | ||||
|                 let isAnyLineItemMatched = false; | ||||
| @@ -169,11 +169,7 @@ class JournalEntryForm { | ||||
|                         break; | ||||
|                     } | ||||
|                 } | ||||
|                 if (isAnyLineItemMatched) { | ||||
|                     currency.deleteButton.classList.add("d-none"); | ||||
|                 } else { | ||||
|                     currency.deleteButton.classList.remove("d-none"); | ||||
|                 } | ||||
|                 currency.setDeleteButtonShown(!isAnyLineItemMatched); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| @@ -184,10 +180,8 @@ class JournalEntryForm { | ||||
|      */ | ||||
|     #initializeDragAndDropReordering() { | ||||
|         initializeDragAndDropReordering(this.#currencyList, () => { | ||||
|             const currencyId = Array.from(this.#currencyList.children).map((currency) => currency.id); | ||||
|             this.#currencies.sort((a, b) => currencyId.indexOf(a.element.id) - currencyId.indexOf(b.element.id)); | ||||
|             for (let i = 0; i < this.#currencies.length; i++) { | ||||
|                 this.#currencies[i].no.value = String(i + 1); | ||||
|             for (const currency of this.#currencies) { | ||||
|                 currency.resetNo(); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| @@ -213,7 +207,7 @@ class JournalEntryForm { | ||||
|      * @return {string[]} the account codes used in the form | ||||
|      */ | ||||
|     getAccountCodesUsed(debitCredit) { | ||||
|         return this.getLineItems(debitCredit).map((lineItem) => lineItem.getAccountCode()) | ||||
|         return this.getLineItems(debitCredit).map((lineItem) => lineItem.accountCode) | ||||
|             .filter((code) => code !== null); | ||||
|     } | ||||
|  | ||||
| @@ -222,7 +216,7 @@ class JournalEntryForm { | ||||
|      * | ||||
|      * @return {string} the date | ||||
|      */ | ||||
|     getDate() { | ||||
|     get date() { | ||||
|         return this.#date.value; | ||||
|     } | ||||
|  | ||||
| @@ -233,7 +227,7 @@ class JournalEntryForm { | ||||
|     updateMinDate() { | ||||
|         let lastOriginalLineItemDate = null; | ||||
|         for (const lineItem of this.getLineItems()) { | ||||
|             const date = lineItem.getOriginalLineItemDate(); | ||||
|             const date = lineItem.originalLineItemDate; | ||||
|             if (date !== null) { | ||||
|                 if (lastOriginalLineItemDate === null || lastOriginalLineItemDate < date) { | ||||
|                     lastOriginalLineItemDate = date; | ||||
| @@ -349,7 +343,7 @@ class CurrencySubForm { | ||||
|      * The element | ||||
|      * @type {HTMLDivElement} | ||||
|      */ | ||||
|     element; | ||||
|     #element; | ||||
|  | ||||
|     /** | ||||
|      * The journal entry form | ||||
| @@ -363,12 +357,6 @@ class CurrencySubForm { | ||||
|      */ | ||||
|     index; | ||||
|  | ||||
|     /** | ||||
|      * The prefix of the HTML ID and class | ||||
|      * @type {string} | ||||
|      */ | ||||
|     #prefix; | ||||
|  | ||||
|     /** | ||||
|      * The control | ||||
|      * @type {HTMLDivElement} | ||||
| @@ -385,7 +373,7 @@ class CurrencySubForm { | ||||
|      * The number | ||||
|      * @type {HTMLInputElement} | ||||
|      */ | ||||
|     no; | ||||
|     #no; | ||||
|  | ||||
|     /** | ||||
|      * The currency code | ||||
| @@ -403,7 +391,7 @@ class CurrencySubForm { | ||||
|      * The button to delete the currency | ||||
|      * @type {HTMLButtonElement} | ||||
|      */ | ||||
|     deleteButton; | ||||
|     #deleteButton; | ||||
|  | ||||
|     /** | ||||
|      * The debit sub-form | ||||
| @@ -424,36 +412,58 @@ class CurrencySubForm { | ||||
|      * @param element {HTMLDivElement} the currency sub-form element | ||||
|      */ | ||||
|     constructor(form, element) { | ||||
|         this.element = element; | ||||
|         this.#element = element; | ||||
|         this.form = form; | ||||
|         this.index = parseInt(this.element.dataset.index); | ||||
|         this.#prefix = "accounting-currency-" + String(this.index); | ||||
|         this.#control = document.getElementById(this.#prefix + "-control"); | ||||
|         this.#error = document.getElementById(this.#prefix + "-error"); | ||||
|         this.no = document.getElementById(this.#prefix + "-no"); | ||||
|         this.#code = document.getElementById(this.#prefix + "-code"); | ||||
|         this.#codeSelect = document.getElementById(this.#prefix + "-code-select"); | ||||
|         this.deleteButton = document.getElementById(this.#prefix + "-delete"); | ||||
|         const debitElement = document.getElementById(this.#prefix + "-debit"); | ||||
|         this.index = parseInt(this.#element.dataset.index); | ||||
|         const prefix = "accounting-currency-" + String(this.index); | ||||
|         this.#control = document.getElementById(prefix + "-control"); | ||||
|         this.#error = document.getElementById(prefix + "-error"); | ||||
|         this.#no = document.getElementById(prefix + "-no"); | ||||
|         this.#code = document.getElementById(prefix + "-code"); | ||||
|         this.#codeSelect = document.getElementById(prefix + "-code-select"); | ||||
|         this.#deleteButton = document.getElementById(prefix + "-delete"); | ||||
|         const debitElement = document.getElementById(prefix + "-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.#codeSelect.onchange = () => this.#code.value = this.#codeSelect.value; | ||||
|         this.deleteButton.onclick = () => { | ||||
|             this.element.parentElement.removeChild(this.element); | ||||
|         this.#deleteButton.onclick = () => { | ||||
|             this.#element.parentElement.removeChild(this.#element); | ||||
|             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. | ||||
|      * | ||||
|      * @return {string} the currency code | ||||
|      */ | ||||
|     getCurrencyCode() { | ||||
|     get currencyCode() { | ||||
|         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. | ||||
|      * | ||||
| @@ -479,7 +489,7 @@ class CurrencySubForm { | ||||
|     updateCodeSelectorStatus() { | ||||
|         let isEnabled = true; | ||||
|         for (const lineItem of this.getLineItems()) { | ||||
|             if (lineItem.getOriginalLineItemId() !== null) { | ||||
|             if (lineItem.originalLineItemId !== null) { | ||||
|                 isEnabled = false; | ||||
|                 break; | ||||
|             } | ||||
| @@ -511,7 +521,7 @@ class CurrencySubForm { | ||||
|      */ | ||||
|     validateBalance() { | ||||
|         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.#error.innerText = A_("The totals of the debit and credit amounts do not match."); | ||||
|                 return false; | ||||
| @@ -541,6 +551,12 @@ class DebitCreditSubForm { | ||||
|      */ | ||||
|     #element; | ||||
|  | ||||
|     /** | ||||
|      * The content | ||||
|      * @type {HTMLDivElement} | ||||
|      */ | ||||
|     #content; | ||||
|  | ||||
|     /** | ||||
|      * The currencyIndex | ||||
|      * @type {number} | ||||
| @@ -554,7 +570,7 @@ class DebitCreditSubForm { | ||||
|     debitCredit; | ||||
|  | ||||
|     /** | ||||
|      * The prefix of the HTML ID and class | ||||
|      * The prefix of the HTML ID and class names | ||||
|      * @type {string} | ||||
|      */ | ||||
|     #prefix; | ||||
| @@ -602,24 +618,36 @@ class DebitCreditSubForm { | ||||
|         this.#currencyIndex = currency.index; | ||||
|         this.debitCredit = debitCredit; | ||||
|         this.#prefix = "accounting-currency-" + String(this.#currencyIndex) + "-" + debitCredit; | ||||
|         this.#content = document.getElementById(this.#prefix + "-content"); | ||||
|         this.#error = document.getElementById(this.#prefix + "-error"); | ||||
|         this.#lineItemList = document.getElementById(this.#prefix + "-list"); | ||||
|         // noinspection JSValidateTypes | ||||
|         this.lineItems = Array.from(document.getElementsByClassName(this.#prefix)).map((element) => new LineItemSubForm(this, element)); | ||||
|         this.#total = document.getElementById(this.#prefix + "-total"); | ||||
|         this.#addLineItemButton = document.getElementById(this.#prefix + "-add-line-item"); | ||||
|  | ||||
|         this.#resetContent(); | ||||
|         this.#addLineItemButton.onclick = () => this.currency.form.lineItemEditor.onAddNew(this); | ||||
|         this.#resetDeleteLineItemButtons(); | ||||
|         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 | ||||
|      * | ||||
|      * @returns {LineItemSubForm} the newly-added line item sub-form | ||||
|      */ | ||||
|     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 | ||||
|             .replaceAll("CURRENCY_INDEX", escapeHtml(String(this.#currencyIndex))) | ||||
|             .replaceAll("DEBIT_CREDIT", escapeHtml(this.debitCredit)) | ||||
| @@ -627,6 +655,7 @@ class DebitCreditSubForm { | ||||
|         this.#lineItemList.insertAdjacentHTML("beforeend", html); | ||||
|         const lineItem = new LineItemSubForm(this, document.getElementById(this.#prefix + "-" + String(newIndex))); | ||||
|         this.lineItems.push(lineItem); | ||||
|         this.#resetContent(); | ||||
|         this.#resetDeleteLineItemButtons(); | ||||
|         this.#initializeDragAndDropReordering(); | ||||
|         this.validate(); | ||||
| @@ -644,6 +673,7 @@ class DebitCreditSubForm { | ||||
|         this.updateTotal(); | ||||
|         this.currency.updateCodeSelectorStatus(); | ||||
|         this.currency.form.updateMinDate(); | ||||
|         this.#resetContent(); | ||||
|         this.#resetDeleteLineItemButtons(); | ||||
|     } | ||||
|  | ||||
| @@ -653,15 +683,36 @@ class DebitCreditSubForm { | ||||
|      */ | ||||
|     #resetDeleteLineItemButtons() { | ||||
|         if (this.lineItems.length === 1) { | ||||
|             this.lineItems[0].deleteButton.classList.add("d-none"); | ||||
|             this.lineItems[0].setDeleteButtonShown(false); | ||||
|         } else { | ||||
|             for (const lineItem of this.lineItems) { | ||||
|                 if (lineItem.isMatched) { | ||||
|                     lineItem.deleteButton.classList.add("d-none"); | ||||
|                 lineItem.setDeleteButtonShown(!lineItem.isMatched); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 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 { | ||||
|                     lineItem.deleteButton.classList.remove("d-none"); | ||||
|                 } | ||||
|             } | ||||
|             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"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -670,10 +721,10 @@ class DebitCreditSubForm { | ||||
|      * | ||||
|      * @return {Decimal} the total amount | ||||
|      */ | ||||
|     getTotal() { | ||||
|     get total() { | ||||
|         let total = new Decimal("0"); | ||||
|         for (const lineItem of this.lineItems) { | ||||
|             const amount = lineItem.getAmount(); | ||||
|             const amount = lineItem.amount; | ||||
|             if (amount !== null) { | ||||
|                 total = total.plus(amount); | ||||
|             } | ||||
| @@ -686,7 +737,7 @@ class DebitCreditSubForm { | ||||
|      * | ||||
|      */ | ||||
|     updateTotal() { | ||||
|         this.#total.innerText = formatDecimal(this.getTotal()); | ||||
|         this.#total.innerText = formatDecimal(this.total); | ||||
|         this.currency.validateBalance(); | ||||
|     } | ||||
|  | ||||
| @@ -696,10 +747,8 @@ class DebitCreditSubForm { | ||||
|      */ | ||||
|     #initializeDragAndDropReordering() { | ||||
|         initializeDragAndDropReordering(this.#lineItemList, () => { | ||||
|             const lineItemId = Array.from(this.#lineItemList.children).map((lineItem) => lineItem.id); | ||||
|             this.lineItems.sort((a, b) => lineItemId.indexOf(a.element.id) - lineItemId.indexOf(b.element.id)); | ||||
|             for (let i = 0; i < this.lineItems.length; i++) { | ||||
|                 this.lineItems[i].no.value = String(i + 1); | ||||
|             for (const lineItem of this.lineItems) { | ||||
|                 lineItem.resetNo(); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| @@ -751,7 +800,7 @@ class LineItemSubForm { | ||||
|      * The element | ||||
|      * @type {HTMLLIElement} | ||||
|      */ | ||||
|     element; | ||||
|     #element; | ||||
|  | ||||
|     /** | ||||
|      * Either "debit" or "credit" | ||||
| @@ -763,7 +812,7 @@ class LineItemSubForm { | ||||
|      * The line item index | ||||
|      * @type {number} | ||||
|      */ | ||||
|     lineItemIndex; | ||||
|     index; | ||||
|  | ||||
|     /** | ||||
|      * Whether this is an original line item with offsets | ||||
| @@ -771,12 +820,6 @@ class LineItemSubForm { | ||||
|      */ | ||||
|     isMatched; | ||||
|  | ||||
|     /** | ||||
|      * The prefix of the HTML ID and class | ||||
|      * @type {string} | ||||
|      */ | ||||
|     #prefix; | ||||
|  | ||||
|     /** | ||||
|      * The control | ||||
|      * @type {HTMLDivElement} | ||||
| @@ -793,7 +836,7 @@ class LineItemSubForm { | ||||
|      * The number | ||||
|      * @type {HTMLInputElement} | ||||
|      */ | ||||
|     no; | ||||
|     #no; | ||||
|  | ||||
|     /** | ||||
|      * The account code | ||||
| @@ -853,7 +896,7 @@ class LineItemSubForm { | ||||
|      * The button to delete line item | ||||
|      * @type {HTMLButtonElement} | ||||
|      */ | ||||
|     deleteButton; | ||||
|     #deleteButton; | ||||
|  | ||||
|     /** | ||||
|      * Constructs the line item sub-form. | ||||
| @@ -863,38 +906,47 @@ class LineItemSubForm { | ||||
|      */ | ||||
|     constructor(debitCredit, element) { | ||||
|         this.debitCreditSubForm = debitCredit; | ||||
|         this.element = element; | ||||
|         this.#element = element; | ||||
|         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.#prefix = "accounting-currency-" + element.dataset.currencyIndex + "-" + this.debitCredit + "-" + this.lineItemIndex; | ||||
|         this.#control = document.getElementById(this.#prefix + "-control"); | ||||
|         this.#error = document.getElementById(this.#prefix + "-error"); | ||||
|         this.no = document.getElementById(this.#prefix + "-no"); | ||||
|         this.#accountCode = document.getElementById(this.#prefix + "-account-code"); | ||||
|         this.#accountText = document.getElementById(this.#prefix + "-account-text"); | ||||
|         this.#description = document.getElementById(this.#prefix + "-description"); | ||||
|         this.#descriptionText = document.getElementById(this.#prefix + "-description-text"); | ||||
|         this.#originalLineItemId = document.getElementById(this.#prefix + "-original-line-item-id"); | ||||
|         this.#originalLineItemText = document.getElementById(this.#prefix + "-original-line-item-text"); | ||||
|         this.#offsets = document.getElementById(this.#prefix + "-offsets"); | ||||
|         this.#amount = document.getElementById(this.#prefix + "-amount"); | ||||
|         this.#amountText = document.getElementById(this.#prefix + "-amount-text"); | ||||
|         this.deleteButton = document.getElementById(this.#prefix + "-delete"); | ||||
|         const prefix = "accounting-currency-" + element.dataset.currencyIndex + "-" + this.debitCredit + "-" + String(this.index); | ||||
|         this.#control = document.getElementById(prefix + "-control"); | ||||
|         this.#error = document.getElementById(prefix + "-error"); | ||||
|         this.#no = document.getElementById(prefix + "-no"); | ||||
|         this.#accountCode = document.getElementById(prefix + "-account-code"); | ||||
|         this.#accountText = document.getElementById(prefix + "-account-text"); | ||||
|         this.#description = document.getElementById(prefix + "-description"); | ||||
|         this.#descriptionText = document.getElementById(prefix + "-description-text"); | ||||
|         this.#originalLineItemId = document.getElementById(prefix + "-original-line-item-id"); | ||||
|         this.#originalLineItemText = document.getElementById(prefix + "-original-line-item-text"); | ||||
|         this.#offsets = document.getElementById(prefix + "-offsets"); | ||||
|         this.#amount = document.getElementById(prefix + "-amount"); | ||||
|         this.#amountText = document.getElementById(prefix + "-amount-text"); | ||||
|         this.#deleteButton = document.getElementById(prefix + "-delete"); | ||||
|         this.#control.onclick = () => this.debitCreditSubForm.currency.form.lineItemEditor.onEdit(this); | ||||
|         this.deleteButton.onclick = () => { | ||||
|             this.element.parentElement.removeChild(this.element); | ||||
|         this.#deleteButton.onclick = () => { | ||||
|             this.#element.parentElement.removeChild(this.#element); | ||||
|             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. | ||||
|      * | ||||
|      * @return {boolean} true if the line item needs offset, or false otherwise | ||||
|      */ | ||||
|     isNeedOffset() { | ||||
|         return "isNeedOffset" in this.element.dataset; | ||||
|     get isNeedOffset() { | ||||
|         return "isNeedOffset" in this.#element.dataset; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -902,7 +954,7 @@ class LineItemSubForm { | ||||
|      * | ||||
|      * @return {string|null} the ID of the original line item | ||||
|      */ | ||||
|     getOriginalLineItemId() { | ||||
|     get originalLineItemId() { | ||||
|         return this.#originalLineItemId.value === ""? null: this.#originalLineItemId.value; | ||||
|     } | ||||
|  | ||||
| @@ -911,7 +963,7 @@ class LineItemSubForm { | ||||
|      * | ||||
|      * @return {string|null} the date of the original line item | ||||
|      */ | ||||
|     getOriginalLineItemDate() { | ||||
|     get originalLineItemDate() { | ||||
|         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 | ||||
|      */ | ||||
|     getOriginalLineItemText() { | ||||
|     get originalLineItemText() { | ||||
|         return this.#originalLineItemId.dataset.text === ""? null: this.#originalLineItemId.dataset.text; | ||||
|     } | ||||
|  | ||||
| @@ -929,7 +981,7 @@ class LineItemSubForm { | ||||
|      * | ||||
|      * @return {string|null} the description | ||||
|      */ | ||||
|     getDescription() { | ||||
|     get description() { | ||||
|         return this.#description.value === ""? null: this.#description.value; | ||||
|     } | ||||
|  | ||||
| @@ -938,7 +990,7 @@ class LineItemSubForm { | ||||
|      * | ||||
|      * @return {string|null} the account code | ||||
|      */ | ||||
|     getAccountCode() { | ||||
|     get accountCode() { | ||||
|         return this.#accountCode.value === ""? null: this.#accountCode.value; | ||||
|     } | ||||
|  | ||||
| @@ -947,7 +999,7 @@ class LineItemSubForm { | ||||
|      * | ||||
|      * @return {string|null} the account text | ||||
|      */ | ||||
|     getAccountText() { | ||||
|     get accountText() { | ||||
|         return this.#accountCode.dataset.text === ""? null: this.#accountCode.dataset.text; | ||||
|     } | ||||
|  | ||||
| @@ -956,7 +1008,7 @@ class LineItemSubForm { | ||||
|      * | ||||
|      * @return {Decimal|null} the amount | ||||
|      */ | ||||
|     getAmount() { | ||||
|     get amount() { | ||||
|         return this.#amount.value === ""? null: new Decimal(this.#amount.value); | ||||
|     } | ||||
|  | ||||
| @@ -965,10 +1017,23 @@ class LineItemSubForm { | ||||
|      * | ||||
|      * @return {Decimal|null} the minimal amount | ||||
|      */ | ||||
|     getAmountMin() { | ||||
|     get amountMin() { | ||||
|         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. | ||||
|      * | ||||
|   | ||||
| @@ -44,7 +44,7 @@ class JournalEntryLineItemEditor { | ||||
|      * The bootstrap modal | ||||
|      * @type {HTMLDivElement} | ||||
|      */ | ||||
|     #modal; | ||||
|     modal; | ||||
|  | ||||
|     /** | ||||
|      * Either "debit" or "credit" | ||||
| @@ -53,7 +53,7 @@ class JournalEntryLineItemEditor { | ||||
|     debitCredit; | ||||
|  | ||||
|     /** | ||||
|      * The prefix of the HTML ID and class | ||||
|      * The prefix of the HTML ID and class names | ||||
|      * @type {string} | ||||
|      */ | ||||
|     #prefix = "accounting-line-item-editor" | ||||
| @@ -190,12 +190,6 @@ class JournalEntryLineItemEditor { | ||||
|      */ | ||||
|     description = null; | ||||
|  | ||||
|     /** | ||||
|      * The amount | ||||
|      * @type {string} | ||||
|      */ | ||||
|     amount = ""; | ||||
|  | ||||
|     /** | ||||
|      * The description editors | ||||
|      * @type {{debit: DescriptionEditor, credit: DescriptionEditor}} | ||||
| @@ -204,7 +198,7 @@ class JournalEntryLineItemEditor { | ||||
|  | ||||
|     /** | ||||
|      * The account selectors | ||||
|      * @type {{debit: AccountSelector, credit: AccountSelector}} | ||||
|      * @type {{debit: JournalEntryAccountSelector, credit: JournalEntryAccountSelector}} | ||||
|      */ | ||||
|     #accountSelectors; | ||||
|  | ||||
| @@ -222,7 +216,7 @@ class JournalEntryLineItemEditor { | ||||
|     constructor(form) { | ||||
|         this.form = form; | ||||
|         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.#originalLineItemControl = document.getElementById(this.#prefix + "-original-line-item-control"); | ||||
|         this.#originalLineItemText = document.getElementById(this.#prefix + "-original-line-item"); | ||||
| @@ -237,8 +231,9 @@ class JournalEntryLineItemEditor { | ||||
|         this.#amountInput = document.getElementById(this.#prefix + "-amount"); | ||||
|         this.#amountError = document.getElementById(this.#prefix + "-amount-error"); | ||||
|         this.#descriptionEditors = DescriptionEditor.getInstances(this); | ||||
|         this.#accountSelectors = AccountSelector.getInstances(this); | ||||
|         this.#accountSelectors = JournalEntryAccountSelector.getInstances(this); | ||||
|         this.originalLineItemSelector = new OriginalLineItemSelector(this); | ||||
|  | ||||
|         this.#originalLineItemControl.onclick = () => this.originalLineItemSelector.onOpen() | ||||
|         this.#originalLineItemDelete.onclick = () => this.clearOriginalLineItem(); | ||||
|         this.#descriptionControl.onclick = () => this.#descriptionEditors[this.debitCredit].onOpen(); | ||||
| @@ -249,12 +244,30 @@ class JournalEntryLineItemEditor { | ||||
|                 if (this.lineItem === null) { | ||||
|                     this.lineItem = this.#debitCreditSubForm.addLineItem(); | ||||
|                 } | ||||
|                 this.amount = this.#amountInput.value; | ||||
|                 this.lineItem.save(this); | ||||
|                 bootstrap.Modal.getInstance(this.#modal).hide(); | ||||
|                 bootstrap.Modal.getInstance(this.modal).hide(); | ||||
|             } | ||||
|             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 = ""; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the currency code. | ||||
|      * | ||||
|      * @return {string} the currency code | ||||
|      */ | ||||
|     getCurrencyCode() { | ||||
|         return this.#debitCreditSubForm.currency.getCurrencyCode(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Saves the description from the description editor. | ||||
|      * | ||||
| @@ -331,7 +335,7 @@ class JournalEntryLineItemEditor { | ||||
|         this.description = description === ""? null: description; | ||||
|         this.#descriptionText.innerText = description; | ||||
|         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 text {string} the account text | ||||
|      * @param isNeedOffset {boolean} true if the line items in the account need offset or false otherwise | ||||
|      * @param account {JournalEntryAccountOption} the selected account | ||||
|      */ | ||||
|     saveAccount(code, text, isNeedOffset) { | ||||
|         this.isNeedOffset = isNeedOffset; | ||||
|     saveAccount(account) { | ||||
|         this.isNeedOffset = account.isNeedOffset; | ||||
|         this.#accountControl.classList.add("accounting-not-empty"); | ||||
|         this.accountCode = code; | ||||
|         this.accountText = text; | ||||
|         this.#accountText.innerText = text; | ||||
|         this.accountCode = account.code; | ||||
|         this.accountText = account.text; | ||||
|         this.#accountText.innerText = account.text; | ||||
|         this.#validateAccount(); | ||||
|     } | ||||
|  | ||||
| @@ -520,10 +522,10 @@ class JournalEntryLineItemEditor { | ||||
|         this.lineItem = lineItem; | ||||
|         this.#debitCreditSubForm = lineItem.debitCreditSubForm; | ||||
|         this.debitCredit = this.#debitCreditSubForm.debitCredit; | ||||
|         this.isNeedOffset = lineItem.isNeedOffset(); | ||||
|         this.originalLineItemId = lineItem.getOriginalLineItemId(); | ||||
|         this.originalLineItemDate = lineItem.getOriginalLineItemDate(); | ||||
|         this.originalLineItemText = lineItem.getOriginalLineItemText(); | ||||
|         this.isNeedOffset = lineItem.isNeedOffset; | ||||
|         this.originalLineItemId = lineItem.originalLineItemId; | ||||
|         this.originalLineItemDate = lineItem.originalLineItemDate; | ||||
|         this.originalLineItemText = lineItem.originalLineItemText; | ||||
|         this.#originalLineItemText.innerText = this.originalLineItemText; | ||||
|         if (this.originalLineItemId === null) { | ||||
|             this.#originalLineItemContainer.classList.add("d-none"); | ||||
| @@ -533,25 +535,25 @@ class JournalEntryLineItemEditor { | ||||
|             this.#originalLineItemControl.classList.add("accounting-not-empty"); | ||||
|         } | ||||
|         this.#setEnableDescriptionAccount(!lineItem.isMatched && this.originalLineItemId === null); | ||||
|         this.description = lineItem.getDescription(); | ||||
|         this.description = lineItem.description; | ||||
|         if (this.description === null) { | ||||
|             this.#descriptionControl.classList.remove("accounting-not-empty"); | ||||
|         } else { | ||||
|             this.#descriptionControl.classList.add("accounting-not-empty"); | ||||
|         } | ||||
|         this.#descriptionText.innerText = this.description === null? "": this.description; | ||||
|         if (lineItem.getAccountCode() === null) { | ||||
|         if (lineItem.accountCode === null) { | ||||
|             this.#accountControl.classList.remove("accounting-not-empty"); | ||||
|         } else { | ||||
|             this.#accountControl.classList.add("accounting-not-empty"); | ||||
|         } | ||||
|         this.accountCode = lineItem.getAccountCode(); | ||||
|         this.accountText = lineItem.getAccountText(); | ||||
|         this.accountCode = lineItem.accountCode; | ||||
|         this.accountText = lineItem.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(); | ||||
|         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(); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -197,7 +197,7 @@ class RecurringExpenseIncomeSubForm { | ||||
|     editor; | ||||
|  | ||||
|     /** | ||||
|      * The prefix of HTML ID and class | ||||
|      * The prefix of the HTML ID and class names | ||||
|      * @type {string} | ||||
|      */ | ||||
|     #prefix; | ||||
| @@ -313,10 +313,8 @@ class RecurringExpenseIncomeSubForm { | ||||
|      */ | ||||
|     #initializeDragAndDropReordering() { | ||||
|         initializeDragAndDropReordering(this.#itemList, () => { | ||||
|             const itemId = Array.from(this.#itemList.children).map((item) => item.id); | ||||
|             this.#items.sort((a, b) => itemId.indexOf(a.element.id) - itemId.indexOf(b.element.id)); | ||||
|             for (let i = 0; i < this.#items.length; i++) { | ||||
|                 this.#items[i].no.value = String(i + 1); | ||||
|             for (const item of this.#items) { | ||||
|                 item.resetNo(); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| @@ -365,7 +363,7 @@ class RecurringItemSubForm { | ||||
|      * The element | ||||
|      * @type {HTMLLIElement} | ||||
|      */ | ||||
|     element; | ||||
|     #element; | ||||
|  | ||||
|     /** | ||||
|      * The item index | ||||
| @@ -386,10 +384,10 @@ class RecurringItemSubForm { | ||||
|     #error; | ||||
|  | ||||
|     /** | ||||
|      * The number | ||||
|      * The order number | ||||
|      * @type {HTMLInputElement} | ||||
|      */ | ||||
|     no; | ||||
|     #no; | ||||
|  | ||||
|     /** | ||||
|      * The name input | ||||
| @@ -441,12 +439,12 @@ class RecurringItemSubForm { | ||||
|      */ | ||||
|     constructor(expenseIncomeSubForm, element) { | ||||
|         this.#expenseIncomeSubForm = expenseIncomeSubForm | ||||
|         this.element = element; | ||||
|         this.#element = element; | ||||
|         this.itemIndex = parseInt(element.dataset.itemIndex); | ||||
|         const prefix = "accounting-recurring-" + expenseIncomeSubForm.expenseIncome + "-" + element.dataset.itemIndex; | ||||
|         this.#control = document.getElementById(prefix + "-control"); | ||||
|         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.#nameText = document.getElementById(prefix + "-name-text"); | ||||
|         this.#accountCode = document.getElementById(prefix + "-account-code"); | ||||
| @@ -457,17 +455,26 @@ class RecurringItemSubForm { | ||||
|  | ||||
|         this.#control.onclick = () => this.#expenseIncomeSubForm.editor.onEdit(this); | ||||
|         this.deleteButton.onclick = () => { | ||||
|             this.element.parentElement.removeChild(this.element); | ||||
|             this.#element.parentElement.removeChild(this.#element); | ||||
|             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. | ||||
|      * | ||||
|      * @return {string|null} the name | ||||
|      */ | ||||
|     getName() { | ||||
|     get name() { | ||||
|         return this.#name.value === ""? null: this.#name.value; | ||||
|     } | ||||
|  | ||||
| @@ -476,7 +483,7 @@ class RecurringItemSubForm { | ||||
|      * | ||||
|      * @return {string|null} the account code | ||||
|      */ | ||||
|     getAccountCode() { | ||||
|     get accountCode() { | ||||
|         return this.#accountCode.value === ""? null: this.#accountCode.value; | ||||
|     } | ||||
|  | ||||
| @@ -485,7 +492,7 @@ class RecurringItemSubForm { | ||||
|      * | ||||
|      * @return {string|null} the account text | ||||
|      */ | ||||
|     getAccountText() { | ||||
|     get accountText() { | ||||
|         return this.#accountCode.dataset.text === ""? null: this.#accountCode.dataset.text; | ||||
|     } | ||||
|  | ||||
| @@ -494,7 +501,7 @@ class RecurringItemSubForm { | ||||
|      * | ||||
|      * @return {string|null} the description template | ||||
|      */ | ||||
|     getDescriptionTemplate() { | ||||
|     get descriptionTemplate() { | ||||
|         return this.#descriptionTemplate.value === ""? null: this.#descriptionTemplate.value; | ||||
|     } | ||||
|  | ||||
| @@ -504,12 +511,12 @@ class RecurringItemSubForm { | ||||
|      * @param editor {RecurringItemEditor} the recurring item 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.#accountCode.value = editor.accountCode; | ||||
|         this.#accountCode.dataset.text = 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.validate(); | ||||
|     } | ||||
| @@ -677,7 +684,7 @@ class RecurringItemEditor { | ||||
|      * | ||||
|      * @return {string|null} the name | ||||
|      */ | ||||
|     getName() { | ||||
|     get name() { | ||||
|         return this.#name.value === ""? null: this.#name.value; | ||||
|     } | ||||
|  | ||||
| @@ -686,7 +693,7 @@ class RecurringItemEditor { | ||||
|      * | ||||
|      * @return {string|null} the description template | ||||
|      */ | ||||
|     getDescriptionTemplate() { | ||||
|     get descriptionTemplate() { | ||||
|         return this.#descriptionTemplate.value === ""? null: this.#descriptionTemplate.value; | ||||
|     } | ||||
|  | ||||
| @@ -742,16 +749,16 @@ class RecurringItemEditor { | ||||
|      */ | ||||
|     onEdit(item) { | ||||
|         this.#item = item; | ||||
|         this.#name.value = item.getName() === null? "": item.getName(); | ||||
|         this.accountCode = item.getAccountCode(); | ||||
|         this.accountText = item.getAccountText(); | ||||
|         this.#name.value = item.name === null? "": item.name; | ||||
|         this.accountCode = item.accountCode; | ||||
|         this.accountText = item.accountText; | ||||
|         if (this.accountText === null) { | ||||
|             this.#accountControl.classList.remove("accounting-not-empty"); | ||||
|         } else { | ||||
|             this.#accountControl.classList.add("accounting-not-empty"); | ||||
|         } | ||||
|         this.#accountContainer.innerText = item.getAccountText() == null? "": item.getAccountText(); | ||||
|         this.#descriptionTemplate.value = item.getDescriptionTemplate() === null? "": item.getDescriptionTemplate(); | ||||
|         this.#accountContainer.innerText = this.accountText === null? "": this.accountText; | ||||
|         this.#descriptionTemplate.value = item.descriptionTemplate === null? "": item.descriptionTemplate; | ||||
|         this.#validate(); | ||||
|     } | ||||
|  | ||||
| @@ -891,16 +898,16 @@ class RecurringAccountSelector { | ||||
|      * | ||||
|      */ | ||||
|     #filterOptions() { | ||||
|         let hasAnyMatched = false; | ||||
|         let isAnyMatched = false; | ||||
|         for (const option of this.#options) { | ||||
|             if (option.isMatched(this.#query.value)) { | ||||
|                 option.setShown(true); | ||||
|                 hasAnyMatched = true; | ||||
|                 isAnyMatched = true; | ||||
|             } else { | ||||
|                 option.setShown(false); | ||||
|             } | ||||
|         } | ||||
|         if (!hasAnyMatched) { | ||||
|         if (!isAnyMatched) { | ||||
|             this.#optionList.classList.add("d-none"); | ||||
|             this.#queryNoResult.classList.remove("d-none"); | ||||
|         } else { | ||||
| @@ -916,7 +923,6 @@ class RecurringAccountSelector { | ||||
|     onOpen() { | ||||
|         this.#query.value = ""; | ||||
|         this.#filterOptions(); | ||||
|         console.log(this.editor.accountCode); | ||||
|         for (const option of this.#options) { | ||||
|             option.setActive(option.code === this.editor.accountCode); | ||||
|         } | ||||
|   | ||||
| @@ -35,7 +35,7 @@ class OriginalLineItemSelector { | ||||
|     lineItemEditor; | ||||
|  | ||||
|     /** | ||||
|      * The prefix of the HTML ID and class | ||||
|      * The prefix of the HTML ID and class names | ||||
|      * @type {string} | ||||
|      */ | ||||
|     #prefix = "accounting-original-line-item-selector"; | ||||
| @@ -96,9 +96,7 @@ class OriginalLineItemSelector { | ||||
|         for (const option of this.#options) { | ||||
|             this.#optionById[option.id] = option; | ||||
|         } | ||||
|         this.#query.addEventListener("input", () => { | ||||
|             this.#filterOptions(); | ||||
|         }); | ||||
|         this.#query.oninput = () => this.#filterOptions(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -113,8 +111,8 @@ class OriginalLineItemSelector { | ||||
|         const otherLineItems = form.getLineItems().filter((lineItem) => lineItem !== currentLineItem); | ||||
|         let otherOffset = new Decimal(0); | ||||
|         for (const otherLineItem of otherLineItems) { | ||||
|             if (otherLineItem.getOriginalLineItemId() === originalLineItemId) { | ||||
|                 const amount = otherLineItem.getAmount(); | ||||
|             if (otherLineItem.originalLineItemId === originalLineItemId) { | ||||
|                 const amount = otherLineItem.amount; | ||||
|                 if (amount !== null) { | ||||
|                     otherOffset = otherOffset.plus(amount); | ||||
|                 } | ||||
| @@ -131,8 +129,8 @@ class OriginalLineItemSelector { | ||||
|         const otherLineItems = this.lineItemEditor.form.getLineItems().filter((lineItem) => lineItem !== this.lineItemEditor.lineItem); | ||||
|         const otherOffsets = {} | ||||
|         for (const otherLineItem of otherLineItems) { | ||||
|             const otherOriginalLineItemId = otherLineItem.getOriginalLineItemId(); | ||||
|             const amount = otherLineItem.getAmount(); | ||||
|             const otherOriginalLineItemId = otherLineItem.originalLineItemId; | ||||
|             const amount = otherLineItem.amount; | ||||
|             if (otherOriginalLineItemId === null || amount === null) { | ||||
|                 continue; | ||||
|             } | ||||
| @@ -155,16 +153,16 @@ class OriginalLineItemSelector { | ||||
|      * | ||||
|      */ | ||||
|     #filterOptions() { | ||||
|         let hasAnyMatched = false; | ||||
|         let isAnyMatched = false; | ||||
|         for (const option of this.#options) { | ||||
|             if (option.isMatched(this.#debitCredit, this.#currencyCode, this.#query.value)) { | ||||
|                 option.setShown(true); | ||||
|                 hasAnyMatched = true; | ||||
|                 isAnyMatched = true; | ||||
|             } else { | ||||
|                 option.setShown(false); | ||||
|             } | ||||
|         } | ||||
|         if (!hasAnyMatched) { | ||||
|         if (!isAnyMatched) { | ||||
|             this.#optionList.classList.add("d-none"); | ||||
|             this.#queryNoResult.classList.remove("d-none"); | ||||
|         } else { | ||||
| @@ -178,7 +176,7 @@ class OriginalLineItemSelector { | ||||
|      * | ||||
|      */ | ||||
|     onOpen() { | ||||
|         this.#currencyCode = this.lineItemEditor.getCurrencyCode(); | ||||
|         this.#currencyCode = this.lineItemEditor.currencyCode; | ||||
|         this.#debitCredit = this.lineItemEditor.debitCredit; | ||||
|         for (const option of this.#options) { | ||||
|             option.setActive(option.id === this.lineItemEditor.originalLineItemId); | ||||
| @@ -275,7 +273,7 @@ class OriginalLineItem { | ||||
|  | ||||
|     /** | ||||
|      * The values to query against | ||||
|      * @type {string[][]} | ||||
|      * @type {string[]} | ||||
|      */ | ||||
|     #queryValues; | ||||
|  | ||||
| @@ -341,10 +339,10 @@ class OriginalLineItem { | ||||
|      */ | ||||
|     isMatched(debitCredit, currencyCode, query = null) { | ||||
|         return this.netBalance.greaterThan(0) | ||||
|             && this.date <= this.#selector.lineItemEditor.form.getDate() | ||||
|             && this.#isDebitCreditMatches(debitCredit) | ||||
|             && this.date <= this.#selector.lineItemEditor.form.date | ||||
|             && this.#isDebitCreditMatched(debitCredit) | ||||
|             && this.#currencyCode === currencyCode | ||||
|             && this.#isQueryMatches(query); | ||||
|             && this.#isQueryMatched(query); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -353,34 +351,43 @@ class OriginalLineItem { | ||||
|      * @param debitCredit {string} either "debit" or credit | ||||
|      * @return {boolean} true if the option matches, or false otherwise | ||||
|      */ | ||||
|     #isDebitCreditMatches(debitCredit) { | ||||
|     #isDebitCreditMatched(debitCredit) { | ||||
|         return (debitCredit === "debit" && this.#debitCredit === "credit") | ||||
|             || (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 | ||||
|      * @return {boolean} true if the option matches, or false otherwise | ||||
|      */ | ||||
|     #isQueryMatches(query) { | ||||
|     #isQueryMatched(query) { | ||||
|         if (query === "") { | ||||
|             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())) { | ||||
|                 return true; | ||||
|             } | ||||
|         } | ||||
|         for (const queryValue of this.#queryValues[1]) { | ||||
|             if (queryValue === query) { | ||||
|                 return true; | ||||
|             } | ||||
|         } | ||||
|         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. | ||||
|      * | ||||
|   | ||||
| @@ -33,12 +33,6 @@ document.addEventListener("DOMContentLoaded", () => { | ||||
|  */ | ||||
| class PeriodChooser { | ||||
|  | ||||
|     /** | ||||
|      * The prefix of the HTML ID and class | ||||
|      * @type {string} | ||||
|      */ | ||||
|     prefix; | ||||
|  | ||||
|     /** | ||||
|      * The modal of the period chooser | ||||
|      * @type {HTMLDivElement} | ||||
| @@ -56,8 +50,8 @@ class PeriodChooser { | ||||
|      * | ||||
|      */ | ||||
|     constructor() { | ||||
|         this.prefix = "accounting-period-chooser"; | ||||
|         this.modal = document.getElementById(this.prefix + "-modal"); | ||||
|         const prefix = "accounting-period-chooser"; | ||||
|         this.modal = document.getElementById(prefix + "-modal"); | ||||
|         for (const cls of [MonthTab, YearTab, DayTab, CustomTab]) { | ||||
|             const tab = new cls(this); | ||||
|             this.tabPlanes[tab.tabId()] = tab; | ||||
| @@ -94,7 +88,7 @@ class TabPlane { | ||||
|     chooser; | ||||
|  | ||||
|     /** | ||||
|      * The prefix of the HTML ID and class | ||||
|      * The prefix of the HTML ID and class names | ||||
|      * @type {string} | ||||
|      */ | ||||
|     prefix; | ||||
|   | ||||
| @@ -37,7 +37,7 @@ First written: 2023/2/25 | ||||
|  | ||||
|         <ul id="accounting-account-selector-{{ debit_credit }}-option-list" class="list-group accounting-selector-list"> | ||||
|           {% 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 }} | ||||
|             </li> | ||||
|           {% endfor %} | ||||
|   | ||||
| @@ -20,8 +20,9 @@ Author: imacat@mail.imacat.idv.tw (imacat) | ||||
| First written: 2023/3/21 | ||||
| #} | ||||
| <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> | ||||
|     <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-content" class="mt-2 {% if not line_item_forms %} d-none {% endif %}"> | ||||
|       <ul id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-list" class="list-group accounting-line-item-list"> | ||||
|         {% for line_item_form in line_item_forms %} | ||||
|           {% with currency_index = currency_index, | ||||
| @@ -33,7 +34,7 @@ First written: 2023/3/21 | ||||
|         {% 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><span id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-total" class="badge rounded-pill bg-primary">{{ debit_credit_total }}</span></div> | ||||
|       </div> | ||||
| @@ -45,5 +46,6 @@ First written: 2023/3/21 | ||||
|         </button> | ||||
|       </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> | ||||
|   | ||||
| @@ -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/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/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/description-editor.js") }}"></script> | ||||
| {% endblock %} | ||||
|   | ||||
| @@ -38,26 +38,49 @@ First written: 2023/3/22 | ||||
|   </a> | ||||
| </div> | ||||
|  | ||||
| <div class="form-floating mb-3"> | ||||
|   <input id="accounting-default-currency" class="form-control" value="{{ obj.default_currency_text }}" readonly="readonly"> | ||||
|   <label class="form-label" for="accounting-default-currency">{{ A_("Default Currency") }}</label> | ||||
| </div> | ||||
| <table class="table table-striped table-hover table-light" aria-label="{{ A_("Settings") }}"> | ||||
| <tbody> | ||||
| <tr> | ||||
|   <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"> | ||||
|   <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> | ||||
| <h2>{{ A_("Recurring Expense") }}</h2> | ||||
|  | ||||
| {% with expense_income = "expense", | ||||
|         label = A_("Recurring Expense"), | ||||
|         recurring_items = obj.recurring.expenses %} | ||||
|   {% include "accounting/option/include/detail-recurring-expense-income.html" %} | ||||
| {% endwith %} | ||||
| {% if obj.recurring.expenses %} | ||||
|   <ul class="list-group mb-3 accounting-list-group-stripped accounting-list-group-hover"> | ||||
|     {% for recurring_item in obj.recurring.expenses %} | ||||
|       <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 %} | ||||
|  | ||||
| {% with expense_income = "income", | ||||
|         label = A_("Recurring Income"), | ||||
|         recurring_items = obj.recurring.incomes %} | ||||
|   {% include "accounting/option/include/detail-recurring-expense-income.html" %} | ||||
| {% endwith %} | ||||
| <h2>{{ A_("Recurring Income") }}</h2> | ||||
|  | ||||
| {% if obj.recurring.incomes %} | ||||
|   <ul class="list-group mb-3 accounting-list-group-stripped accounting-list-group-hover"> | ||||
|     {% 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 %} | ||||
|   | ||||
| @@ -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> | ||||
| @@ -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 #} | ||||
| @@ -27,12 +27,9 @@ from flask import Flask | ||||
| from flask.testing import FlaskCliRunner | ||||
|  | ||||
| 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: str = "/_next" | ||||
| """The next URI.""" | ||||
|  | ||||
|  | ||||
| class AccountData: | ||||
|     """The account data.""" | ||||
| @@ -550,8 +547,8 @@ class AccountTestCase(unittest.TestCase): | ||||
|         :return: None. | ||||
|         """ | ||||
|         from accounting.models import Account | ||||
|         editor_username, editor2_username = "editor", "editor2" | ||||
|         client, csrf_token = get_client(self.app, editor2_username) | ||||
|         editor_username, admin_username = "editor", "admin" | ||||
|         client, csrf_token = get_client(self.app, admin_username) | ||||
|         detail_uri: str = f"{PREFIX}/{CASH.code}" | ||||
|         update_uri: str = f"{PREFIX}/{CASH.code}/update" | ||||
|         account: Account | ||||
| @@ -574,7 +571,7 @@ class AccountTestCase(unittest.TestCase): | ||||
|             self.assertEqual(account.created_by.username, | ||||
|                              editor_username) | ||||
|             self.assertEqual(account.updated_by.username, | ||||
|                              editor2_username) | ||||
|                              admin_username) | ||||
|  | ||||
|     def test_l10n(self) -> None: | ||||
|         """Tests the localization. | ||||
|   | ||||
| @@ -28,8 +28,8 @@ from flask import Flask | ||||
| from flask.testing import FlaskCliRunner | ||||
|  | ||||
| from test_site import db | ||||
| from testlib import create_test_app, get_client, set_locale | ||||
| from testlib_journal_entry import add_journal_entry, NEXT_URI | ||||
| from testlib import NEXT_URI, create_test_app, get_client, set_locale | ||||
| from testlib_journal_entry import add_journal_entry | ||||
|  | ||||
|  | ||||
| class CurrencyData: | ||||
| @@ -471,8 +471,8 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|         :return: None. | ||||
|         """ | ||||
|         from accounting.models import Currency | ||||
|         editor_username, editor2_username = "editor", "editor2" | ||||
|         client, csrf_token = get_client(self.app, editor2_username) | ||||
|         editor_username, admin_username = "editor", "admin" | ||||
|         client, csrf_token = get_client(self.app, admin_username) | ||||
|         detail_uri: str = f"{PREFIX}/{USD.code}" | ||||
|         update_uri: str = f"{PREFIX}/{USD.code}/update" | ||||
|         currency: Currency | ||||
| @@ -493,7 +493,7 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|         with self.app.app_context(): | ||||
|             currency = db.session.get(Currency, USD.code) | ||||
|             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: | ||||
|         """Tests the API to check if a code exists. | ||||
|   | ||||
| @@ -24,8 +24,8 @@ from click.testing import Result | ||||
| from flask import Flask | ||||
| from flask.testing import FlaskCliRunner | ||||
|  | ||||
| from testlib import create_test_app, get_client | ||||
| from testlib_journal_entry import Accounts, NEXT_URI, add_journal_entry | ||||
| from testlib import NEXT_URI, Accounts, create_test_app, get_client | ||||
| from testlib_journal_entry import add_journal_entry | ||||
|  | ||||
|  | ||||
| class DescriptionEditorTestCase(unittest.TestCase): | ||||
|   | ||||
| @@ -27,9 +27,9 @@ from flask import Flask | ||||
| from flask.testing import FlaskCliRunner | ||||
|  | ||||
| from test_site import db | ||||
| from testlib import create_test_app, get_client | ||||
| from testlib_journal_entry import NEXT_URI, NON_EMPTY_NOTE, EMPTY_NOTE, \ | ||||
|     Accounts, get_add_form, get_unchanged_update_form, get_update_form, \ | ||||
| from testlib import NEXT_URI, Accounts, create_test_app, get_client | ||||
| from testlib_journal_entry import NON_EMPTY_NOTE, EMPTY_NOTE, \ | ||||
|     get_add_form, get_unchanged_update_form, get_update_form, \ | ||||
|     match_journal_entry_detail, set_negative_amount, \ | ||||
|     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 | ||||
|         journal_entry_id: int \ | ||||
|             = add_journal_entry(self.client, self.__get_add_form()) | ||||
|         editor_username, editor2_username = "editor", "editor2" | ||||
|         client, csrf_token = get_client(self.app, editor2_username) | ||||
|         editor_username, admin_username = "editor", "admin" | ||||
|         client, csrf_token = get_client(self.app, admin_username) | ||||
|         detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next" | ||||
|         update_uri: str = f"{PREFIX}/{journal_entry_id}/update" | ||||
|         journal_entry: JournalEntry | ||||
| @@ -562,7 +562,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase): | ||||
|             self.assertEqual(journal_entry.created_by.username, | ||||
|                              editor_username) | ||||
|             self.assertEqual(journal_entry.updated_by.username, | ||||
|                              editor2_username) | ||||
|                              admin_username) | ||||
|  | ||||
|     def test_delete(self) -> None: | ||||
|         """Tests to delete a journal entry. | ||||
| @@ -1163,8 +1163,8 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase): | ||||
|         from accounting.models import JournalEntry | ||||
|         journal_entry_id: int \ | ||||
|             = add_journal_entry(self.client, self.__get_add_form()) | ||||
|         editor_username, editor2_username = "editor", "editor2" | ||||
|         client, csrf_token = get_client(self.app, editor2_username) | ||||
|         editor_username, admin_username = "editor", "admin" | ||||
|         client, csrf_token = get_client(self.app, admin_username) | ||||
|         detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next" | ||||
|         update_uri: str = f"{PREFIX}/{journal_entry_id}/update" | ||||
|         journal_entry: JournalEntry | ||||
| @@ -1188,7 +1188,7 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase): | ||||
|             self.assertEqual(journal_entry.created_by.username, | ||||
|                              editor_username) | ||||
|             self.assertEqual(journal_entry.updated_by.username, | ||||
|                              editor2_username) | ||||
|                              admin_username) | ||||
|  | ||||
|     def test_delete(self) -> None: | ||||
|         """Tests to delete a journal entry. | ||||
| @@ -1837,8 +1837,8 @@ class TransferJournalEntryTestCase(unittest.TestCase): | ||||
|         from accounting.models import JournalEntry | ||||
|         journal_entry_id: int \ | ||||
|             = add_journal_entry(self.client, self.__get_add_form()) | ||||
|         editor_username, editor2_username = "editor", "editor2" | ||||
|         client, csrf_token = get_client(self.app, editor2_username) | ||||
|         editor_username, admin_username = "editor", "admin" | ||||
|         client, csrf_token = get_client(self.app, admin_username) | ||||
|         detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next" | ||||
|         update_uri: str = f"{PREFIX}/{journal_entry_id}/update" | ||||
|         journal_entry: JournalEntry | ||||
| @@ -1862,7 +1862,7 @@ class TransferJournalEntryTestCase(unittest.TestCase): | ||||
|             self.assertEqual(journal_entry.created_by.username, | ||||
|                              editor_username) | ||||
|             self.assertEqual(journal_entry.updated_by.username, | ||||
|                              editor2_username) | ||||
|                              admin_username) | ||||
|  | ||||
|     def test_save_as_receipt(self) -> None: | ||||
|         """Tests to save a transfer journal entry as a cash receipt journal | ||||
|   | ||||
| @@ -26,8 +26,8 @@ from flask import Flask | ||||
| from flask.testing import FlaskCliRunner | ||||
|  | ||||
| from test_site import db | ||||
| from testlib import create_test_app, get_client | ||||
| from testlib_journal_entry import Accounts, match_journal_entry_detail | ||||
| from testlib import Accounts, create_test_app, get_client | ||||
| from testlib_journal_entry import match_journal_entry_detail | ||||
| from testlib_offset import TestData, JournalEntryLineItemData, \ | ||||
|     JournalEntryData, CurrencyData | ||||
|  | ||||
|   | ||||
| @@ -26,8 +26,7 @@ from flask import Flask | ||||
| from flask.testing import FlaskCliRunner | ||||
|  | ||||
| from test_site import db | ||||
| from testlib import create_test_app, get_client | ||||
| from testlib_journal_entry import NEXT_URI, Accounts | ||||
| from testlib import NEXT_URI, Accounts, create_test_app, get_client | ||||
| from testlib_offset import TestData | ||||
|  | ||||
| PREFIX: str = "/accounting/options" | ||||
| @@ -68,7 +67,7 @@ class OptionTestCase(unittest.TestCase): | ||||
|             self.assertEqual(result.exit_code, 0) | ||||
|             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) | ||||
|  | ||||
|     def test_nobody(self) -> None: | ||||
| @@ -105,12 +104,12 @@ class OptionTestCase(unittest.TestCase): | ||||
|         response = client.post(UPDATE_URI, data=self.__get_form(csrf_token)) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|     def test_editor2(self) -> None: | ||||
|         """Test the permission as non-administrator. | ||||
|     def test_editor(self) -> None: | ||||
|         """Test the permission as editor. | ||||
|  | ||||
|         :return: None. | ||||
|         """ | ||||
|         client, csrf_token = get_client(self.app, "editor2") | ||||
|         client, csrf_token = get_client(self.app, "editor") | ||||
|         response: httpx.Response | ||||
|  | ||||
|         response = client.get(DETAIL_URI) | ||||
| @@ -122,7 +121,7 @@ class OptionTestCase(unittest.TestCase): | ||||
|         response = client.post(UPDATE_URI, data=self.__get_form(csrf_token)) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|     def test_editor(self) -> None: | ||||
|     def test_admin(self) -> None: | ||||
|         """Test the permission as administrator. | ||||
|  | ||||
|         :return: None. | ||||
| @@ -344,7 +343,7 @@ class OptionTestCase(unittest.TestCase): | ||||
|         """ | ||||
|         from accounting.models import Option | ||||
|         from accounting.utils.user import get_user_pk | ||||
|         editor_username, editor2_username = "editor", "editor2" | ||||
|         admin_username, editor_username = "admin", "editor" | ||||
|         option: Option | None | ||||
|         response: httpx.Response | ||||
|  | ||||
| @@ -353,11 +352,11 @@ class OptionTestCase(unittest.TestCase): | ||||
|         self.assertEqual(response.headers["Location"], DETAIL_URI) | ||||
|  | ||||
|         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") | ||||
|             self.assertIsNotNone(option) | ||||
|             option.created_by_id = editor2_pk | ||||
|             option.updated_by_id = editor2_pk | ||||
|             option.created_by_id = editor_pk | ||||
|             option.updated_by_id = editor_pk | ||||
|             db.session.commit() | ||||
|  | ||||
|         form: dict[str, str] = self.__get_form() | ||||
| @@ -372,8 +371,8 @@ class OptionTestCase(unittest.TestCase): | ||||
|         with self.app.app_context(): | ||||
|             option = db.session.get(Option, "recurring") | ||||
|             self.assertIsNotNone(option) | ||||
|             self.assertEqual(option.created_by.username, editor2_username) | ||||
|             self.assertEqual(option.updated_by.username, editor_username) | ||||
|             self.assertEqual(option.created_by.username, editor_username) | ||||
|             self.assertEqual(option.updated_by.username, admin_username) | ||||
|  | ||||
|     def __get_form(self, csrf_token: str | None = None) -> dict[str, str]: | ||||
|         """Returns the option form. | ||||
|   | ||||
| @@ -72,15 +72,15 @@ def create_app(is_testing: bool = False) -> Flask: | ||||
|         def can_view(self) -> bool: | ||||
|             return auth.current_user() is not None \ | ||||
|                 and auth.current_user().username in ["viewer", "editor", | ||||
|                                                      "editor2"] | ||||
|                                                      "admin"] | ||||
|  | ||||
|         def can_edit(self) -> bool: | ||||
|             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: | ||||
|             return auth.current_user() is not None \ | ||||
|                 and auth.current_user().username == "editor" | ||||
|                 and auth.current_user().username == "admin" | ||||
|  | ||||
|         @property | ||||
|         def cls(self) -> t.Type[auth.User]: | ||||
| @@ -112,7 +112,7 @@ def init_db_command() -> None: | ||||
|     """Initializes the database.""" | ||||
|     db.create_all() | ||||
|     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: | ||||
|             db.session.add(User(username=username)) | ||||
|     db.session.commit() | ||||
|   | ||||
| @@ -58,8 +58,8 @@ def login() -> redirect: | ||||
|  | ||||
|     :return: The redirection to the home page. | ||||
|     """ | ||||
|     if request.form.get("username") not in ["viewer", "editor", "editor2", | ||||
|                                             "nobody"]: | ||||
|     if request.form.get("username") not in {"viewer", "editor", "admin", | ||||
|                                             "nobody"}: | ||||
|         return redirect(url_for("auth.login")) | ||||
|     session["user"] = request.form.get("username") | ||||
|     return redirect(url_for("home.home")) | ||||
|   | ||||
| @@ -29,7 +29,7 @@ First written: 2023/1/27 | ||||
|   <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="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> | ||||
| </form> | ||||
|  | ||||
|   | ||||
| @@ -9,8 +9,8 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n" | ||||
| "Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n" | ||||
| "POT-Creation-Date: 2023-02-27 10:07+0800\n" | ||||
| "PO-Revision-Date: 2023-02-27 10:08+0800\n" | ||||
| "POT-Creation-Date: 2023-03-24 08:32+0800\n" | ||||
| "PO-Revision-Date: 2023-03-24 08:33+0800\n" | ||||
| "Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n" | ||||
| "Language: zh_Hant\n" | ||||
| "Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n" | ||||
| @@ -18,27 +18,27 @@ msgstr "" | ||||
| "MIME-Version: 1.0\n" | ||||
| "Content-Type: text/plain; charset=utf-8\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 | ||||
| msgid "en" | ||||
| msgstr "zh-Hant" | ||||
|  | ||||
| #: tests/test_site/templates/base.html:43 | ||||
| #: tests/test_site/templates/base.html:46 | ||||
| #: tests/test_site/templates/home.html:24 | ||||
| msgid "Home" | ||||
| msgstr "首頁" | ||||
|  | ||||
| #: tests/test_site/templates/base.html:68 | ||||
| #: tests/test_site/templates/base.html:71 | ||||
| msgid "Log Out" | ||||
| msgstr "登出" | ||||
|  | ||||
| #: tests/test_site/templates/base.html:78 | ||||
| #: tests/test_site/templates/base.html:81 | ||||
| #: tests/test_site/templates/login.html:24 | ||||
| msgid "Log In" | ||||
| msgstr "登入" | ||||
|  | ||||
| #: tests/test_site/templates/base.html:119 | ||||
| #: tests/test_site/templates/base.html:122 | ||||
| msgid "Error:" | ||||
| msgstr "錯誤:" | ||||
|  | ||||
| @@ -51,8 +51,8 @@ msgid "Editor" | ||||
| msgstr "記帳者" | ||||
|  | ||||
| #: tests/test_site/templates/login.html:32 | ||||
| msgid "Editor2" | ||||
| msgstr "記帳者2" | ||||
| msgid "Administrator" | ||||
| msgstr "管理者" | ||||
|  | ||||
| #: tests/test_site/templates/login.html:33 | ||||
| msgid "Nobody" | ||||
|   | ||||
| @@ -26,6 +26,33 @@ from test_site import create_app | ||||
|  | ||||
| TEST_SERVER: str = "https://testserver" | ||||
| """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: | ||||
| @@ -57,7 +84,6 @@ def get_csrf_token(client: httpx.Client) -> str: | ||||
|     return client.get("/.csrf-token").text | ||||
|  | ||||
|  | ||||
|  | ||||
| def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]: | ||||
|     """Returns a user client. | ||||
|  | ||||
|   | ||||
| @@ -26,40 +26,14 @@ import httpx | ||||
| from flask import Flask | ||||
|  | ||||
| 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." | ||||
| """The stripped content of an non-empty note.""" | ||||
| EMPTY_NOTE: str = " \n\n  " | ||||
| """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]: | ||||
|     """Returns the form data to add a new journal entry. | ||||
|  | ||||
|   | ||||
| @@ -26,8 +26,8 @@ import httpx | ||||
| from flask import Flask | ||||
|  | ||||
| from test_site import db | ||||
| from testlib_journal_entry import Accounts, match_journal_entry_detail, \ | ||||
|     NEXT_URI | ||||
| from testlib import NEXT_URI, Accounts | ||||
| from testlib_journal_entry import match_journal_entry_detail | ||||
|  | ||||
|  | ||||
| class JournalEntryLineItemData: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user