Compare commits
	
		
			38 Commits
		
	
	
		
			b28d446d07
			...
			f3548a2327
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| f3548a2327 | |||
| 79883d6940 | |||
| b2bc993416 | |||
| 453b3f0da5 | |||
| 63ae3f0746 | |||
| da4cc6489f | |||
| 1102a3a4f3 | |||
| 1402a12f04 | |||
| f049b5d7ee | |||
| 14ed4ca354 | |||
| 535ff96ab3 | |||
| 57482f81fc | |||
| a31ce3c400 | |||
| 319f0aed90 | |||
| 826dcf0f86 | |||
| b2411aee74 | |||
| 731acdced0 | |||
| 35b3bca1e6 | |||
| 3c413497ae | |||
| 1b5e516413 | |||
| 20cb5cecc4 | |||
| 08dc24605d | |||
| bb7e9e94ee | |||
| 2680a1c872 | |||
| 20a7ce591c | |||
| 474e844ed9 | |||
| b34955f2fb | |||
| 2bd0f0f14d | |||
| 8b77d9ff93 | |||
| a9c7360020 | |||
| d02c87602b | |||
| 9f966643b5 | |||
| 5746e2a3d6 | |||
| d5c2231794 | |||
| fc8e257a10 | |||
| 2e9bf382fb | |||
| de48c848da | |||
| 9cdcc828a7 | 
| @@ -36,6 +36,14 @@ accounting.transaction.query module | |||||||
|    :undoc-members: |    :undoc-members: | ||||||
|    :show-inheritance: |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.transaction.summary\_helper module | ||||||
|  | --------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.transaction.summary_helper | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
| accounting.transaction.template module | accounting.transaction.template module | ||||||
| -------------------------------------- | -------------------------------------- | ||||||
|  |  | ||||||
|   | |||||||
| @@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath('../../src/')) | |||||||
| project = 'Mia! Accounting Flask' | project = 'Mia! Accounting Flask' | ||||||
| copyright = '2023, imacat' | copyright = '2023, imacat' | ||||||
| author = 'imacat' | author = 'imacat' | ||||||
| release = '0.0.0' | release = '0.4.0' | ||||||
|  |  | ||||||
| # -- General configuration --------------------------------------------------- | # -- General configuration --------------------------------------------------- | ||||||
| # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration | ||||||
| @@ -28,5 +28,5 @@ exclude_patterns = [] | |||||||
| # -- Options for HTML output ------------------------------------------------- | # -- Options for HTML output ------------------------------------------------- | ||||||
| # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output | ||||||
|  |  | ||||||
| html_theme = 'nature' | html_theme = 'sphinx_rtd_theme' | ||||||
| html_static_path = ['_static'] | html_static_path = ['_static'] | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ | |||||||
|  |  | ||||||
| [metadata] | [metadata] | ||||||
| name = mia-accounting-flask | name = mia-accounting-flask | ||||||
| version = 0.3.1 | version = 0.4.0 | ||||||
| author = imacat | author = imacat | ||||||
| author_email = imacat@mail.imacat.idv.tw | author_email = imacat@mail.imacat.idv.tw | ||||||
| description = The Mia! Accounting Flask project. | description = The Mia! Accounting Flask project. | ||||||
|   | |||||||
| @@ -203,25 +203,6 @@ class Account(db.Model): | |||||||
|                 return |                 return | ||||||
|         self.l10n.append(AccountL10n(locale=current_locale, title=value)) |         self.l10n.append(AccountL10n(locale=current_locale, title=value)) | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def is_in_use(self) -> bool: |  | ||||||
|         """Returns whether the account is in use. |  | ||||||
|  |  | ||||||
|         :return: True if the account is in use, or False otherwise. |  | ||||||
|         """ |  | ||||||
|         if not hasattr(self, "__is_in_use"): |  | ||||||
|             setattr(self, "__is_in_use", len(self.entries) > 0) |  | ||||||
|         return getattr(self, "__is_in_use") |  | ||||||
|  |  | ||||||
|     @is_in_use.setter |  | ||||||
|     def is_in_use(self, is_in_use: bool) -> None: |  | ||||||
|         """Sets whether the account is in use. |  | ||||||
|  |  | ||||||
|         :param is_in_use: True if the account is in use, or False otherwise. |  | ||||||
|         :return: None. |  | ||||||
|         """ |  | ||||||
|         setattr(self, "__is_in_use", is_in_use) |  | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def find_by_code(cls, code: str) -> t.Self | None: |     def find_by_code(cls, code: str) -> t.Self | None: | ||||||
|         """Finds an account by its code. |         """Finds an account by its code. | ||||||
|   | |||||||
| @@ -46,9 +46,9 @@ function initializeBaseAccountSelector() { | |||||||
|     const btnClear = document.getElementById("accounting-btn-clear-base"); |     const btnClear = document.getElementById("accounting-btn-clear-base"); | ||||||
|     selector.addEventListener("show.bs.modal", function () { |     selector.addEventListener("show.bs.modal", function () { | ||||||
|         base.classList.add("accounting-not-empty"); |         base.classList.add("accounting-not-empty"); | ||||||
|         options.forEach(function (item) { |         for (const option of options) { | ||||||
|             item.classList.remove("active"); |             option.classList.remove("active"); | ||||||
|         }); |         } | ||||||
|         const selected = document.getElementById("accounting-base-option-" + baseCode.value); |         const selected = document.getElementById("accounting-base-option-" + baseCode.value); | ||||||
|         if (selected !== null) { |         if (selected !== null) { | ||||||
|             selected.classList.add("active"); |             selected.classList.add("active"); | ||||||
| @@ -59,7 +59,7 @@ function initializeBaseAccountSelector() { | |||||||
|             base.classList.remove("accounting-not-empty"); |             base.classList.remove("accounting-not-empty"); | ||||||
|         } |         } | ||||||
|     }); |     }); | ||||||
|     options.forEach(function (option) { |     for (const option of options) { | ||||||
|         option.onclick = function () { |         option.onclick = function () { | ||||||
|             baseCode.value = option.dataset.code; |             baseCode.value = option.dataset.code; | ||||||
|             baseContent.innerText = option.dataset.content; |             baseContent.innerText = option.dataset.content; | ||||||
| @@ -69,7 +69,7 @@ function initializeBaseAccountSelector() { | |||||||
|             validateBase(); |             validateBase(); | ||||||
|             bootstrap.Modal.getInstance(selector).hide(); |             bootstrap.Modal.getInstance(selector).hide(); | ||||||
|         }; |         }; | ||||||
|     }); |     } | ||||||
|     btnClear.onclick = function () { |     btnClear.onclick = function () { | ||||||
|         baseCode.value = ""; |         baseCode.value = ""; | ||||||
|         baseContent.innerText = ""; |         baseContent.innerText = ""; | ||||||
| @@ -94,15 +94,15 @@ function initializeBaseAccountQuery() { | |||||||
|     const queryNoResult = document.getElementById("accounting-base-option-no-result"); |     const queryNoResult = document.getElementById("accounting-base-option-no-result"); | ||||||
|     query.addEventListener("input", function () { |     query.addEventListener("input", function () { | ||||||
|         if (query.value === "") { |         if (query.value === "") { | ||||||
|             options.forEach(function (option) { |             for (const option of options) { | ||||||
|                 option.classList.remove("d-none"); |                 option.classList.remove("d-none"); | ||||||
|             }); |             } | ||||||
|             optionList.classList.remove("d-none"); |             optionList.classList.remove("d-none"); | ||||||
|             queryNoResult.classList.add("d-none"); |             queryNoResult.classList.add("d-none"); | ||||||
|             return |             return | ||||||
|         } |         } | ||||||
|         let hasAnyMatched = false; |         let hasAnyMatched = false; | ||||||
|         options.forEach(function (option) { |         for (const option of options) { | ||||||
|             const queryValues = JSON.parse(option.dataset.queryValues); |             const queryValues = JSON.parse(option.dataset.queryValues); | ||||||
|             let isMatched = false; |             let isMatched = false; | ||||||
|             for (const queryValue of queryValues) { |             for (const queryValue of queryValues) { | ||||||
| @@ -117,7 +117,7 @@ function initializeBaseAccountQuery() { | |||||||
|             } else { |             } else { | ||||||
|                 option.classList.add("d-none"); |                 option.classList.add("d-none"); | ||||||
|             } |             } | ||||||
|         }); |         } | ||||||
|         if (!hasAnyMatched) { |         if (!hasAnyMatched) { | ||||||
|             optionList.classList.add("d-none"); |             optionList.classList.add("d-none"); | ||||||
|             queryNoResult.classList.remove("d-none"); |             queryNoResult.classList.remove("d-none"); | ||||||
|   | |||||||
							
								
								
									
										254
									
								
								src/accounting/static/js/account-selector.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										254
									
								
								src/accounting/static/js/account-selector.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,254 @@ | |||||||
|  | /* The Mia! Accounting Flask Project | ||||||
|  |  * transaction-transfer-form.js: The JavaScript for the transfer transaction 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 | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | // Initializes the page JavaScript. | ||||||
|  | document.addEventListener("DOMContentLoaded", function () { | ||||||
|  |     AccountSelector.initialize(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * The account selector. | ||||||
|  |  * | ||||||
|  |  */ | ||||||
|  | class AccountSelector { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The entry type | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     #entryType; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The prefix of the HTML ID and class | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     #prefix; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Constructs an account selector. | ||||||
|  |      * | ||||||
|  |      * @param modal {HTMLFormElement} the account selector modal | ||||||
|  |      */ | ||||||
|  |     constructor(modal) { | ||||||
|  |         this.#entryType = modal.dataset.entryType; | ||||||
|  |         this.#prefix = "accounting-account-selector-" + modal.dataset.entryType; | ||||||
|  |         this.#init(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Initializes the account selector. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     #init() { | ||||||
|  |         const formAccountControl = document.getElementById("accounting-entry-form-account-control"); | ||||||
|  |         const formAccount = document.getElementById("accounting-entry-form-account"); | ||||||
|  |         const more = document.getElementById(this.#prefix + "-more"); | ||||||
|  |         const btnClear = document.getElementById(this.#prefix + "-btn-clear"); | ||||||
|  |         const options = Array.from(document.getElementsByClassName(this.#prefix + "-option")); | ||||||
|  |         const selector1 = this | ||||||
|  |         more.onclick = function () { | ||||||
|  |             more.classList.add("d-none"); | ||||||
|  |             selector1.#filterAccountOptions(); | ||||||
|  |         }; | ||||||
|  |         this.#initializeAccountQuery(); | ||||||
|  |         btnClear.onclick = function () { | ||||||
|  |             formAccountControl.classList.remove("accounting-not-empty"); | ||||||
|  |             formAccount.innerText = ""; | ||||||
|  |             formAccount.dataset.code = ""; | ||||||
|  |             formAccount.dataset.text = ""; | ||||||
|  |             validateJournalEntryAccount(); | ||||||
|  |         }; | ||||||
|  |         for (const option of options) { | ||||||
|  |             option.onclick = function () { | ||||||
|  |                 formAccountControl.classList.add("accounting-not-empty"); | ||||||
|  |                 formAccount.innerText = option.dataset.content; | ||||||
|  |                 formAccount.dataset.code = option.dataset.code; | ||||||
|  |                 formAccount.dataset.text = option.dataset.content; | ||||||
|  |                 validateJournalEntryAccount(); | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Initializes the query on the account options. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     #initializeAccountQuery() { | ||||||
|  |         const query = document.getElementById(this.#prefix + "-query"); | ||||||
|  |         const helper = this; | ||||||
|  |         query.addEventListener("input", function () { | ||||||
|  |             helper.#filterAccountOptions(); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Filters the account options. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     #filterAccountOptions() { | ||||||
|  |         const query = document.getElementById(this.#prefix + "-query"); | ||||||
|  |         const optionList = document.getElementById(this.#prefix + "-option-list"); | ||||||
|  |         if (optionList === null) { | ||||||
|  |             console.log(this.#prefix + "-option-list"); | ||||||
|  |         } | ||||||
|  |         const options = Array.from(document.getElementsByClassName(this.#prefix + "-option")); | ||||||
|  |         const more = document.getElementById(this.#prefix + "-more"); | ||||||
|  |         const queryNoResult = document.getElementById(this.#prefix + "-option-no-result"); | ||||||
|  |         const codesInUse = this.#getAccountCodeUsedInForm(); | ||||||
|  |         let shouldAnyShow = false; | ||||||
|  |         for (const option of options) { | ||||||
|  |             const shouldShow = this.#shouldAccountOptionShow(option, more, codesInUse, query); | ||||||
|  |             if (shouldShow) { | ||||||
|  |                 option.classList.remove("d-none"); | ||||||
|  |                 shouldAnyShow = true; | ||||||
|  |             } else { | ||||||
|  |                 option.classList.add("d-none"); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         if (!shouldAnyShow && more.classList.contains("d-none")) { | ||||||
|  |             optionList.classList.add("d-none"); | ||||||
|  |             queryNoResult.classList.remove("d-none"); | ||||||
|  |         } else { | ||||||
|  |             optionList.classList.remove("d-none"); | ||||||
|  |             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 | ||||||
|  |      */ | ||||||
|  |     #getAccountCodeUsedInForm() { | ||||||
|  |         const accountCodes = Array.from(document.getElementsByClassName("accounting-" + this.#prefix + "-account-code")); | ||||||
|  |         const formAccount = document.getElementById("accounting-entry-form-account"); | ||||||
|  |         const inUse = [formAccount.dataset.code]; | ||||||
|  |         for (const accountCode of accountCodes) { | ||||||
|  |             inUse.push(accountCode.value); | ||||||
|  |         } | ||||||
|  |         return inUse | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Returns whether an account option should show. | ||||||
|  |      * | ||||||
|  |      * @param option {HTMLLIElement} the account option | ||||||
|  |      * @param more {HTMLLIElement} the more account 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 account option should show, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #shouldAccountOptionShow(option, more, inUse, query) { | ||||||
|  |         const isQueryMatched = function () { | ||||||
|  |             if (query.value === "") { | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |             const queryValues = JSON.parse(option.dataset.queryValues); | ||||||
|  |             for (const queryValue of queryValues) { | ||||||
|  |                 if (queryValue.includes(query.value)) { | ||||||
|  |                     return true; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             return false; | ||||||
|  |         }; | ||||||
|  |         const isMoreMatched = function () { | ||||||
|  |             if (more.classList.contains("d-none")) { | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |             return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code); | ||||||
|  |         }; | ||||||
|  |         return isMoreMatched() && isQueryMatched(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Initializes the account selector when it is shown. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     initShow() { | ||||||
|  |         const formAccount = document.getElementById("accounting-entry-form-account"); | ||||||
|  |         const query = document.getElementById(this.#prefix + "-query") | ||||||
|  |         const more = document.getElementById(this.#prefix + "-more"); | ||||||
|  |         const options = Array.from(document.getElementsByClassName(this.#prefix + "-option")); | ||||||
|  |         const btnClear = document.getElementById(this.#prefix + "-btn-clear"); | ||||||
|  |         query.value = ""; | ||||||
|  |         more.classList.remove("d-none"); | ||||||
|  |         this.#filterAccountOptions(); | ||||||
|  |         for (const option of options) { | ||||||
|  |             if (option.dataset.code === formAccount.dataset.code) { | ||||||
|  |                 option.classList.add("active"); | ||||||
|  |             } else { | ||||||
|  |                 option.classList.remove("active"); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         if (formAccount.dataset.code === "") { | ||||||
|  |             btnClear.classList.add("btn-secondary"); | ||||||
|  |             btnClear.classList.remove("btn-danger"); | ||||||
|  |             btnClear.disabled = true; | ||||||
|  |         } else { | ||||||
|  |             btnClear.classList.add("btn-danger"); | ||||||
|  |             btnClear.classList.remove("btn-secondary"); | ||||||
|  |             btnClear.disabled = false; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The account selectors. | ||||||
|  |      * @type {{debit: AccountSelector, credit: AccountSelector}} | ||||||
|  |      */ | ||||||
|  |     static #selectors = {} | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Initializes the account selectors. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     static initialize() { | ||||||
|  |         const modals = Array.from(document.getElementsByClassName("accounting-account-selector-modal")); | ||||||
|  |         for (const modal of modals) { | ||||||
|  |             const selector = new AccountSelector(modal); | ||||||
|  |             this.#selectors[selector.#entryType] = selector; | ||||||
|  |         } | ||||||
|  |         this.#initializeTransactionForm(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Initializes the transaction form. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     static #initializeTransactionForm() { | ||||||
|  |         const entryForm = document.getElementById("accounting-entry-form"); | ||||||
|  |         const formAccountControl = document.getElementById("accounting-entry-form-account-control"); | ||||||
|  |         const selectors = this.#selectors; | ||||||
|  |         formAccountControl.onclick = function () { | ||||||
|  |             selectors[entryForm.dataset.entryType].initShow(); | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |     /** | ||||||
|  |      * Initializes the account selector for the journal entry form. | ||||||
|  |      *x | ||||||
|  |      */ | ||||||
|  |     static initializeJournalEntryForm() { | ||||||
|  |         const entryForm = document.getElementById("accounting-entry-form"); | ||||||
|  |         const formAccountControl = document.getElementById("accounting-entry-form-account-control"); | ||||||
|  |         formAccountControl.dataset.bsTarget = "#accounting-account-selector-" + entryForm.dataset.entryType + "-modal"; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -65,9 +65,9 @@ function validateForm() { | |||||||
|  */ |  */ | ||||||
| function submitFormIfAllAsyncValid() { | function submitFormIfAllAsyncValid() { | ||||||
|     let isValid = true; |     let isValid = true; | ||||||
|     Object.keys(isAsyncValid).forEach(function (key) { |     for (const key of Object.keys(isAsyncValid)) { | ||||||
|         isValid = isAsyncValid[key] && isValid; |         isValid = isAsyncValid[key] && isValid; | ||||||
|     }); |     } | ||||||
|     if (isValid) { |     if (isValid) { | ||||||
|         document.getElementById("accounting-form").submit() |         document.getElementById("accounting-form").submit() | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -42,7 +42,7 @@ function initializeDragAndDropReordering(list, onReorder) { | |||||||
| function initializeMouseDragAndDropReordering(list, onReorder) { | function initializeMouseDragAndDropReordering(list, onReorder) { | ||||||
|     const items = Array.from(list.children); |     const items = Array.from(list.children); | ||||||
|     let dragged = null; |     let dragged = null; | ||||||
|     items.forEach(function (item) { |     for (const item of items) { | ||||||
|         item.draggable = true; |         item.draggable = true; | ||||||
|         item.addEventListener("dragstart", function () { |         item.addEventListener("dragstart", function () { | ||||||
|             dragged = item; |             dragged = item; | ||||||
| @@ -56,7 +56,7 @@ function initializeMouseDragAndDropReordering(list, onReorder) { | |||||||
|             dragged.classList.remove("accounting-dragged"); |             dragged.classList.remove("accounting-dragged"); | ||||||
|             dragged = null; |             dragged = null; | ||||||
|         }); |         }); | ||||||
|     }); |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -68,7 +68,7 @@ function initializeMouseDragAndDropReordering(list, onReorder) { | |||||||
|  */ |  */ | ||||||
| function initializeTouchDragAndDropReordering(list, onReorder) { | function initializeTouchDragAndDropReordering(list, onReorder) { | ||||||
|     const items = Array.from(list.children); |     const items = Array.from(list.children); | ||||||
|     items.forEach(function (item) { |     for (const item of items) { | ||||||
|         item.addEventListener("touchstart", function () { |         item.addEventListener("touchstart", function () { | ||||||
|             item.classList.add("accounting-dragged"); |             item.classList.add("accounting-dragged"); | ||||||
|         }); |         }); | ||||||
| @@ -81,7 +81,7 @@ function initializeTouchDragAndDropReordering(list, onReorder) { | |||||||
|         item.addEventListener("touchend", function () { |         item.addEventListener("touchend", function () { | ||||||
|             item.classList.remove("accounting-dragged"); |             item.classList.remove("accounting-dragged"); | ||||||
|         }); |         }); | ||||||
|     }); |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
|   | |||||||
							
								
								
									
										827
									
								
								src/accounting/static/js/summary-helper.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										827
									
								
								src/accounting/static/js/summary-helper.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,827 @@ | |||||||
|  | /* The Mia! Accounting Flask Project | ||||||
|  |  * summary-helper.js: The JavaScript for the summary helper | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /*  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 | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | // Initializes the page JavaScript. | ||||||
|  | document.addEventListener("DOMContentLoaded", function () { | ||||||
|  |     SummaryHelper.initialize(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * A summary helper. | ||||||
|  |  * | ||||||
|  |  */ | ||||||
|  | class SummaryHelper { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The entry type, either "debit" or "credit" | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     #entryType; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The prefix of the HTML ID and class | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     #prefix; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The default tab ID | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     #defaultTabId; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Constructs a summary helper. | ||||||
|  |      * | ||||||
|  |      * @param form {HTMLFormElement} the summary helper form | ||||||
|  |      */ | ||||||
|  |     constructor(form) { | ||||||
|  |         this.#entryType = form.dataset.entryType; | ||||||
|  |         this.#prefix = "accounting-summary-helper-" + form.dataset.entryType; | ||||||
|  |         this.#defaultTabId = form.dataset.defaultTabId; | ||||||
|  |         this.#init(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Initializes the summary helper. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     #init() { | ||||||
|  |         const helper = this; | ||||||
|  |         const summary = document.getElementById(this.#prefix + "-summary"); | ||||||
|  |         const tabs = Array.from(document.getElementsByClassName(this.#prefix + "-tab")); | ||||||
|  |         for (const tab of tabs) { | ||||||
|  |             tab.onclick = function () { | ||||||
|  |                 helper.#switchToTab(tab.dataset.tabId); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         this.#initializeGeneralTagHelper(); | ||||||
|  |         this.#initializeGeneralTripHelper(); | ||||||
|  |         this.#initializeBusTripHelper(); | ||||||
|  |         this.#initializeNumberHelper(); | ||||||
|  |         this.#initializeSuggestedAccounts(); | ||||||
|  |         this.#initializeSubmission(); | ||||||
|  |         summary.onchange = function () { | ||||||
|  |             summary.value = summary.value.trim(); | ||||||
|  |             helper.#parseAndPopulate(); | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Switches to a tab. | ||||||
|  |      * | ||||||
|  |      * @param tabId {string} the tab ID. | ||||||
|  |      */ | ||||||
|  |     #switchToTab(tabId) { | ||||||
|  |         const tabs = Array.from(document.getElementsByClassName(this.#prefix + "-tab")); | ||||||
|  |         const pages = Array.from(document.getElementsByClassName(this.#prefix + "-page")); | ||||||
|  |         const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-" + tabId + "-btn-tag")); | ||||||
|  |         for (const tab of tabs) { | ||||||
|  |             if (tab.dataset.tabId === tabId) { | ||||||
|  |                 tab.classList.add("active"); | ||||||
|  |                 tab.ariaCurrent = "page"; | ||||||
|  |             } else { | ||||||
|  |                 tab.classList.remove("active"); | ||||||
|  |                 tab.ariaCurrent = "false"; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         for (const page of pages) { | ||||||
|  |             if (page.dataset.tabId === tabId) { | ||||||
|  |                 page.classList.remove("d-none"); | ||||||
|  |                 page.ariaCurrent = "page"; | ||||||
|  |             } else { | ||||||
|  |                 page.classList.add("d-none"); | ||||||
|  |                 page.ariaCurrent = "false"; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         let selectedBtnTag = null; | ||||||
|  |         for (const tagButton of tagButtons) { | ||||||
|  |             if (tagButton.classList.contains("btn-primary")) { | ||||||
|  |                 selectedBtnTag = tagButton; | ||||||
|  |                 break; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         this.#filterSuggestedAccounts(selectedBtnTag); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Initializes the general tag helper. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     #initializeGeneralTagHelper() { | ||||||
|  |         const summary = document.getElementById(this.#prefix + "-summary"); | ||||||
|  |         const tag = document.getElementById(this.#prefix + "-general-tag"); | ||||||
|  |         const helper = this; | ||||||
|  |         const updateSummary = function () { | ||||||
|  |             const pos = summary.value.indexOf("—"); | ||||||
|  |             const prefix = tag.value === ""? "": tag.value + "—"; | ||||||
|  |             if (pos === -1) { | ||||||
|  |                 summary.value = prefix + summary.value; | ||||||
|  |             } else { | ||||||
|  |                 summary.value = prefix + summary.value.substring(pos + 1); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         this.#initializeTagButtons("general", tag, updateSummary); | ||||||
|  |         tag.onchange = function () { | ||||||
|  |             helper.#onTagInputChange("general", tag, updateSummary); | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Initializes the general trip helper. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     #initializeGeneralTripHelper() { | ||||||
|  |         const summary = document.getElementById(this.#prefix + "-summary"); | ||||||
|  |         const tag = document.getElementById(this.#prefix + "-travel-tag"); | ||||||
|  |         const from = document.getElementById(this.#prefix + "-travel-from"); | ||||||
|  |         const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction")) | ||||||
|  |         const to = document.getElementById(this.#prefix + "-travel-to"); | ||||||
|  |         const helper = this; | ||||||
|  |         const updateSummary = function () { | ||||||
|  |             let direction; | ||||||
|  |             for (const directionButton of directionButtons) { | ||||||
|  |                 if (directionButton.classList.contains("btn-primary")) { | ||||||
|  |                     direction = directionButton.dataset.arrow; | ||||||
|  |                     break; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             summary.value = tag.value + "—" + from.value + direction + to.value; | ||||||
|  |         }; | ||||||
|  |         this.#initializeTagButtons("travel", tag, updateSummary); | ||||||
|  |         tag.onchange = function () { | ||||||
|  |             helper.#onTagInputChange("travel", tag, updateSummary); | ||||||
|  |             helper.#validateGeneralTripTag(); | ||||||
|  |         }; | ||||||
|  |         from.onchange = function () { | ||||||
|  |             updateSummary(); | ||||||
|  |             helper.#validateGeneralTripFrom(); | ||||||
|  |         }; | ||||||
|  |         for (const directionButton of directionButtons) { | ||||||
|  |             directionButton.onclick = function () { | ||||||
|  |                 for (const otherButton of directionButtons) { | ||||||
|  |                     otherButton.classList.remove("btn-primary"); | ||||||
|  |                     otherButton.classList.add("btn-outline-primary"); | ||||||
|  |                 } | ||||||
|  |                 directionButton.classList.remove("btn-outline-primary"); | ||||||
|  |                 directionButton.classList.add("btn-primary"); | ||||||
|  |                 updateSummary(); | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  |         to.onchange = function () { | ||||||
|  |             updateSummary(); | ||||||
|  |             helper.#validateGeneralTripTo(); | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Initializes the bus trip helper. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     #initializeBusTripHelper() { | ||||||
|  |         const summary = document.getElementById(this.#prefix + "-summary"); | ||||||
|  |         const tag = document.getElementById(this.#prefix + "-bus-tag"); | ||||||
|  |         const route = document.getElementById(this.#prefix + "-bus-route"); | ||||||
|  |         const from = document.getElementById(this.#prefix + "-bus-from"); | ||||||
|  |         const to = document.getElementById(this.#prefix + "-bus-to"); | ||||||
|  |         const helper = this; | ||||||
|  |         const updateSummary = function () { | ||||||
|  |             summary.value = tag.value + "—" + route.value + "—" + from.value + "→" + to.value; | ||||||
|  |         }; | ||||||
|  |         this.#initializeTagButtons("bus", tag, updateSummary); | ||||||
|  |         tag.onchange = function () { | ||||||
|  |             helper.#onTagInputChange("bus", tag, updateSummary); | ||||||
|  |             helper.#validateBusTripTag(); | ||||||
|  |         }; | ||||||
|  |         route.onchange = function () { | ||||||
|  |             updateSummary(); | ||||||
|  |             helper.#validateBusTripRoute(); | ||||||
|  |         }; | ||||||
|  |         from.onchange = function () { | ||||||
|  |             updateSummary(); | ||||||
|  |             helper.#validateBusTripFrom(); | ||||||
|  |         }; | ||||||
|  |         to.onchange = function () { | ||||||
|  |             updateSummary(); | ||||||
|  |             helper.#validateBusTripTo(); | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Initializes the tag buttons. | ||||||
|  |      * | ||||||
|  |      * @param tabId {string} the tab ID | ||||||
|  |      * @param tag {HTMLInputElement} the tag input | ||||||
|  |      * @param updateSummary {function(): void} the callback to update the summary | ||||||
|  |      */ | ||||||
|  |     #initializeTagButtons(tabId, tag, updateSummary) { | ||||||
|  |         const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-" + tabId + "-btn-tag")); | ||||||
|  |         const helper = this; | ||||||
|  |         for (const tagButton of tagButtons) { | ||||||
|  |             tagButton.onclick = function () { | ||||||
|  |                 for (const otherButton of tagButtons) { | ||||||
|  |                     otherButton.classList.remove("btn-primary"); | ||||||
|  |                     otherButton.classList.add("btn-outline-primary"); | ||||||
|  |                 } | ||||||
|  |                 tagButton.classList.remove("btn-outline-primary"); | ||||||
|  |                 tagButton.classList.add("btn-primary"); | ||||||
|  |                 tag.value = tagButton.dataset.value; | ||||||
|  |                 helper.#filterSuggestedAccounts(tagButton); | ||||||
|  |                 updateSummary(); | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The callback when the tag input is changed. | ||||||
|  |      * | ||||||
|  |      * @param tabId {string} the tab ID | ||||||
|  |      * @param tag {HTMLInputElement} the tag input | ||||||
|  |      * @param updateSummary {function(): void} the callback to update the summary | ||||||
|  |      */ | ||||||
|  |     #onTagInputChange(tabId, tag, updateSummary) { | ||||||
|  |         const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-" + tabId + "-btn-tag")); | ||||||
|  |         let isMatched = false; | ||||||
|  |         for (const tagButton of tagButtons) { | ||||||
|  |             if (tagButton.dataset.value === tag.value) { | ||||||
|  |                 tagButton.classList.remove("btn-outline-primary"); | ||||||
|  |                 tagButton.classList.add("btn-primary"); | ||||||
|  |                 this.#filterSuggestedAccounts(tagButton); | ||||||
|  |                 isMatched = true; | ||||||
|  |             } else { | ||||||
|  |                 tagButton.classList.remove("btn-primary"); | ||||||
|  |                 tagButton.classList.add("btn-outline-primary"); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         if (!isMatched) { | ||||||
|  |             this.#filterSuggestedAccounts(null); | ||||||
|  |         } | ||||||
|  |         updateSummary(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Filters the suggested accounts. | ||||||
|  |      * | ||||||
|  |      * @param tagButton {HTMLButtonElement|null} the tag button | ||||||
|  |      */ | ||||||
|  |     #filterSuggestedAccounts(tagButton) { | ||||||
|  |         const accountButtons = Array.from(document.getElementsByClassName(this.#prefix + "-account")); | ||||||
|  |         if (tagButton === null) { | ||||||
|  |             for (const accountButton of accountButtons) { | ||||||
|  |                 accountButton.classList.add("d-none"); | ||||||
|  |                 accountButton.classList.remove("btn-primary"); | ||||||
|  |                 accountButton.classList.add("btn-outline-primary"); | ||||||
|  |                 this.#selectAccount(null); | ||||||
|  |             } | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |         const suggested = JSON.parse(tagButton.dataset.accounts); | ||||||
|  |         for (const accountButton of accountButtons) { | ||||||
|  |             if (suggested.includes(accountButton.dataset.code)) { | ||||||
|  |                 accountButton.classList.remove("d-none"); | ||||||
|  |             } else { | ||||||
|  |                 accountButton.classList.add("d-none"); | ||||||
|  |             } | ||||||
|  |             this.#selectAccount(suggested[0]); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Initializes the number helper. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     #initializeNumberHelper() { | ||||||
|  |         const summary = document.getElementById(this.#prefix + "-summary"); | ||||||
|  |         const number = document.getElementById(this.#prefix + "-number"); | ||||||
|  |         number.onchange = function () { | ||||||
|  |             const found = summary.value.match(/^(.+)×(\d+)$/); | ||||||
|  |             if (found !== null) { | ||||||
|  |                 summary.value = found[1]; | ||||||
|  |             } | ||||||
|  |             if (number.value > 1) { | ||||||
|  |                 summary.value = summary.value + "×" + String(number.value); | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Initializes the suggested accounts. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     #initializeSuggestedAccounts() { | ||||||
|  |         const accountButtons = Array.from(document.getElementsByClassName(this.#prefix + "-account")); | ||||||
|  |         const helper = this; | ||||||
|  |         for (const accountButton of accountButtons) { | ||||||
|  |             accountButton.onclick = function () { | ||||||
|  |                 helper.#selectAccount(accountButton.dataset.code); | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Select a suggested account. | ||||||
|  |      * | ||||||
|  |      * @param selectedCode {string|null} the account code, or null to deselect the account | ||||||
|  |      */ | ||||||
|  |     #selectAccount(selectedCode) { | ||||||
|  |         const form = document.getElementById(this.#prefix); | ||||||
|  |         if (selectedCode === null) { | ||||||
|  |             form.dataset.selectedAccountCode = ""; | ||||||
|  |             form.dataset.selectedAccountText = ""; | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |         const accountButtons = Array.from(document.getElementsByClassName(this.#prefix + "-account")); | ||||||
|  |         for (const accountButton of accountButtons) { | ||||||
|  |             if (accountButton.dataset.code === selectedCode) { | ||||||
|  |                 accountButton.classList.remove("btn-outline-primary"); | ||||||
|  |                 accountButton.classList.add("btn-primary"); | ||||||
|  |                 form.dataset.selectedAccountCode = accountButton.dataset.code; | ||||||
|  |                 form.dataset.selectedAccountText = accountButton.dataset.text; | ||||||
|  |             } else { | ||||||
|  |                 accountButton.classList.remove("btn-primary"); | ||||||
|  |                 accountButton.classList.add("btn-outline-primary"); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Initializes the summary submission | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     #initializeSubmission() { | ||||||
|  |         const form = document.getElementById(this.#prefix); | ||||||
|  |         const helper = this; | ||||||
|  |         form.onsubmit = function () { | ||||||
|  |             if (helper.#validate()) { | ||||||
|  |                 helper.#submit(); | ||||||
|  |             } | ||||||
|  |             return false; | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the form. | ||||||
|  |      * | ||||||
|  |      * @return {boolean} true if valid, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #validate() { | ||||||
|  |         const tabs = Array.from(document.getElementsByClassName(this.#prefix + "-tab")); | ||||||
|  |         let isValid = true; | ||||||
|  |         for (const tab of tabs) { | ||||||
|  |             if (tab.classList.contains("active")) { | ||||||
|  |                 switch (tab.dataset.tabId) { | ||||||
|  |                     case "general": | ||||||
|  |                         isValid = this.#validateGeneralTag() && isValid; | ||||||
|  |                         break; | ||||||
|  |                     case "travel": | ||||||
|  |                         isValid = this.#validateGeneralTrip() && isValid; | ||||||
|  |                         break; | ||||||
|  |                     case "bus": | ||||||
|  |                         isValid = this.#validateBusTrip() && isValid; | ||||||
|  |                         break; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         return isValid; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates a general tag. | ||||||
|  |      * | ||||||
|  |      * @return {boolean} true if valid, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #validateGeneralTag() { | ||||||
|  |         const field = document.getElementById(this.#prefix + "-general-tag"); | ||||||
|  |         const error = document.getElementById(this.#prefix + "-general-tag-error"); | ||||||
|  |         field.value = field.value.trim(); | ||||||
|  |         field.classList.remove("is-invalid"); | ||||||
|  |         error.innerText = ""; | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates a general trip. | ||||||
|  |      * | ||||||
|  |      * @return {boolean} true if valid, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #validateGeneralTrip() { | ||||||
|  |         let isValid = true; | ||||||
|  |         isValid = this.#validateGeneralTripTag() && isValid; | ||||||
|  |         isValid = this.#validateGeneralTripFrom() && isValid; | ||||||
|  |         isValid = this.#validateGeneralTripTo() && isValid; | ||||||
|  |         return isValid; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the tag of a general trip. | ||||||
|  |      * | ||||||
|  |      * @return {boolean} true if valid, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #validateGeneralTripTag() { | ||||||
|  |         const field = document.getElementById(this.#prefix + "-travel-tag"); | ||||||
|  |         const error = document.getElementById(this.#prefix + "-travel-tag-error"); | ||||||
|  |         field.value = field.value.trim(); | ||||||
|  |         if (field.value === "") { | ||||||
|  |             field.classList.add("is-invalid"); | ||||||
|  |             error.innerText = A_("Please fill in the tag."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         field.classList.remove("is-invalid"); | ||||||
|  |         error.innerText = ""; | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the origin of a general trip. | ||||||
|  |      * | ||||||
|  |      * @return {boolean} true if valid, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #validateGeneralTripFrom() { | ||||||
|  |         const field = document.getElementById(this.#prefix + "-travel-from"); | ||||||
|  |         const error = document.getElementById(this.#prefix + "-travel-from-error"); | ||||||
|  |         field.value = field.value.trim(); | ||||||
|  |         if (field.value === "") { | ||||||
|  |             field.classList.add("is-invalid"); | ||||||
|  |             error.innerText = A_("Please fill in the origin."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         field.classList.remove("is-invalid"); | ||||||
|  |         error.innerText = ""; | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the destination of a general trip. | ||||||
|  |      * | ||||||
|  |      * @return {boolean} true if valid, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #validateGeneralTripTo() { | ||||||
|  |         const field = document.getElementById(this.#prefix + "-travel-to"); | ||||||
|  |         const error = document.getElementById(this.#prefix + "-travel-to-error"); | ||||||
|  |         field.value = field.value.trim(); | ||||||
|  |         if (field.value === "") { | ||||||
|  |             field.classList.add("is-invalid"); | ||||||
|  |             error.innerText = A_("Please fill in the destination."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         field.classList.remove("is-invalid"); | ||||||
|  |         error.innerText = ""; | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates a bus trip. | ||||||
|  |      * | ||||||
|  |      * @return {boolean} true if valid, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #validateBusTrip() { | ||||||
|  |         let isValid = true; | ||||||
|  |         isValid = this.#validateBusTripTag() && isValid; | ||||||
|  |         isValid = this.#validateBusTripRoute() && isValid; | ||||||
|  |         isValid = this.#validateBusTripFrom() && isValid; | ||||||
|  |         isValid = this.#validateBusTripTo() && isValid; | ||||||
|  |         return isValid; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the tag of a bus trip. | ||||||
|  |      * | ||||||
|  |      * @return {boolean} true if valid, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #validateBusTripTag() { | ||||||
|  |         const field = document.getElementById(this.#prefix + "-bus-tag"); | ||||||
|  |         const error = document.getElementById(this.#prefix + "-bus-tag-error"); | ||||||
|  |         field.value = field.value.trim(); | ||||||
|  |         if (field.value === "") { | ||||||
|  |             field.classList.add("is-invalid"); | ||||||
|  |             error.innerText = A_("Please fill in the tag."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         field.classList.remove("is-invalid"); | ||||||
|  |         error.innerText = ""; | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the route of a bus trip. | ||||||
|  |      * | ||||||
|  |      * @return {boolean} true if valid, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #validateBusTripRoute() { | ||||||
|  |         const field = document.getElementById(this.#prefix + "-bus-route"); | ||||||
|  |         const error = document.getElementById(this.#prefix + "-bus-route-error"); | ||||||
|  |         field.value = field.value.trim(); | ||||||
|  |         if (field.value === "") { | ||||||
|  |             field.classList.add("is-invalid"); | ||||||
|  |             error.innerText = A_("Please fill in the route."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         field.classList.remove("is-invalid"); | ||||||
|  |         error.innerText = ""; | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the origin of a bus trip. | ||||||
|  |      * | ||||||
|  |      * @return {boolean} true if valid, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #validateBusTripFrom() { | ||||||
|  |         const field = document.getElementById(this.#prefix + "-bus-from"); | ||||||
|  |         const error = document.getElementById(this.#prefix + "-bus-from-error"); | ||||||
|  |         field.value = field.value.trim(); | ||||||
|  |         if (field.value === "") { | ||||||
|  |             field.classList.add("is-invalid"); | ||||||
|  |             error.innerText = A_("Please fill in the origin."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         field.classList.remove("is-invalid"); | ||||||
|  |         error.innerText = ""; | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the destination of a bus trip. | ||||||
|  |      * | ||||||
|  |      * @return {boolean} true if valid, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #validateBusTripTo() { | ||||||
|  |         const field = document.getElementById(this.#prefix + "-bus-to"); | ||||||
|  |         const error = document.getElementById(this.#prefix + "-bus-to-error"); | ||||||
|  |         field.value = field.value.trim(); | ||||||
|  |         if (field.value === "") { | ||||||
|  |             field.classList.add("is-invalid"); | ||||||
|  |             error.innerText = A_("Please fill in the destination."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         field.classList.remove("is-invalid"); | ||||||
|  |         error.innerText = ""; | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Submits the summary. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     #submit() { | ||||||
|  |         const form = document.getElementById(this.#prefix); | ||||||
|  |         const summary = document.getElementById(this.#prefix + "-summary"); | ||||||
|  |         const formSummaryControl = document.getElementById("accounting-entry-form-summary-control"); | ||||||
|  |         const formSummary = document.getElementById("accounting-entry-form-summary"); | ||||||
|  |         const formAccountControl = document.getElementById("accounting-entry-form-account-control"); | ||||||
|  |         const formAccount = document.getElementById("accounting-entry-form-account"); | ||||||
|  |         const helperModal = document.getElementById(this.#prefix + "-modal"); | ||||||
|  |         const entryModal = document.getElementById("accounting-entry-form-modal"); | ||||||
|  |         if (summary.value === "") { | ||||||
|  |             formSummaryControl.classList.remove("accounting-not-empty"); | ||||||
|  |         } else { | ||||||
|  |             formSummaryControl.classList.add("accounting-not-empty"); | ||||||
|  |         } | ||||||
|  |         if (form.dataset.selectedAccountCode !== "") { | ||||||
|  |             formAccountControl.classList.add("accounting-not-empty"); | ||||||
|  |             formAccount.dataset.code = form.dataset.selectedAccountCode; | ||||||
|  |             formAccount.dataset.text = form.dataset.selectedAccountText; | ||||||
|  |             formAccount.innerText = form.dataset.selectedAccountText; | ||||||
|  |         } | ||||||
|  |         formSummary.dataset.value = summary.value; | ||||||
|  |         formSummary.innerText = summary.value; | ||||||
|  |         bootstrap.Modal.getInstance(helperModal).hide(); | ||||||
|  |         bootstrap.Modal.getOrCreateInstance(entryModal).show(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Initializes the summary helper when it is shown. | ||||||
|  |      * | ||||||
|  |      * @param isNew {boolean} true for adding a new journal entry, or false otherwise | ||||||
|  |      */ | ||||||
|  |     initShow(isNew) { | ||||||
|  |         const formSummary = document.getElementById("accounting-entry-form-summary"); | ||||||
|  |         const summary = document.getElementById(this.#prefix + "-summary"); | ||||||
|  |         const closeButtons = Array.from(document.getElementsByClassName(this.#prefix + "-close")); | ||||||
|  |         for (const closeButton of closeButtons) { | ||||||
|  |             if (isNew) { | ||||||
|  |                 closeButton.dataset.bsTarget = "#" + this.#prefix + "-modal"; | ||||||
|  |             } else { | ||||||
|  |                 closeButton.dataset.bsTarget = "#accounting-entry-form-modal"; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         this.#reset(); | ||||||
|  |         if (!isNew) { | ||||||
|  |             summary.value = formSummary.dataset.value; | ||||||
|  |             this.#parseAndPopulate(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Resets the summary helper. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     #reset() { | ||||||
|  |         const inputs = Array.from(document.getElementsByClassName(this.#prefix + "-input")); | ||||||
|  |         const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-btn-tag")); | ||||||
|  |         const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction")); | ||||||
|  |         for (const input of inputs) { | ||||||
|  |             input.value = ""; | ||||||
|  |             input.classList.remove("is-invalid"); | ||||||
|  |         } | ||||||
|  |         for (const tagButton of tagButtons) { | ||||||
|  |             tagButton.classList.remove("btn-primary"); | ||||||
|  |             tagButton.classList.add("btn-outline-primary"); | ||||||
|  |         } | ||||||
|  |         for (const directionButton of directionButtons) { | ||||||
|  |             if (directionButton.classList.contains("accounting-default")) { | ||||||
|  |                 directionButton.classList.remove("btn-outline-primary"); | ||||||
|  |                 directionButton.classList.add("btn-primary"); | ||||||
|  |             } else { | ||||||
|  |                 directionButton.classList.add("btn-outline-primary"); | ||||||
|  |                 directionButton.classList.remove("btn-primary"); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         this.#filterSuggestedAccounts(null); | ||||||
|  |         this.#switchToTab(this.#defaultTabId); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Parses the summary input and populates the summary helper. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     #parseAndPopulate() { | ||||||
|  |         const summary = document.getElementById(this.#prefix + "-summary"); | ||||||
|  |         const pos = summary.value.indexOf("—"); | ||||||
|  |         if (pos === -1) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |         let found; | ||||||
|  |         found = summary.value.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:×(\d+))?$/); | ||||||
|  |         if (found !== null) { | ||||||
|  |             return this.#populateBusTrip(found[1], found[2], found[3], found[4], found[5]); | ||||||
|  |         } | ||||||
|  |         found = summary.value.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:×(\d+))?$/); | ||||||
|  |         if (found !== null) { | ||||||
|  |             return this.#populateGeneralTrip(found[1], found[2], found[3], found[4], found[5]); | ||||||
|  |         } | ||||||
|  |         found = summary.value.match(/^([^—]+)—.+?(?:×(\d+)?)?$/); | ||||||
|  |         if (found !== null) { | ||||||
|  |             return this.#populateGeneralTag(found[1], found[2]); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Populates a bus trip. | ||||||
|  |      * | ||||||
|  |      * @param tagName {string} the tag name | ||||||
|  |      * @param routeName {string} the route name or route number | ||||||
|  |      * @param fromName {string} the name of the origin | ||||||
|  |      * @param toName {string} the name of the destination | ||||||
|  |      * @param numberStr {string|undefined} the number of items, if any | ||||||
|  |      */ | ||||||
|  |     #populateBusTrip(tagName, routeName, fromName, toName, numberStr) { | ||||||
|  |         const tag = document.getElementById(this.#prefix + "-bus-tag"); | ||||||
|  |         const route = document.getElementById(this.#prefix + "-bus-route"); | ||||||
|  |         const from = document.getElementById(this.#prefix + "-bus-from"); | ||||||
|  |         const to = document.getElementById(this.#prefix + "-bus-to"); | ||||||
|  |         const number = document.getElementById(this.#prefix + "-number"); | ||||||
|  |         const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-bus-btn-tag")); | ||||||
|  |         tag.value = tagName; | ||||||
|  |         route.value = routeName; | ||||||
|  |         from.value = fromName; | ||||||
|  |         to.value = toName; | ||||||
|  |         if (numberStr !== undefined) { | ||||||
|  |             number.value = parseInt(numberStr); | ||||||
|  |         } | ||||||
|  |         for (const tagButton of tagButtons) { | ||||||
|  |             if (tagButton.dataset.value === tagName) { | ||||||
|  |                 tagButton.classList.remove("btn-outline-primary"); | ||||||
|  |                 tagButton.classList.add("btn-primary"); | ||||||
|  |                 this.#filterSuggestedAccounts(tagButton); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         this.#switchToTab("bus"); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Populates a general trip. | ||||||
|  |      * | ||||||
|  |      * @param tagName {string} the tag name | ||||||
|  |      * @param fromName {string} the name of the origin | ||||||
|  |      * @param direction {string} the direction arrow, either "→" or "↔" | ||||||
|  |      * @param toName {string} the name of the destination | ||||||
|  |      * @param numberStr {string|undefined} the number of items, if any | ||||||
|  |      */ | ||||||
|  |     #populateGeneralTrip(tagName, fromName, direction, toName, numberStr) { | ||||||
|  |         const tag = document.getElementById(this.#prefix + "-travel-tag"); | ||||||
|  |         const from = document.getElementById(this.#prefix + "-travel-from"); | ||||||
|  |         const to = document.getElementById(this.#prefix + "-travel-to"); | ||||||
|  |         const number = document.getElementById(this.#prefix + "-number"); | ||||||
|  |         const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-btn-tag")); | ||||||
|  |         const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction")); | ||||||
|  |         tag.value = tagName; | ||||||
|  |         from.value = fromName; | ||||||
|  |         for (const directionButton of directionButtons) { | ||||||
|  |             if (directionButton.dataset.arrow === direction) { | ||||||
|  |                 directionButton.classList.remove("btn-outline-primary"); | ||||||
|  |                 directionButton.classList.add("btn-primary"); | ||||||
|  |             } else { | ||||||
|  |                 directionButton.classList.add("btn-outline-primary"); | ||||||
|  |                 directionButton.classList.remove("btn-primary"); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         to.value = toName; | ||||||
|  |         if (numberStr !== undefined) { | ||||||
|  |             number.value = parseInt(numberStr); | ||||||
|  |         } | ||||||
|  |         for (const tagButton of tagButtons) { | ||||||
|  |             if (tagButton.dataset.value === tagName) { | ||||||
|  |                 tagButton.classList.remove("btn-outline-primary"); | ||||||
|  |                 tagButton.classList.add("btn-primary"); | ||||||
|  |                 this.#filterSuggestedAccounts(tagButton); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         this.#switchToTab("travel"); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Populates a general tag. | ||||||
|  |      * | ||||||
|  |      * @param tagName {string} the tag name | ||||||
|  |      * @param numberStr {string|undefined} the number of items, if any | ||||||
|  |      */ | ||||||
|  |     #populateGeneralTag(tagName, numberStr) { | ||||||
|  |         const tag = document.getElementById(this.#prefix + "-general-tag"); | ||||||
|  |         const number = document.getElementById(this.#prefix + "-number"); | ||||||
|  |         const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-general-btn-tag")); | ||||||
|  |         tag.value = tagName; | ||||||
|  |         if (numberStr !== undefined) { | ||||||
|  |             number.value = parseInt(numberStr); | ||||||
|  |         } | ||||||
|  |         for (const tagButton of tagButtons) { | ||||||
|  |             if (tagButton.dataset.value === tagName) { | ||||||
|  |                 tagButton.classList.remove("btn-outline-primary"); | ||||||
|  |                 tagButton.classList.add("btn-primary"); | ||||||
|  |                 this.#filterSuggestedAccounts(tagButton); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         this.#switchToTab("general"); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The summary helpers. | ||||||
|  |      * @type {{debit: SummaryHelper, credit: SummaryHelper}} | ||||||
|  |      */ | ||||||
|  |     static #helpers = {} | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Initializes the summary helpers. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     static initialize() { | ||||||
|  |         const forms = Array.from(document.getElementsByClassName("accounting-summary-helper")); | ||||||
|  |         for (const form of forms) { | ||||||
|  |             const helper = new SummaryHelper(form); | ||||||
|  |             this.#helpers[helper.#entryType] = helper; | ||||||
|  |         } | ||||||
|  |         this.#initializeTransactionForm(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Initializes the transaction form. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     static #initializeTransactionForm() { | ||||||
|  |         const entryForm = document.getElementById("accounting-entry-form"); | ||||||
|  |         const formSummaryControl = document.getElementById("accounting-entry-form-summary-control"); | ||||||
|  |         const helpers = this.#helpers; | ||||||
|  |         formSummaryControl.onclick = function () { | ||||||
|  |             helpers[entryForm.dataset.entryType].initShow(false); | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Initializes the summary helper for a new journal entry. | ||||||
|  |      * | ||||||
|  |      * @param entryType {string} the entry type, either "debit" or "credit" | ||||||
|  |      */ | ||||||
|  |     static initializeNewJournalEntry(entryType) { | ||||||
|  |         this.#helpers[entryType].initShow(true); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -25,7 +25,6 @@ | |||||||
| document.addEventListener("DOMContentLoaded", function () { | document.addEventListener("DOMContentLoaded", function () { | ||||||
|     initializeCurrencyForms(); |     initializeCurrencyForms(); | ||||||
|     initializeJournalEntries(); |     initializeJournalEntries(); | ||||||
|     initializeAccountSelectors(); |  | ||||||
|     initializeFormValidation(); |     initializeFormValidation(); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| @@ -79,12 +78,12 @@ function initializeCurrencyForms() { | |||||||
|     btnNew.onclick = function () { |     btnNew.onclick = function () { | ||||||
|         const currencies = Array.from(document.getElementsByClassName("accounting-currency")); |         const currencies = Array.from(document.getElementsByClassName("accounting-currency")); | ||||||
|         let maxIndex = 0; |         let maxIndex = 0; | ||||||
|         currencies.forEach(function (currency) { |         for (const currency of currencies) { | ||||||
|             const index = parseInt(currency.dataset.index); |             const index = parseInt(currency.dataset.index); | ||||||
|             if (maxIndex < index) { |             if (maxIndex < index) { | ||||||
|                 maxIndex = index; |                 maxIndex = index; | ||||||
|             } |             } | ||||||
|         }); |         } | ||||||
|         const newIndex = String(maxIndex + 1); |         const newIndex = String(maxIndex + 1); | ||||||
|         const html = form.dataset.currencyTemplate |         const html = form.dataset.currencyTemplate | ||||||
|             .replaceAll("CURRENCY_INDEX", escapeHtml(newIndex)); |             .replaceAll("CURRENCY_INDEX", escapeHtml(newIndex)); | ||||||
| @@ -122,9 +121,9 @@ function initializeBtnDeleteCurrency(button) { | |||||||
| function resetDeleteCurrencyButtons() { | function resetDeleteCurrencyButtons() { | ||||||
|     const buttons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency")); |     const buttons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency")); | ||||||
|     if (buttons.length > 1) { |     if (buttons.length > 1) { | ||||||
|         buttons.forEach(function (button) { |         for (const button of buttons) { | ||||||
|             button.classList.remove("d-none"); |             button.classList.remove("d-none"); | ||||||
|         }); |         } | ||||||
|     } else { |     } else { | ||||||
|         buttons[0].classList.add("d-none"); |         buttons[0].classList.add("d-none"); | ||||||
|     } |     } | ||||||
| @@ -157,6 +156,7 @@ function initializeNewEntryButton(button) { | |||||||
|     const formAccountControl = document.getElementById("accounting-entry-form-account-control"); |     const formAccountControl = document.getElementById("accounting-entry-form-account-control"); | ||||||
|     const formAccount = document.getElementById("accounting-entry-form-account"); |     const formAccount = document.getElementById("accounting-entry-form-account"); | ||||||
|     const formAccountError = document.getElementById("accounting-entry-form-account-error") |     const formAccountError = document.getElementById("accounting-entry-form-account-error") | ||||||
|  |     const formSummaryControl = document.getElementById("accounting-entry-form-summary-control"); | ||||||
|     const formSummary = document.getElementById("accounting-entry-form-summary"); |     const formSummary = document.getElementById("accounting-entry-form-summary"); | ||||||
|     const formSummaryError = document.getElementById("accounting-entry-form-summary-error"); |     const formSummaryError = document.getElementById("accounting-entry-form-summary-error"); | ||||||
|     const formAmount = document.getElementById("accounting-entry-form-amount"); |     const formAmount = document.getElementById("accounting-entry-form-amount"); | ||||||
| @@ -165,19 +165,23 @@ function initializeNewEntryButton(button) { | |||||||
|         entryForm.dataset.currencyIndex = button.dataset.currencyIndex; |         entryForm.dataset.currencyIndex = button.dataset.currencyIndex; | ||||||
|         entryForm.dataset.entryType = button.dataset.entryType; |         entryForm.dataset.entryType = button.dataset.entryType; | ||||||
|         entryForm.dataset.entryIndex = button.dataset.entryIndex; |         entryForm.dataset.entryIndex = button.dataset.entryIndex; | ||||||
|         formAccountControl.classList.remove("accounting-not-empty") |         formAccountControl.classList.remove("accounting-not-empty"); | ||||||
|         formAccountControl.classList.remove("is-invalid"); |         formAccountControl.classList.remove("is-invalid"); | ||||||
|         formAccountControl.dataset.bsTarget = button.dataset.accountModal; |  | ||||||
|         formAccount.innerText = ""; |         formAccount.innerText = ""; | ||||||
|         formAccount.dataset.code = ""; |         formAccount.dataset.code = ""; | ||||||
|         formAccount.dataset.text = ""; |         formAccount.dataset.text = ""; | ||||||
|         formAccountError.innerText = ""; |         formAccountError.innerText = ""; | ||||||
|         formSummary.value = ""; |         formSummaryControl.dataset.bsTarget = "#accounting-summary-helper-" + button.dataset.entryType + "-modal"; | ||||||
|         formSummary.classList.remove("is-invalid"); |         formSummaryControl.classList.remove("accounting-not-empty"); | ||||||
|  |         formSummaryControl.classList.remove("is-invalid"); | ||||||
|  |         formSummary.dataset.value = ""; | ||||||
|  |         formSummary.innerText = "" | ||||||
|         formSummaryError.innerText = "" |         formSummaryError.innerText = "" | ||||||
|         formAmount.value = ""; |         formAmount.value = ""; | ||||||
|         formAmount.classList.remove("is-invalid"); |         formAmount.classList.remove("is-invalid"); | ||||||
|         formAmountError.innerText = ""; |         formAmountError.innerText = ""; | ||||||
|  |         AccountSelector.initializeJournalEntryForm(); | ||||||
|  |         SummaryHelper.initializeNewJournalEntry(button.dataset.entryType); | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -209,6 +213,7 @@ function initializeJournalEntry(entry) { | |||||||
|     const control = document.getElementById(entry.dataset.prefix + "-control"); |     const control = document.getElementById(entry.dataset.prefix + "-control"); | ||||||
|     const formAccountControl = document.getElementById("accounting-entry-form-account-control"); |     const formAccountControl = document.getElementById("accounting-entry-form-account-control"); | ||||||
|     const formAccount = document.getElementById("accounting-entry-form-account"); |     const formAccount = document.getElementById("accounting-entry-form-account"); | ||||||
|  |     const formSummaryControl = document.getElementById("accounting-entry-form-summary-control"); | ||||||
|     const formSummary = document.getElementById("accounting-entry-form-summary"); |     const formSummary = document.getElementById("accounting-entry-form-summary"); | ||||||
|     const formAmount = document.getElementById("accounting-entry-form-amount"); |     const formAmount = document.getElementById("accounting-entry-form-amount"); | ||||||
|     control.onclick = function () { |     control.onclick = function () { | ||||||
| @@ -220,12 +225,19 @@ function initializeJournalEntry(entry) { | |||||||
|         } else { |         } else { | ||||||
|             formAccountControl.classList.add("accounting-not-empty"); |             formAccountControl.classList.add("accounting-not-empty"); | ||||||
|         } |         } | ||||||
|         formAccountControl.dataset.bsTarget = entry.dataset.accountModal; |  | ||||||
|         formAccount.innerText = accountCode.dataset.text; |         formAccount.innerText = accountCode.dataset.text; | ||||||
|         formAccount.dataset.code = accountCode.value; |         formAccount.dataset.code = accountCode.value; | ||||||
|         formAccount.dataset.text = accountCode.dataset.text; |         formAccount.dataset.text = accountCode.dataset.text; | ||||||
|         formSummary.value = summary.value; |         formSummaryControl.dataset.bsTarget = "#accounting-summary-helper-" + entry.dataset.entryType + "-modal"; | ||||||
|  |         if (summary.value === "") { | ||||||
|  |             formSummaryControl.classList.remove("accounting-not-empty"); | ||||||
|  |         } else { | ||||||
|  |             formSummaryControl.classList.add("accounting-not-empty"); | ||||||
|  |         } | ||||||
|  |         formSummary.dataset.value = summary.value; | ||||||
|  |         formSummary.innerText = summary.value; | ||||||
|         formAmount.value = amount.value; |         formAmount.value = amount.value; | ||||||
|  |         AccountSelector.initializeJournalEntryForm(); | ||||||
|         validateJournalEntryForm(); |         validateJournalEntryForm(); | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
| @@ -237,38 +249,8 @@ function initializeJournalEntry(entry) { | |||||||
|  */ |  */ | ||||||
| function initializeJournalEntryFormModal() { | function initializeJournalEntryFormModal() { | ||||||
|     const entryForm = document.getElementById("accounting-entry-form"); |     const entryForm = document.getElementById("accounting-entry-form"); | ||||||
|     const formAccountControl = document.getElementById("accounting-entry-form-account-control"); |  | ||||||
|     const formAccount = document.getElementById("accounting-entry-form-account"); |  | ||||||
|     const formSummary = document.getElementById("accounting-entry-form-summary"); |  | ||||||
|     const formAmount = document.getElementById("accounting-entry-form-amount"); |     const formAmount = document.getElementById("accounting-entry-form-amount"); | ||||||
|     const modal = document.getElementById("accounting-entry-form-modal"); |     const modal = document.getElementById("accounting-entry-form-modal"); | ||||||
|     formAccountControl.onclick = function () { |  | ||||||
|         const prefix = "accounting-" + entryForm.dataset.entryType + "-account"; |  | ||||||
|         const query = document.getElementById(prefix + "-selector-query") |  | ||||||
|         const more = document.getElementById(prefix + "-more"); |  | ||||||
|         const options = Array.from(document.getElementsByClassName(prefix + "-option")); |  | ||||||
|         const btnClear = document.getElementById(prefix + "-btn-clear"); |  | ||||||
|         query.value = ""; |  | ||||||
|         more.classList.remove("d-none"); |  | ||||||
|         filterAccountOptions(prefix); |  | ||||||
|         options.forEach(function (option) { |  | ||||||
|             if (option.dataset.code === formAccount.dataset.code) { |  | ||||||
|                 option.classList.add("active"); |  | ||||||
|             } else { |  | ||||||
|                 option.classList.remove("active"); |  | ||||||
|             } |  | ||||||
|         }); |  | ||||||
|         if (formAccount.dataset.code === "") { |  | ||||||
|             btnClear.classList.add("btn-secondary"); |  | ||||||
|             btnClear.classList.remove("btn-danger"); |  | ||||||
|             btnClear.disabled = true; |  | ||||||
|         } else { |  | ||||||
|             btnClear.classList.add("btn-danger"); |  | ||||||
|             btnClear.classList.remove("btn-secondary"); |  | ||||||
|             btnClear.disabled = false; |  | ||||||
|         } |  | ||||||
|     }; |  | ||||||
|     formSummary.onchange = validateJournalEntrySummary; |  | ||||||
|     formAmount.onchange = validateJournalEntryAmount; |     formAmount.onchange = validateJournalEntryAmount; | ||||||
|     entryForm.onsubmit = function () { |     entryForm.onsubmit = function () { | ||||||
|         if (validateJournalEntryForm()) { |         if (validateJournalEntryForm()) { | ||||||
| @@ -297,7 +279,6 @@ function validateJournalEntryForm() { | |||||||
|  * Validates the account in the journal entry form modal. |  * Validates the account in the journal entry form modal. | ||||||
|  * |  * | ||||||
|  * @return {boolean} true if valid, or false otherwise |  * @return {boolean} true if valid, or false otherwise | ||||||
|  * @private |  | ||||||
|  */ |  */ | ||||||
| function validateJournalEntryAccount() { | function validateJournalEntryAccount() { | ||||||
|     const field = document.getElementById("accounting-entry-form-account"); |     const field = document.getElementById("accounting-entry-form-account"); | ||||||
| @@ -320,10 +301,9 @@ function validateJournalEntryAccount() { | |||||||
|  * @private |  * @private | ||||||
|  */ |  */ | ||||||
| function validateJournalEntrySummary() { | function validateJournalEntrySummary() { | ||||||
|     const field = document.getElementById("accounting-entry-form-summary"); |     const control = document.getElementById("accounting-entry-form-summary-control"); | ||||||
|     const error = document.getElementById("accounting-entry-form-summary-error"); |     const error = document.getElementById("accounting-entry-form-summary-error"); | ||||||
|     field.value = field.value.trim(); |     control.classList.remove("is-invalid"); | ||||||
|     field.classList.remove("is-invalid"); |  | ||||||
|     error.innerText = ""; |     error.innerText = ""; | ||||||
|     return true; |     return true; | ||||||
| } | } | ||||||
| @@ -366,12 +346,12 @@ function saveJournalEntryForm() { | |||||||
|         const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType)); |         const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType)); | ||||||
|         const entryList = document.getElementById("accounting-currency-" + currencyIndex + "-" + entryType + "-list") |         const entryList = document.getElementById("accounting-currency-" + currencyIndex + "-" + entryType + "-list") | ||||||
|         let maxIndex = 0; |         let maxIndex = 0; | ||||||
|         entries.forEach(function (entry) { |         for (const entry of entries) { | ||||||
|             const index = parseInt(entry.dataset.entryIndex); |             const index = parseInt(entry.dataset.entryIndex); | ||||||
|             if (maxIndex < index) { |             if (maxIndex < index) { | ||||||
|                 maxIndex = index; |                 maxIndex = index; | ||||||
|             } |             } | ||||||
|         }); |         } | ||||||
|         entryIndex = String(maxIndex + 1); |         entryIndex = String(maxIndex + 1); | ||||||
|         const html = form.dataset.entryTemplate |         const html = form.dataset.entryTemplate | ||||||
|             .replaceAll("CURRENCY_INDEX", escapeHtml(currencyIndex)) |             .replaceAll("CURRENCY_INDEX", escapeHtml(currencyIndex)) | ||||||
| @@ -393,8 +373,8 @@ function saveJournalEntryForm() { | |||||||
|     accountCode.value = formAccount.dataset.code; |     accountCode.value = formAccount.dataset.code; | ||||||
|     accountCode.dataset.text = formAccount.dataset.text; |     accountCode.dataset.text = formAccount.dataset.text; | ||||||
|     accountText.innerText = formAccount.dataset.text; |     accountText.innerText = formAccount.dataset.text; | ||||||
|     summary.value = formSummary.value; |     summary.value = formSummary.dataset.value; | ||||||
|     summaryText.innerText = formSummary.value; |     summaryText.innerText = formSummary.dataset.value; | ||||||
|     amount.value = formAmount.value; |     amount.value = formAmount.value; | ||||||
|     amountText.innerText = formatDecimal(new Decimal(formAmount.value)); |     amountText.innerText = formatDecimal(new Decimal(formAmount.value)); | ||||||
|     if (entryForm.dataset.entryIndex === "new") { |     if (entryForm.dataset.entryIndex === "new") { | ||||||
| @@ -436,9 +416,9 @@ function initializeDeleteJournalEntryButton(button) { | |||||||
| function resetDeleteJournalEntryButtons(sameClass) { | function resetDeleteJournalEntryButtons(sameClass) { | ||||||
|     const buttons = Array.from(document.getElementsByClassName(sameClass)); |     const buttons = Array.from(document.getElementsByClassName(sameClass)); | ||||||
|     if (buttons.length > 1) { |     if (buttons.length > 1) { | ||||||
|         buttons.forEach(function (button) { |         for (const button of buttons) { | ||||||
|             button.classList.remove("d-none"); |             button.classList.remove("d-none"); | ||||||
|         }); |         } | ||||||
|     } else { |     } else { | ||||||
|         buttons[0].classList.add("d-none"); |         buttons[0].classList.add("d-none"); | ||||||
|     } |     } | ||||||
| @@ -456,147 +436,14 @@ function updateBalance(currencyIndex, entryType) { | |||||||
|     const amounts = Array.from(document.getElementsByClassName(prefix + "-amount")); |     const amounts = Array.from(document.getElementsByClassName(prefix + "-amount")); | ||||||
|     const totalText = document.getElementById(prefix + "-total"); |     const totalText = document.getElementById(prefix + "-total"); | ||||||
|     let total = new Decimal("0"); |     let total = new Decimal("0"); | ||||||
|     amounts.forEach(function (amount) { |     for (const amount of amounts) { | ||||||
|         if (amount.value !== "") { |         if (amount.value !== "") { | ||||||
|             total = total.plus(new Decimal(amount.value)); |             total = total.plus(new Decimal(amount.value)); | ||||||
|         } |         } | ||||||
|     }); |     } | ||||||
|     totalText.innerText = formatDecimal(total); |     totalText.innerText = formatDecimal(total); | ||||||
| } | } | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Initializes the account selectors. |  | ||||||
|  * |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function initializeAccountSelectors() { |  | ||||||
|     const selectors = Array.from(document.getElementsByClassName("accounting-selector-modal")); |  | ||||||
|     const formAccountControl = document.getElementById("accounting-entry-form-account-control"); |  | ||||||
|     const formAccount = document.getElementById("accounting-entry-form-account"); |  | ||||||
|     selectors.forEach(function (selector) { |  | ||||||
|         const more = document.getElementById(selector.dataset.prefix + "-more"); |  | ||||||
|         const btnClear = document.getElementById(selector.dataset.prefix + "-btn-clear"); |  | ||||||
|         const options = Array.from(document.getElementsByClassName(selector.dataset.prefix + "-option")); |  | ||||||
|         more.onclick = function () { |  | ||||||
|             more.classList.add("d-none"); |  | ||||||
|             filterAccountOptions(selector.dataset.prefix); |  | ||||||
|         }; |  | ||||||
|         initializeAccountQuery(selector); |  | ||||||
|         btnClear.onclick = function () { |  | ||||||
|             formAccountControl.classList.remove("accounting-not-empty"); |  | ||||||
|             formAccount.innerText = ""; |  | ||||||
|             formAccount.dataset.code = ""; |  | ||||||
|             formAccount.dataset.text = ""; |  | ||||||
|             validateJournalEntryAccount(); |  | ||||||
|         }; |  | ||||||
|         options.forEach(function (option) { |  | ||||||
|             option.onclick = function () { |  | ||||||
|                 formAccountControl.classList.add("accounting-not-empty"); |  | ||||||
|                 formAccount.innerText = option.dataset.content; |  | ||||||
|                 formAccount.dataset.code = option.dataset.code; |  | ||||||
|                 formAccount.dataset.text = option.dataset.content; |  | ||||||
|                 validateJournalEntryAccount(); |  | ||||||
|             }; |  | ||||||
|         }); |  | ||||||
|     }); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Initializes the query on the account options. |  | ||||||
|  * |  | ||||||
|  * @param selector {HTMLDivElement} the selector modal |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function initializeAccountQuery(selector) { |  | ||||||
|     const query = document.getElementById(selector.dataset.prefix + "-selector-query"); |  | ||||||
|     query.addEventListener("input", function () { |  | ||||||
|         filterAccountOptions(selector.dataset.prefix); |  | ||||||
|     }); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Filters the account options. |  | ||||||
|  * |  | ||||||
|  * @param prefix {string} the HTML ID and class prefix |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function filterAccountOptions(prefix) { |  | ||||||
|     const query = document.getElementById(prefix + "-selector-query"); |  | ||||||
|     const optionList = document.getElementById(prefix + "-option-list"); |  | ||||||
|     if (optionList === null) { |  | ||||||
|         console.log(prefix + "-option-list"); |  | ||||||
|     } |  | ||||||
|     const options = Array.from(document.getElementsByClassName(prefix + "-option")); |  | ||||||
|     const more = document.getElementById(prefix + "-more"); |  | ||||||
|     const queryNoResult = document.getElementById(prefix + "-option-no-result"); |  | ||||||
|     const codesInUse = getAccountCodeUsedInForm(); |  | ||||||
|     let shouldAnyShow = false; |  | ||||||
|     options.forEach(function (option) { |  | ||||||
|         const shouldShow = shouldAccountOptionShow(option, more, codesInUse, query); |  | ||||||
|         if (shouldShow) { |  | ||||||
|             option.classList.remove("d-none"); |  | ||||||
|             shouldAnyShow = true; |  | ||||||
|         } else { |  | ||||||
|             option.classList.add("d-none"); |  | ||||||
|         } |  | ||||||
|     }); |  | ||||||
|     if (!shouldAnyShow && more.classList.contains("d-none")) { |  | ||||||
|         optionList.classList.add("d-none"); |  | ||||||
|         queryNoResult.classList.remove("d-none"); |  | ||||||
|     } else { |  | ||||||
|         optionList.classList.remove("d-none"); |  | ||||||
|         queryNoResult.classList.add("d-none"); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Returns whether an account option should show. |  | ||||||
|  * |  | ||||||
|  * @param option {HTMLLIElement} the account option |  | ||||||
|  * @param more {HTMLLIElement} the more account 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 account option should show, or false otherwise |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function shouldAccountOptionShow(option, more, inUse, query) { |  | ||||||
|     const isQueryMatched = function () { |  | ||||||
|         if (query.value === "") { |  | ||||||
|             return true; |  | ||||||
|         } |  | ||||||
|         const queryValues = JSON.parse(option.dataset.queryValues); |  | ||||||
|         for (const queryValue of queryValues) { |  | ||||||
|             if (queryValue.includes(query.value)) { |  | ||||||
|                 return true; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         return false; |  | ||||||
|     }; |  | ||||||
|     const isMoreMatched = function () { |  | ||||||
|         if (more.classList.contains("d-none")) { |  | ||||||
|             return true; |  | ||||||
|         } |  | ||||||
|         return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code); |  | ||||||
|     }; |  | ||||||
|     return isMoreMatched() && isQueryMatched(); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Returns the account codes that are used in the form. |  | ||||||
|  * |  | ||||||
|  * @return {string[]} the account codes that are used in the form |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function getAccountCodeUsedInForm() { |  | ||||||
|     const accountCodes = Array.from(document.getElementsByClassName("accounting-account-code")); |  | ||||||
|     const formAccount = document.getElementById("accounting-entry-form-account"); |  | ||||||
|     const inUse = [formAccount.dataset.code]; |  | ||||||
|     accountCodes.forEach(function (accountCode) { |  | ||||||
|         inUse.push(accountCode.value); |  | ||||||
|     }); |  | ||||||
|     return inUse |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Initializes the form validation. |  * Initializes the form validation. | ||||||
|  * |  * | ||||||
| @@ -655,9 +502,9 @@ function validateCurrencies() { | |||||||
|     const currencies = Array.from(document.getElementsByClassName("accounting-currency")); |     const currencies = Array.from(document.getElementsByClassName("accounting-currency")); | ||||||
|     let isValid = true; |     let isValid = true; | ||||||
|     isValid = validateCurrenciesReal() && isValid; |     isValid = validateCurrenciesReal() && isValid; | ||||||
|     currencies.forEach(function (currency) { |     for (const currency of currencies) { | ||||||
|         isValid = validateCurrency(currency) && isValid; |         isValid = validateCurrency(currency) && isValid; | ||||||
|     }); |     } | ||||||
|     return isValid; |     return isValid; | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -718,9 +565,9 @@ function validateJournalEntries(currency, entryType) { | |||||||
|     const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType)); |     const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType)); | ||||||
|     let isValid = true; |     let isValid = true; | ||||||
|     isValid = validateJournalEntriesReal(currencyIndex, entryType) && isValid; |     isValid = validateJournalEntriesReal(currencyIndex, entryType) && isValid; | ||||||
|     entries.forEach(function (entry) { |     for (const entry of entries) { | ||||||
|         isValid = validateJournalEntry(entry) && isValid; |         isValid = validateJournalEntry(entry) && isValid; | ||||||
|     }) |     } | ||||||
|     return isValid; |     return isValid; | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -791,17 +638,17 @@ function validateBalance(currency) { | |||||||
|     const creditAmounts = Array.from(document.getElementsByClassName(prefix + "-credit-amount")); |     const creditAmounts = Array.from(document.getElementsByClassName(prefix + "-credit-amount")); | ||||||
|     if (debit !== null && credit !== null) { |     if (debit !== null && credit !== null) { | ||||||
|         let debitTotal = new Decimal("0"); |         let debitTotal = new Decimal("0"); | ||||||
|         debitAmounts.forEach(function (amount) { |         for (const amount of debitAmounts) { | ||||||
|             if (amount.value !== "") { |             if (amount.value !== "") { | ||||||
|                 debitTotal = debitTotal.plus(new Decimal(amount.value)); |                 debitTotal = debitTotal.plus(new Decimal(amount.value)); | ||||||
|             } |             } | ||||||
|         }); |         } | ||||||
|         let creditTotal = new Decimal("0"); |         let creditTotal = new Decimal("0"); | ||||||
|         creditAmounts.forEach(function (amount) { |         for (const amount of creditAmounts) { | ||||||
|             if (amount.value !== "") { |             if (amount.value !== "") { | ||||||
|                 creditTotal = creditTotal.plus(new Decimal(amount.value)); |                 creditTotal = creditTotal.plus(new Decimal(amount.value)); | ||||||
|             } |             } | ||||||
|         }); |         } | ||||||
|         if (!debitTotal.equals(creditTotal)) { |         if (!debitTotal.equals(creditTotal)) { | ||||||
|             control.classList.add("is-invalid"); |             control.classList.add("is-invalid"); | ||||||
|             error.innerText = A_("The totals of the debit and credit amounts do not match."); |             error.innerText = A_("The totals of the debit and credit amounts do not match."); | ||||||
|   | |||||||
| @@ -70,7 +70,7 @@ First written: 2023/2/25 | |||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
|         <div> |         <div> | ||||||
|           <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-account-modal="#accounting-debit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> |           <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> | ||||||
|             <i class="fas fa-plus"></i> |             <i class="fas fa-plus"></i> | ||||||
|             {{ A_("New") }} |             {{ A_("New") }} | ||||||
|           </button> |           </button> | ||||||
|   | |||||||
| @@ -45,6 +45,12 @@ First written: 2023/2/25 | |||||||
|   {% endif %} |   {% endif %} | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block account_selector_modals %} | {% block form_modals %} | ||||||
|   {% include "accounting/transaction/include/debit-account-modal.html" %} |   {% with summary_helper = form.summary_helper.debit %} | ||||||
|  |     {% include "accounting/transaction/include/summary-helper-modal.html" %} | ||||||
|  |   {% endwith %} | ||||||
|  |   {% with entry_type = "debit", | ||||||
|  |           account_options = form.debit_account_options %} | ||||||
|  |     {% include "accounting/transaction/include/account-selector-modal.html" %} | ||||||
|  |   {% endwith %} | ||||||
| {% endblock %} | {% endblock %} | ||||||
|   | |||||||
| @@ -0,0 +1,54 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | account-selector-modal.html: The modal 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/25 | ||||||
|  | #} | ||||||
|  | <div id="accounting-account-selector-{{ entry_type }}-modal" class="modal fade accounting-account-selector-modal" data-entry-type="{{ entry_type }}" tabindex="-1" aria-labelledby="accounting-account-selector-{{ entry_type }}-modal-label" aria-hidden="true"> | ||||||
|  |   <div class="modal-dialog"> | ||||||
|  |     <div class="modal-content"> | ||||||
|  |       <div class="modal-header"> | ||||||
|  |         <h1 class="modal-title fs-5" id="accounting-account-selector-{{ entry_type }}-modal-label">{{ A_("Select Account") }}</h1> | ||||||
|  |         <button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button> | ||||||
|  |       </div> | ||||||
|  |       <div class="modal-body"> | ||||||
|  |         <div class="input-group mb-2"> | ||||||
|  |           <input id="accounting-account-selector-{{ entry_type }}-query" class="form-control form-control-sm" type="search" placeholder=" " required="required"> | ||||||
|  |           <label class="input-group-text" for="accounting-account-selector-{{ entry_type }}-query"> | ||||||
|  |             <i class="fa-solid fa-magnifying-glass"></i> | ||||||
|  |             {{ A_("Search") }} | ||||||
|  |           </label> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         <ul id="accounting-account-selector-{{ entry_type }}-option-list" class="list-group accounting-selector-list"> | ||||||
|  |           {% for account in account_options %} | ||||||
|  |           <li id="accounting-account-selector-{{ entry_type }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ entry_type }}-option {% if account.is_in_use %} accounting-account-in-use {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> | ||||||
|  |             {{ account }} | ||||||
|  |           </li> | ||||||
|  |           {% endfor %} | ||||||
|  |           <li id="accounting-account-selector-{{ entry_type }}-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li> | ||||||
|  |         </ul> | ||||||
|  |         <p id="accounting-account-selector-{{ entry_type }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p> | ||||||
|  |       </div> | ||||||
|  |       <div class="modal-footer"> | ||||||
|  |         <button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button> | ||||||
|  |         <button id="accounting-account-selector-{{ entry_type }}-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
| @@ -1,54 +0,0 @@ | |||||||
| {# |  | ||||||
| The Mia! Accounting Flask Project |  | ||||||
| credit-modals.html: The modals for the credit journal entry sub-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/25 |  | ||||||
| #} |  | ||||||
| <div id="accounting-credit-account-selector-modal" class="modal fade accounting-selector-modal" data-prefix="accounting-credit-account" tabindex="-1" aria-labelledby="accounting-credit-account-selector-modal-label" aria-hidden="true"> |  | ||||||
|   <div class="modal-dialog"> |  | ||||||
|     <div class="modal-content"> |  | ||||||
|       <div class="modal-header"> |  | ||||||
|         <h1 class="modal-title fs-5" id="accounting-credit-account-selector-modal-label">{{ A_("Select Credit Account") }}</h1> |  | ||||||
|         <button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button> |  | ||||||
|       </div> |  | ||||||
|       <div class="modal-body"> |  | ||||||
|         <div class="input-group mb-2"> |  | ||||||
|           <input id="accounting-credit-account-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required"> |  | ||||||
|           <label class="input-group-text" for="accounting-credit-account-selector-query"> |  | ||||||
|             <i class="fa-solid fa-magnifying-glass"></i> |  | ||||||
|             {{ A_("Search") }} |  | ||||||
|           </label> |  | ||||||
|         </div> |  | ||||||
|  |  | ||||||
|         <ul id="accounting-credit-account-option-list" class="list-group accounting-selector-list"> |  | ||||||
|           {% for account in form.credit_account_options %} |  | ||||||
|           <li id="accounting-credit-account-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-credit-account-option {% if account.is_in_use %} accounting-account-in-use {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> |  | ||||||
|             {{ account }} |  | ||||||
|           </li> |  | ||||||
|           {% endfor %} |  | ||||||
|           <li id="accounting-credit-account-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li> |  | ||||||
|         </ul> |  | ||||||
|         <p id="accounting-credit-account-option-no-result" class="d-none">{{ A_("There is no data.") }}</p> |  | ||||||
|       </div> |  | ||||||
|       <div class="modal-footer"> |  | ||||||
|         <button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button> |  | ||||||
|         <button id="accounting-credit-account-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
| </div> |  | ||||||
| @@ -1,54 +0,0 @@ | |||||||
| {# |  | ||||||
| The Mia! Accounting Flask Project |  | ||||||
| credit-modals.html: The modals for the debit journal entry sub-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/25 |  | ||||||
| #} |  | ||||||
| <div id="accounting-debit-account-selector-modal" class="modal fade accounting-selector-modal" data-prefix="accounting-debit-account" tabindex="-1" aria-labelledby="accounting-debit-account-selector-modal-label" aria-hidden="true"> |  | ||||||
|   <div class="modal-dialog"> |  | ||||||
|     <div class="modal-content"> |  | ||||||
|       <div class="modal-header"> |  | ||||||
|         <h1 class="modal-title fs-5" id="accounting-debit-account-selector-modal-label">{{ A_("Select Debit Account") }}</h1> |  | ||||||
|         <button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button> |  | ||||||
|       </div> |  | ||||||
|       <div class="modal-body"> |  | ||||||
|         <div class="input-group mb-2"> |  | ||||||
|           <input id="accounting-debit-account-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required"> |  | ||||||
|           <label class="input-group-text" for="accounting-debit-account-selector-query"> |  | ||||||
|             <i class="fa-solid fa-magnifying-glass"></i> |  | ||||||
|             {{ A_("Search") }} |  | ||||||
|           </label> |  | ||||||
|         </div> |  | ||||||
|  |  | ||||||
|         <ul id="accounting-debit-account-option-list" class="list-group accounting-selector-list"> |  | ||||||
|           {% for account in form.debit_account_options %} |  | ||||||
|           <li id="accounting-debit-account-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-debit-account-option {% if account.is_in_use %} accounting-account-in-use {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> |  | ||||||
|             {{ account }} |  | ||||||
|           </li> |  | ||||||
|           {% endfor %} |  | ||||||
|           <li id="accounting-debit-account-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li> |  | ||||||
|         </ul> |  | ||||||
|         <p id="accounting-debit-account-option-no-result" class="d-none">{{ A_("There is no data.") }}</p> |  | ||||||
|       </div> |  | ||||||
|       <div class="modal-footer"> |  | ||||||
|         <button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button> |  | ||||||
|         <button id="accounting-debit-account-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
| </div> |  | ||||||
| @@ -36,9 +36,11 @@ First written: 2023/2/25 | |||||||
|             <div id="accounting-entry-form-account-error" class="invalid-feedback"></div> |             <div id="accounting-entry-form-account-error" class="invalid-feedback"></div> | ||||||
|           </div> |           </div> | ||||||
|  |  | ||||||
|           <div class="form-floating mb-3"> |           <div class="mb-3"> | ||||||
|             <input id="accounting-entry-form-summary" class="form-control" type="text" value="" placeholder=" "> |             <div id="accounting-entry-form-summary-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target=""> | ||||||
|             <label for="accounting-entry-form-summary">{{ A_("Summary") }}</label> |               <label class="form-label" for="accounting-entry-form-summary">{{ A_("Summary") }}</label> | ||||||
|  |               <div id="accounting-entry-form-summary" data-value=""></div> | ||||||
|  |             </div> | ||||||
|             <div id="accounting-entry-form-summary-error" class="invalid-feedback"></div> |             <div id="accounting-entry-form-summary-error" class="invalid-feedback"></div> | ||||||
|           </div> |           </div> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -20,12 +20,12 @@ Author: imacat@mail.imacat.idv.tw (imacat) | |||||||
| First written: 2023/2/25 | First written: 2023/2/25 | ||||||
| #} | #} | ||||||
| {# <ul> For SonarQube not to complain about incorrect HTML #} | {# <ul> For SonarQube not to complain about incorrect HTML #} | ||||||
| <li id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-entry accounting-currency-{{ currency_index }}-{{ entry_type }}" data-currency-index="{{ currency_index }}" data-entry-type="{{ entry_type }}" data-entry-index="{{ entry_index }}" data-account-modal="#accounting-{{ entry_type }}-account-selector-modal" data-prefix="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}"> | <li id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-entry accounting-currency-{{ currency_index }}-{{ entry_type }}" data-currency-index="{{ currency_index }}" data-entry-type="{{ entry_type }}" data-entry-index="{{ entry_index }}" data-prefix="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}"> | ||||||
|   {% if entry_id %} |   {% if entry_id %} | ||||||
|     <input type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-eid" value="{{ entry_id }}"> |     <input type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-eid" value="{{ entry_id }}"> | ||||||
|   {% endif %} |   {% endif %} | ||||||
|   <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" value="{{ entry_index }}"> |   <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" value="{{ entry_index }}"> | ||||||
|   <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-code" class="accounting-account-code" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account_code" value="{{ account_code_data }}" data-text="{{ account_text }}"> |   <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-code" class="accounting-{{ entry_type }}-account-code" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account_code" value="{{ account_code_data }}" data-text="{{ account_text }}"> | ||||||
|   <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" value="{{ summary_data }}"> |   <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" value="{{ summary_data }}"> | ||||||
|   <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" class="accounting-currency-{{ currency_index }}-{{ entry_type }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" value="{{ amount_data }}"> |   <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" class="accounting-currency-{{ currency_index }}-{{ entry_type }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" value="{{ amount_data }}"> | ||||||
|   <div class="accounting-entry-content"> |   <div class="accounting-entry-content"> | ||||||
|   | |||||||
| @@ -24,6 +24,8 @@ First written: 2023/2/26 | |||||||
| {% block accounting_scripts %} | {% block accounting_scripts %} | ||||||
|   <script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script> |   <script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script> | ||||||
|   <script src="{{ url_for("accounting.static", filename="js/transaction-form.js") }}"></script> |   <script src="{{ url_for("accounting.static", filename="js/transaction-form.js") }}"></script> | ||||||
|  |   <script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script> | ||||||
|  |   <script src="{{ url_for("accounting.static", filename="js/summary-helper.js") }}"></script> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
| @@ -85,6 +87,6 @@ First written: 2023/2/26 | |||||||
| </form> | </form> | ||||||
|  |  | ||||||
| {% include "accounting/transaction/include/entry-form-modal.html" %} | {% include "accounting/transaction/include/entry-form-modal.html" %} | ||||||
| {% block account_selector_modals %}{% endblock %} | {% block form_modals %}{% endblock %} | ||||||
|  |  | ||||||
| {% endblock %} | {% endblock %} | ||||||
|   | |||||||
| @@ -0,0 +1,181 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | entry-form-modal.html: The modal of the summary helper | ||||||
|  |  | ||||||
|  |  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 | ||||||
|  | #} | ||||||
|  | <form id="accounting-summary-helper-{{ summary_helper.type }}" class="accounting-summary-helper" data-entry-type="{{ summary_helper.type }}" data-default-tab-id="general" data-selected-account-code="" data-selected-account-text=""> | ||||||
|  |   <div id="accounting-summary-helper-{{ summary_helper.type }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-modal-label" aria-hidden="true"> | ||||||
|  |     <div class="modal-dialog"> | ||||||
|  |       <div class="modal-content"> | ||||||
|  |         <div class="modal-header"> | ||||||
|  |           <h1 class="modal-title fs-5" id="accounting-summary-helper-{{ summary_helper.type }}-modal-label"> | ||||||
|  |             <label for="accounting-summary-helper-{{ summary_helper.type }}-summary">{{ A_("Summary") }}</label> | ||||||
|  |           </h1> | ||||||
|  |           <button class="btn-close accounting-summary-helper-{{ summary_helper.type }}-close" type="button" data-bs-toggle="modal" data-bs-target="" aria-label="{{ A_("Close") }}"></button> | ||||||
|  |         </div> | ||||||
|  |         <div class="modal-body"> | ||||||
|  |           <div class="mb-3"> | ||||||
|  |             <input id="accounting-summary-helper-{{ summary_helper.type }}-summary" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-modal-label"> | ||||||
|  |           </div> | ||||||
|  |           <ul class="nav nav-tabs mb-2"> | ||||||
|  |             <li class="nav-item"> | ||||||
|  |               <span id="accounting-summary-helper-{{ summary_helper.type }}-tab-general" class="nav-link active accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="page" data-tab-id="general"> | ||||||
|  |                 {{ A_("General") }} | ||||||
|  |               </span> | ||||||
|  |             </li> | ||||||
|  |             <li class="nav-item"> | ||||||
|  |               <span id="accounting-summary-helper-{{ summary_helper.type }}-tab-travel" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="travel"> | ||||||
|  |                 {{ A_("Travel") }} | ||||||
|  |               </span> | ||||||
|  |             </li> | ||||||
|  |             <li class="nav-item"> | ||||||
|  |               <span id="accounting-summary-helper-{{ summary_helper.type }}-tab-bus" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="bus"> | ||||||
|  |                 {{ A_("Bus") }} | ||||||
|  |               </span> | ||||||
|  |             </li> | ||||||
|  |             <li class="nav-item"> | ||||||
|  |               <span id="accounting-summary-helper-{{ summary_helper.type }}-tab-regular" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="regular"> | ||||||
|  |                 {{ A_("Regular") }} | ||||||
|  |               </span> | ||||||
|  |             </li> | ||||||
|  |             <li class="nav-item"> | ||||||
|  |               <span id="accounting-summary-helper-{{ summary_helper.type }}-tab-number" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="number"> | ||||||
|  |                 {{ A_("Number") }} | ||||||
|  |               </span> | ||||||
|  |             </li> | ||||||
|  |           </ul> | ||||||
|  |           {# A general summary with a tag #} | ||||||
|  |           <div class="accounting-summary-helper-{{ summary_helper.type }}-page" aria-current="page" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-general" data-tab-id="general"> | ||||||
|  |             <div class="form-floating mb-2"> | ||||||
|  |               <input id="accounting-summary-helper-{{ summary_helper.type }}-general-tag" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" "> | ||||||
|  |               <label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-general-tag">{{ A_("Tag") }}</label> | ||||||
|  |               <div id="accounting-summary-helper-{{ summary_helper.type }}-general-tag-error" class="invalid-feedback"></div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div> | ||||||
|  |               {% for tag in summary_helper.general.tags %} | ||||||
|  |                 <button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-btn-tag accounting-summary-helper-{{ summary_helper.type }}-general-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}"> | ||||||
|  |                   {{ tag }} | ||||||
|  |                 </button> | ||||||
|  |               {% endfor %} | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |  | ||||||
|  |           {# A general trip with the origin and distination #} | ||||||
|  |           <div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-travel" data-tab-id="travel"> | ||||||
|  |             <div class="form-floating mb-2"> | ||||||
|  |               <input id="accounting-summary-helper-{{ summary_helper.type }}-travel-tag" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" "> | ||||||
|  |               <label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-travel-tag">{{ A_("Tag") }}</label> | ||||||
|  |               <div id="accounting-summary-helper-{{ summary_helper.type }}-travel-tag-error" class="invalid-feedback"></div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div> | ||||||
|  |               {% for tag in summary_helper.travel.tags %} | ||||||
|  |                 <button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-btn-tag accounting-summary-helper-{{ summary_helper.type }}-travel-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}"> | ||||||
|  |                   {{ tag }} | ||||||
|  |                 </button> | ||||||
|  |               {% endfor %} | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div class="d-flex justify-content-between mt-2"> | ||||||
|  |               <div class="form-floating"> | ||||||
|  |                 <input id="accounting-summary-helper-{{ summary_helper.type }}-travel-from" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" "> | ||||||
|  |                 <label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-travel-from">{{ A_("From") }}</label> | ||||||
|  |                 <div id="accounting-summary-helper-{{ summary_helper.type }}-travel-from-error" class="invalid-feedback"></div> | ||||||
|  |               </div> | ||||||
|  |               <div class="btn-group-vertical ms-1 me-1"> | ||||||
|  |                 <button class="btn btn-primary accounting-summary-helper-{{ summary_helper.type }}-travel-direction accounting-default" type="button" tabindex="-1" data-arrow="→">→</button> | ||||||
|  |                 <button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-travel-direction" type="button" tabindex="-1" data-arrow="↔">↔</button> | ||||||
|  |               </div> | ||||||
|  |               <div class="form-floating"> | ||||||
|  |                 <input id="accounting-summary-helper-{{ summary_helper.type }}-travel-to" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" "> | ||||||
|  |                 <label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-travel-to">{{ A_("To") }}</label> | ||||||
|  |                 <div id="accounting-summary-helper-{{ summary_helper.type }}-travel-to-error" class="invalid-feedback"></div> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |  | ||||||
|  |           {# A bus trip with the route name or route number, the origin and distination #} | ||||||
|  |           <div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-bus" data-tab-id="bus"> | ||||||
|  |             <div class="d-flex justify-content-between mb-2"> | ||||||
|  |               <div class="form-floating me-2"> | ||||||
|  |                 <input id="accounting-summary-helper-{{ summary_helper.type }}-bus-tag" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" "> | ||||||
|  |                 <label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-tag">{{ A_("Tag") }}</label> | ||||||
|  |                 <div id="accounting-summary-helper-{{ summary_helper.type }}-bus-tag-error" class="invalid-feedback"></div> | ||||||
|  |               </div> | ||||||
|  |               <div class="form-floating"> | ||||||
|  |                 <input id="accounting-summary-helper-{{ summary_helper.type }}-bus-route" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" "> | ||||||
|  |                 <label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-route">{{ A_("Route") }}</label> | ||||||
|  |                 <div id="accounting-summary-helper-{{ summary_helper.type }}-bus-route-error" class="invalid-feedback"></div> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div> | ||||||
|  |               {% for tag in summary_helper.bus.tags %} | ||||||
|  |                 <button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-btn-tag accounting-summary-helper-{{ summary_helper.type }}-bus-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}"> | ||||||
|  |                   {{ tag }} | ||||||
|  |                 </button> | ||||||
|  |               {% endfor %} | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div class="d-flex justify-content-between mt-2"> | ||||||
|  |               <div class="form-floating me-2"> | ||||||
|  |                 <input id="accounting-summary-helper-{{ summary_helper.type }}-bus-from" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" "> | ||||||
|  |                 <label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-from">{{ A_("From") }}</label> | ||||||
|  |                 <div id="accounting-summary-helper-{{ summary_helper.type }}-bus-from-error" class="invalid-feedback"></div> | ||||||
|  |               </div> | ||||||
|  |               <div class="form-floating"> | ||||||
|  |                 <input id="accounting-summary-helper-{{ summary_helper.type }}-bus-to" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" "> | ||||||
|  |                 <label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-to">{{ A_("To") }}</label> | ||||||
|  |                 <div id="accounting-summary-helper-{{ summary_helper.type }}-bus-to-error" class="invalid-feedback"></div> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |  | ||||||
|  |           {# A regular income/payment #} | ||||||
|  |           <div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-regular" data-tab-id="regular"> | ||||||
|  |             {# TODO: To be done #} | ||||||
|  |           </div> | ||||||
|  |  | ||||||
|  |           {# The number of items #} | ||||||
|  |           <div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-number" data-tab-id="number"> | ||||||
|  |             <div class="form-floating"> | ||||||
|  |               <input id="accounting-summary-helper-{{ summary_helper.type }}-number" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="number" min="1" value="" placeholder=" "> | ||||||
|  |               <label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-number">{{ A_("The number of items") }}</label> | ||||||
|  |               <div id="accounting-summary-helper-{{ summary_helper.type }}-number-error" class="invalid-feedback"></div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |  | ||||||
|  |           {# The suggested accounts #} | ||||||
|  |           <div class="mt-3"> | ||||||
|  |             {% for account in summary_helper.accounts %} | ||||||
|  |               <button class="btn btn-outline-primary d-none accounting-summary-helper-{{ summary_helper.type }}-account" type="button" data-code="{{ account.code }}" data-text="{{ account }}"> | ||||||
|  |                 {{ account }} | ||||||
|  |               </button> | ||||||
|  |             {% endfor %} | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |         <div class="modal-footer"> | ||||||
|  |           <button class="btn btn-secondary accounting-summary-helper-{{ summary_helper.type }}-close" type="button" data-bs-toggle="modal" data-bs-target="">{{ A_("Cancel") }}</button> | ||||||
|  |           <button id="accounting-summary-helper-{{ summary_helper.type }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </form> | ||||||
| @@ -70,7 +70,7 @@ First written: 2023/2/25 | |||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
|         <div> |         <div> | ||||||
|           <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-account-modal="#accounting-credit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> |           <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> | ||||||
|             <i class="fas fa-plus"></i> |             <i class="fas fa-plus"></i> | ||||||
|             {{ A_("New") }} |             {{ A_("New") }} | ||||||
|           </button> |           </button> | ||||||
|   | |||||||
| @@ -45,6 +45,12 @@ First written: 2023/2/25 | |||||||
|   {% endif %} |   {% endif %} | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block account_selector_modals %} | {% block form_modals %} | ||||||
|   {% include "accounting/transaction/include/credit-account-modal.html" %} |   {% with summary_helper = form.summary_helper.credit %} | ||||||
|  |     {% include "accounting/transaction/include/summary-helper-modal.html" %} | ||||||
|  |   {% endwith %} | ||||||
|  |   {% with entry_type = "credit", | ||||||
|  |           account_options = form.credit_account_options %} | ||||||
|  |     {% include "accounting/transaction/include/account-selector-modal.html" %} | ||||||
|  |   {% endwith %} | ||||||
| {% endblock %} | {% endblock %} | ||||||
|   | |||||||
| @@ -57,7 +57,7 @@ First written: 2023/2/25 | |||||||
|                       account_text = entry_form.account_text, |                       account_text = entry_form.account_text, | ||||||
|                       summary_data = "" if entry_form.summary.data is none else entry_form.summary.data, |                       summary_data = "" if entry_form.summary.data is none else entry_form.summary.data, | ||||||
|                       summary_errors = entry_form.summary.errors, |                       summary_errors = entry_form.summary.errors, | ||||||
|                       amount_data = "" if entry_form.amount.data is none else entry_form.amount.data, |                       amount_data = "" if entry_form.amount.data is none else entry_form.amount.data|accounting_txn_format_amount_input, | ||||||
|                       amount_errors = entry_form.amount.errors, |                       amount_errors = entry_form.amount.errors, | ||||||
|                       amount_text = entry_form.amount.data|accounting_txn_format_amount, |                       amount_text = entry_form.amount.data|accounting_txn_format_amount, | ||||||
|                       entry_errors = entry_form.all_errors %} |                       entry_errors = entry_form.all_errors %} | ||||||
| @@ -72,7 +72,7 @@ First written: 2023/2/25 | |||||||
|           </div> |           </div> | ||||||
|  |  | ||||||
|           <div> |           <div> | ||||||
|             <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-account-modal="#accounting-debit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> |             <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> | ||||||
|               <i class="fas fa-plus"></i> |               <i class="fas fa-plus"></i> | ||||||
|               {{ A_("New") }} |               {{ A_("New") }} | ||||||
|             </button> |             </button> | ||||||
| @@ -112,7 +112,7 @@ First written: 2023/2/25 | |||||||
|           </div> |           </div> | ||||||
|  |  | ||||||
|           <div> |           <div> | ||||||
|             <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-account-modal="#accounting-credit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> |             <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> | ||||||
|               <i class="fas fa-plus"></i> |               <i class="fas fa-plus"></i> | ||||||
|               {{ A_("New") }} |               {{ A_("New") }} | ||||||
|             </button> |             </button> | ||||||
|   | |||||||
| @@ -49,7 +49,19 @@ First written: 2023/2/25 | |||||||
|   {% endif %} |   {% endif %} | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block account_selector_modals %} | {% block form_modals %} | ||||||
|   {% include "accounting/transaction/include/debit-account-modal.html" %} |   {% with summary_helper = form.summary_helper.debit %} | ||||||
|   {% include "accounting/transaction/include/credit-account-modal.html" %} |     {% include "accounting/transaction/include/summary-helper-modal.html" %} | ||||||
|  |   {% endwith %} | ||||||
|  |   {% with summary_helper = form.summary_helper.credit %} | ||||||
|  |     {% include "accounting/transaction/include/summary-helper-modal.html" %} | ||||||
|  |   {% endwith %} | ||||||
|  |   {% with entry_type = "debit", | ||||||
|  |           account_options = form.debit_account_options %} | ||||||
|  |     {% include "accounting/transaction/include/account-selector-modal.html" %} | ||||||
|  |   {% endwith %} | ||||||
|  |   {% with entry_type = "credit", | ||||||
|  |           account_options = form.credit_account_options %} | ||||||
|  |     {% include "accounting/transaction/include/account-selector-modal.html" %} | ||||||
|  |   {% endwith %} | ||||||
| {% endblock %} | {% endblock %} | ||||||
|   | |||||||
| @@ -14,7 +14,7 @@ | |||||||
| #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
| #  See the License for the specific language governing permissions and | #  See the License for the specific language governing permissions and | ||||||
| #  limitations under the License. | #  limitations under the License. | ||||||
| """The transaction type dispatcher. | """The view dispatcher for different transaction types. | ||||||
|  |  | ||||||
| """ | """ | ||||||
| import typing as t | import typing as t | ||||||
|   | |||||||
| @@ -37,6 +37,7 @@ from accounting import db | |||||||
| from accounting.locale import lazy_gettext | from accounting.locale import lazy_gettext | ||||||
| from accounting.models import Transaction, Account, JournalEntry, \ | from accounting.models import Transaction, Account, JournalEntry, \ | ||||||
|     TransactionCurrency, Currency |     TransactionCurrency, Currency | ||||||
|  | from accounting.transaction.summary_helper import SummaryHelper | ||||||
| from accounting.utils.random_id import new_id | from accounting.utils.random_id import new_id | ||||||
| from accounting.utils.strip_text import strip_text, strip_multiline_text | from accounting.utils.strip_text import strip_text, strip_multiline_text | ||||||
| from accounting.utils.user import get_current_user_pk | from accounting.utils.user import get_current_user_pk | ||||||
| @@ -114,6 +115,35 @@ class IsDebitAccount: | |||||||
|             "This account is not for debit entries.")) |             "This account is not for debit entries.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AccountOption: | ||||||
|  |     """An account option.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, account: Account): | ||||||
|  |         """Constructs an account option. | ||||||
|  |  | ||||||
|  |         :param account: The account. | ||||||
|  |         """ | ||||||
|  |         self.__account: Account = account | ||||||
|  |         self.id: str = account.id | ||||||
|  |         self.code: str = account.code | ||||||
|  |         self.is_in_use: bool = False | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         """Returns the string representation of the account option. | ||||||
|  |  | ||||||
|  |         :return: The string representation of the account option. | ||||||
|  |         """ | ||||||
|  |         return str(self.__account) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def query_values(self) -> list[str]: | ||||||
|  |         """Returns the values to be queried. | ||||||
|  |  | ||||||
|  |         :return: The values to be queried. | ||||||
|  |         """ | ||||||
|  |         return self.__account.query_values | ||||||
|  |  | ||||||
|  |  | ||||||
| class JournalEntryForm(FlaskForm): | class JournalEntryForm(FlaskForm): | ||||||
|     """The base form to create or edit a journal entry.""" |     """The base form to create or edit a journal entry.""" | ||||||
|     eid = IntegerField() |     eid = IntegerField() | ||||||
| @@ -320,49 +350,54 @@ class TransactionForm(FlaskForm): | |||||||
|             obj.no = count + 1 |             obj.no = count + 1 | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def debit_account_options(self) -> list[Account]: |     def debit_account_options(self) -> list[AccountOption]: | ||||||
|         """The selectable debit accounts. |         """The selectable debit accounts. | ||||||
|  |  | ||||||
|         :return: The selectable debit accounts. |         :return: The selectable debit accounts. | ||||||
|         """ |         """ | ||||||
|         accounts: list[Account] = Account.debit() |         accounts: list[AccountOption] \ | ||||||
|         in_use: set[int] = self.__get_in_use_account_id() |             = [AccountOption(x) for x in Account.debit()] | ||||||
|  |         in_use: set[int] = set(db.session.scalars( | ||||||
|  |             sa.select(JournalEntry.account_id) | ||||||
|  |             .filter(JournalEntry.is_debit) | ||||||
|  |             .group_by(JournalEntry.account_id)).all()) | ||||||
|         for account in accounts: |         for account in accounts: | ||||||
|             account.is_in_use = account.id in in_use |             account.is_in_use = account.id in in_use | ||||||
|         return accounts |         return accounts | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def credit_account_options(self) -> list[Account]: |     def credit_account_options(self) -> list[AccountOption]: | ||||||
|         """The selectable credit accounts. |         """The selectable credit accounts. | ||||||
|  |  | ||||||
|         :return: The selectable credit accounts. |         :return: The selectable credit accounts. | ||||||
|         """ |         """ | ||||||
|         accounts: list[Account] = Account.credit() |         accounts: list[AccountOption] \ | ||||||
|         in_use: set[int] = self.__get_in_use_account_id() |             = [AccountOption(x) for x in Account.credit()] | ||||||
|  |         in_use: set[int] = set(db.session.scalars( | ||||||
|  |             sa.select(JournalEntry.account_id) | ||||||
|  |             .filter(sa.not_(JournalEntry.is_debit)) | ||||||
|  |             .group_by(JournalEntry.account_id)).all()) | ||||||
|         for account in accounts: |         for account in accounts: | ||||||
|             account.is_in_use = account.id in in_use |             account.is_in_use = account.id in in_use | ||||||
|         return accounts |         return accounts | ||||||
|  |  | ||||||
|     def __get_in_use_account_id(self) -> set[int]: |  | ||||||
|         """Returns the ID of the accounts that are in use. |  | ||||||
|  |  | ||||||
|         :return: The ID of the accounts that are in use. |  | ||||||
|         """ |  | ||||||
|         if self.__in_use_account_id is None: |  | ||||||
|             self.__in_use_account_id = set(db.session.scalars( |  | ||||||
|                 sa.select(JournalEntry.account_id) |  | ||||||
|                 .group_by(JournalEntry.account_id)).all()) |  | ||||||
|         return self.__in_use_account_id |  | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def currencies_errors(self) -> list[str | LazyString]: |     def currencies_errors(self) -> list[str | LazyString]: | ||||||
|         """Returns the currency errors, without the errors in their sub-forms. |         """Returns the currency errors, without the errors in their sub-forms. | ||||||
|  |  | ||||||
|         :return: |         :return: The currency errors, without the errors in their sub-forms. | ||||||
|         """ |         """ | ||||||
|         return [x for x in self.currencies.errors |         return [x for x in self.currencies.errors | ||||||
|                 if isinstance(x, str) or isinstance(x, LazyString)] |                 if isinstance(x, str) or isinstance(x, LazyString)] | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def summary_helper(self) -> SummaryHelper: | ||||||
|  |         """Returns the summary helper. | ||||||
|  |  | ||||||
|  |         :return: The summary helper. | ||||||
|  |         """ | ||||||
|  |         return SummaryHelper() | ||||||
|  |  | ||||||
|  |  | ||||||
| T = t.TypeVar("T", bound=TransactionForm) | T = t.TypeVar("T", bound=TransactionForm) | ||||||
| """A transaction form variant.""" | """A transaction form variant.""" | ||||||
|   | |||||||
							
								
								
									
										255
									
								
								src/accounting/transaction/summary_helper.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										255
									
								
								src/accounting/transaction/summary_helper.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,255 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27 | ||||||
|  |  | ||||||
|  | #  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. | ||||||
|  | """The summary helper. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import typing as t | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.models import Account, JournalEntry | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SummaryAccount: | ||||||
|  |     """An account for a summary tag.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, account: Account, freq: int): | ||||||
|  |         """Constructs an account for a summary tag. | ||||||
|  |  | ||||||
|  |         :param account: The account. | ||||||
|  |         :param freq: The frequency of the tag with the account. | ||||||
|  |         """ | ||||||
|  |         self.account: Account = account | ||||||
|  |         """The account.""" | ||||||
|  |         self.id: int = account.id | ||||||
|  |         """The account ID.""" | ||||||
|  |         self.code: str = account.code | ||||||
|  |         """The account code.""" | ||||||
|  |         self.freq: int = freq | ||||||
|  |         """The frequency of the tag with the account.""" | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         """Returns the string representation of the account. | ||||||
|  |  | ||||||
|  |         :return: The string representation of the account. | ||||||
|  |         """ | ||||||
|  |         return str(self.account) | ||||||
|  |  | ||||||
|  |     def add_freq(self, freq: int) -> None: | ||||||
|  |         """Adds the frequency of an account. | ||||||
|  |  | ||||||
|  |         :param freq: The frequency of the tag name with the account. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         self.freq = self.freq + freq | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SummaryTag: | ||||||
|  |     """A summary tag.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, name: str): | ||||||
|  |         """Constructs a summary tag. | ||||||
|  |  | ||||||
|  |         :param name: The tag name. | ||||||
|  |         """ | ||||||
|  |         self.name: str = name | ||||||
|  |         """The tag name.""" | ||||||
|  |         self.__account_dict: dict[int, SummaryAccount] = {} | ||||||
|  |         """The accounts that come with the tag, in the order of their | ||||||
|  |         frequency.""" | ||||||
|  |         self.freq: int = 0 | ||||||
|  |         """The frequency of the tag.""" | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         """Returns the string representation of the tag. | ||||||
|  |  | ||||||
|  |         :return: The string representation of the tag. | ||||||
|  |         """ | ||||||
|  |         return self.name | ||||||
|  |  | ||||||
|  |     def add_account(self, account: Account, freq: int): | ||||||
|  |         """Adds an account. | ||||||
|  |  | ||||||
|  |         :param account: The associated account. | ||||||
|  |         :param freq: The frequency of the tag name with the account. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         self.__account_dict[account.id] = SummaryAccount(account, freq) | ||||||
|  |         self.freq = self.freq + freq | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def accounts(self) -> list[SummaryAccount]: | ||||||
|  |         """Returns the accounts by the order of their frequencies. | ||||||
|  |  | ||||||
|  |         :return: The accounts by the order of their frequencies. | ||||||
|  |         """ | ||||||
|  |         return sorted(self.__account_dict.values(), key=lambda x: -x.freq) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def account_codes(self) -> list[str]: | ||||||
|  |         """Returns the account codes by the order of their frequencies. | ||||||
|  |  | ||||||
|  |         :return: The account codes by the order of their frequencies. | ||||||
|  |         """ | ||||||
|  |         return [x.code for x in self.accounts] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SummaryType: | ||||||
|  |     """A summary type""" | ||||||
|  |  | ||||||
|  |     def __init__(self, type_id: t.Literal["general", "travel", "bus"]): | ||||||
|  |         """Constructs a summary type. | ||||||
|  |  | ||||||
|  |         :param type_id: The type ID, either "general", "travel", or "bus". | ||||||
|  |         """ | ||||||
|  |         self.id: t.Literal["general", "travel", "bus"] = type_id | ||||||
|  |         """The type ID.""" | ||||||
|  |         self.__tag_dict: dict[str, SummaryTag] = {} | ||||||
|  |         """A dictionary from the tag name to their corresponding tag.""" | ||||||
|  |  | ||||||
|  |     def add_tag(self, name: str, account: Account, freq: int) -> None: | ||||||
|  |         """Adds a tag. | ||||||
|  |  | ||||||
|  |         :param name: The tag name. | ||||||
|  |         :param account: The associated account. | ||||||
|  |         :param freq: The frequency of the tag name with the account. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         if name not in self.__tag_dict: | ||||||
|  |             self.__tag_dict[name] = SummaryTag(name) | ||||||
|  |         self.__tag_dict[name].add_account(account, freq) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def tags(self) -> list[SummaryTag]: | ||||||
|  |         """Returns the tags by the order of their frequencies. | ||||||
|  |  | ||||||
|  |         :return: The tags by the order of their frequencies. | ||||||
|  |         """ | ||||||
|  |         return sorted(self.__tag_dict.values(), key=lambda x: -x.freq) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SummaryEntryType: | ||||||
|  |     """A summary type""" | ||||||
|  |  | ||||||
|  |     def __init__(self, entry_type_id: t.Literal["debit", "credit"]): | ||||||
|  |         """Constructs a summary entry type. | ||||||
|  |  | ||||||
|  |         :param entry_type_id: The entry type ID, either "debit" or "credit". | ||||||
|  |         """ | ||||||
|  |         self.type: t.Literal["debit", "credit"] = entry_type_id | ||||||
|  |         """The entry type.""" | ||||||
|  |         self.general: SummaryType = SummaryType("general") | ||||||
|  |         """The general tags.""" | ||||||
|  |         self.travel: SummaryType = SummaryType("travel") | ||||||
|  |         """The travel tags.""" | ||||||
|  |         self.bus: SummaryType = SummaryType("bus") | ||||||
|  |         """The bus tags.""" | ||||||
|  |         self.__type_dict: dict[t.Literal["general", "travel", "bus"], | ||||||
|  |                                SummaryType] \ | ||||||
|  |             = {x.id: x for x in {self.general, self.travel, self.bus}} | ||||||
|  |         """A dictionary from the type ID to the corresponding tags.""" | ||||||
|  |  | ||||||
|  |     def add_tag(self, tag_type: t.Literal["general", "travel", "bus"], | ||||||
|  |                 name: str, account: Account, freq: int) -> None: | ||||||
|  |         """Adds a tag. | ||||||
|  |  | ||||||
|  |         :param tag_type: The tag type, either "general", "travel", or "bus". | ||||||
|  |         :param name: The name. | ||||||
|  |         :param account: The associated account. | ||||||
|  |         :param freq: The frequency of the tag name with the account. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         self.__type_dict[tag_type].add_tag(name, account, freq) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def accounts(self) -> list[SummaryAccount]: | ||||||
|  |         """Returns the suggested accounts of all tags in the summary helper in | ||||||
|  |         the entry type, in their frequency order. | ||||||
|  |  | ||||||
|  |         :return: The suggested accounts of all tags, in their frequency order. | ||||||
|  |         """ | ||||||
|  |         accounts: dict[int, SummaryAccount] = {} | ||||||
|  |         freq: dict[int, int] = {} | ||||||
|  |         for tag_type in self.__type_dict.values(): | ||||||
|  |             for tag in tag_type.tags: | ||||||
|  |                 for account in tag.accounts: | ||||||
|  |                     accounts[account.id] = account | ||||||
|  |                     if account.id not in freq: | ||||||
|  |                         freq[account.id] = 0 | ||||||
|  |                     freq[account.id] \ | ||||||
|  |                         = freq[account.id] + account.freq | ||||||
|  |         return [accounts[y] for y in sorted(freq.keys(), | ||||||
|  |                                             key=lambda x: -freq[x])] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SummaryHelper: | ||||||
|  |     """The summary helper.""" | ||||||
|  |  | ||||||
|  |     def __init__(self): | ||||||
|  |         """Constructs the summary helper.""" | ||||||
|  |         self.debit: SummaryEntryType = SummaryEntryType("debit") | ||||||
|  |         """The debit tags.""" | ||||||
|  |         self.credit: SummaryEntryType = SummaryEntryType("credit") | ||||||
|  |         """The credit tags.""" | ||||||
|  |         entry_type: sa.Label = sa.case((JournalEntry.is_debit, "debit"), | ||||||
|  |                                        else_="credit").label("entry_type") | ||||||
|  |         tag_type: sa.Label = sa.case( | ||||||
|  |             (JournalEntry.summary.like("_%—_%—_%→_%"), "bus"), | ||||||
|  |             (sa.or_(JournalEntry.summary.like("_%—_%→_%"), | ||||||
|  |                     JournalEntry.summary.like("_%—_%↔_%")), "travel"), | ||||||
|  |             else_="general").label("tag_type") | ||||||
|  |         tag: sa.Label = get_prefix(JournalEntry.summary, "—").label("tag") | ||||||
|  |         select: sa.Select = sa.Select(entry_type, tag_type, tag, | ||||||
|  |                                       JournalEntry.account_id, | ||||||
|  |                                       sa.func.count().label("freq"))\ | ||||||
|  |             .filter(JournalEntry.summary.is_not(None), | ||||||
|  |                     JournalEntry.summary.like("_%—_%"))\ | ||||||
|  |             .group_by(entry_type, tag_type, tag, JournalEntry.account_id) | ||||||
|  |         result: list[sa.Row] = db.session.execute(select).all() | ||||||
|  |         accounts: dict[int, Account] \ | ||||||
|  |             = {x.id: x for x in Account.query | ||||||
|  |                .filter(Account.id.in_({x.account_id for x in result})).all()} | ||||||
|  |         entry_type_dict: dict[t.Literal["debit", "credit"], SummaryEntryType] \ | ||||||
|  |             = {x.type: x for x in {self.debit, self.credit}} | ||||||
|  |         for row in result: | ||||||
|  |             entry_type_dict[row.entry_type].add_tag( | ||||||
|  |                 row.tag_type, row.tag, accounts[row.account_id], row.freq) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_prefix(string: str | sa.Column, separator: str | sa.Column) \ | ||||||
|  |         -> sa.Function: | ||||||
|  |     """Returns the SQL function to find the prefix of a string. | ||||||
|  |  | ||||||
|  |     :param string: The string. | ||||||
|  |     :param separator: The separator. | ||||||
|  |     :return: The position of the substring, starting from 1. | ||||||
|  |     """ | ||||||
|  |     return sa.func.substr(string, 0, get_position(string, separator)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_position(string: str | sa.Column, substring: str | sa.Column) \ | ||||||
|  |         -> sa.Function: | ||||||
|  |     """Returns the SQL function to find the position of a substring. | ||||||
|  |  | ||||||
|  |     :param string: The string. | ||||||
|  |     :param substring: The substring. | ||||||
|  |     :return: The position of the substring, starting from 1. | ||||||
|  |     """ | ||||||
|  |     if db.engine.name == "postgresql": | ||||||
|  |         return sa.func.strpos(string, substring) | ||||||
|  |     return sa.func.instr(string, substring) | ||||||
| @@ -8,8 +8,8 @@ msgid "" | |||||||
| msgstr "" | msgstr "" | ||||||
| "Project-Id-Version: Mia! Accounting Flask 0.0.0\n" | "Project-Id-Version: Mia! Accounting Flask 0.0.0\n" | ||||||
| "Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n" | "Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n" | ||||||
| "POT-Creation-Date: 2023-02-27 18:59+0800\n" | "POT-Creation-Date: 2023-03-01 00:51+0800\n" | ||||||
| "PO-Revision-Date: 2023-02-27 18:59+0800\n" | "PO-Revision-Date: 2023-03-01 00:51+0800\n" | ||||||
| "Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n" | "Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n" | ||||||
| "Language: zh_Hant\n" | "Language: zh_Hant\n" | ||||||
| "Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n" | "Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n" | ||||||
| @@ -126,33 +126,52 @@ msgstr "貨幣刪掉了" | |||||||
| msgid "Please fill in the title." | msgid "Please fill in the title." | ||||||
| msgstr "請填上標題。" | msgstr "請填上標題。" | ||||||
|  |  | ||||||
| #: src/accounting/static/js/transaction-form.js:308 | #: src/accounting/static/js/summary-helper.js:441 | ||||||
| #: src/accounting/static/js/transaction-form.js:764 | #: src/accounting/static/js/summary-helper.js:512 | ||||||
| #: src/accounting/transaction/forms.py:46 | msgid "Please fill in the tag." | ||||||
|  | msgstr "請填上標籤。" | ||||||
|  |  | ||||||
|  | #: src/accounting/static/js/summary-helper.js:460 | ||||||
|  | #: src/accounting/static/js/summary-helper.js:550 | ||||||
|  | msgid "Please fill in the origin." | ||||||
|  | msgstr "請填上起點。" | ||||||
|  |  | ||||||
|  | #: src/accounting/static/js/summary-helper.js:479 | ||||||
|  | #: src/accounting/static/js/summary-helper.js:569 | ||||||
|  | msgid "Please fill in the destination." | ||||||
|  | msgstr "請填上終點。" | ||||||
|  |  | ||||||
|  | #: src/accounting/static/js/summary-helper.js:531 | ||||||
|  | msgid "Please fill in the route." | ||||||
|  | msgstr "請填上路線名稱。" | ||||||
|  |  | ||||||
|  | #: src/accounting/static/js/transaction-form.js:289 | ||||||
|  | #: src/accounting/static/js/transaction-form.js:611 | ||||||
|  | #: src/accounting/transaction/forms.py:47 | ||||||
| msgid "Please select the account." | msgid "Please select the account." | ||||||
| msgstr "請選擇科目。" | msgstr "請選擇科目。" | ||||||
|  |  | ||||||
| #: src/accounting/static/js/transaction-form.js:344 | #: src/accounting/static/js/transaction-form.js:324 | ||||||
| #: src/accounting/static/js/transaction-form.js:769 | #: src/accounting/static/js/transaction-form.js:616 | ||||||
| msgid "Please fill in the amount." | msgid "Please fill in the amount." | ||||||
| msgstr "請填上金額。" | msgstr "請填上金額。" | ||||||
|  |  | ||||||
| #: src/accounting/static/js/transaction-form.js:641 | #: src/accounting/static/js/transaction-form.js:488 | ||||||
| msgid "Please fill in the date." | msgid "Please fill in the date." | ||||||
| msgstr "請填上日期。" | msgstr "請填上日期。" | ||||||
|  |  | ||||||
| #: src/accounting/static/js/transaction-form.js:676 | #: src/accounting/static/js/transaction-form.js:523 | ||||||
| #: src/accounting/transaction/forms.py:56 | #: src/accounting/transaction/forms.py:57 | ||||||
| msgid "Please add some currencies." | msgid "Please add some currencies." | ||||||
| msgstr "請加上貨幣。" | msgstr "請加上貨幣。" | ||||||
|  |  | ||||||
| #: src/accounting/static/js/transaction-form.js:742 | #: src/accounting/static/js/transaction-form.js:589 | ||||||
| #: src/accounting/transaction/forms.py:77 | #: src/accounting/transaction/forms.py:78 | ||||||
| msgid "Please add some journal entries." | msgid "Please add some journal entries." | ||||||
| msgstr "請加上分錄。" | msgstr "請加上分錄。" | ||||||
|  |  | ||||||
| #: src/accounting/static/js/transaction-form.js:807 | #: src/accounting/static/js/transaction-form.js:654 | ||||||
| #: src/accounting/transaction/forms.py:670 | #: src/accounting/transaction/forms.py:672 | ||||||
| msgid "The totals of the debit and credit amounts do not match." | msgid "The totals of the debit and credit amounts do not match." | ||||||
| msgstr "借方貸方合計不符。 " | msgstr "借方貸方合計不符。 " | ||||||
|  |  | ||||||
| @@ -167,7 +186,7 @@ msgstr "新增科目" | |||||||
| #: src/accounting/templates/accounting/currency/detail.html:31 | #: src/accounting/templates/accounting/currency/detail.html:31 | ||||||
| #: src/accounting/templates/accounting/currency/include/form.html:33 | #: src/accounting/templates/accounting/currency/include/form.html:33 | ||||||
| #: src/accounting/templates/accounting/transaction/include/detail.html:31 | #: src/accounting/templates/accounting/transaction/include/detail.html:31 | ||||||
| #: src/accounting/templates/accounting/transaction/include/form.html:34 | #: src/accounting/templates/accounting/transaction/include/form.html:36 | ||||||
| #: src/accounting/templates/accounting/transaction/order.html:36 | #: src/accounting/templates/accounting/transaction/order.html:36 | ||||||
| msgid "Back" | msgid "Back" | ||||||
| msgstr "回上頁" | msgstr "回上頁" | ||||||
| @@ -196,10 +215,10 @@ msgstr "科目刪除確認" | |||||||
| #: src/accounting/templates/accounting/account/detail.html:70 | #: src/accounting/templates/accounting/account/detail.html:70 | ||||||
| #: src/accounting/templates/accounting/account/include/form.html:91 | #: src/accounting/templates/accounting/account/include/form.html:91 | ||||||
| #: src/accounting/templates/accounting/currency/detail.html:66 | #: src/accounting/templates/accounting/currency/detail.html:66 | ||||||
| #: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:27 | #: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:27 | ||||||
| #: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:27 |  | ||||||
| #: src/accounting/templates/accounting/transaction/include/detail.html:71 | #: src/accounting/templates/accounting/transaction/include/detail.html:71 | ||||||
| #: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:28 | #: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:28 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:30 | ||||||
| msgid "Close" | msgid "Close" | ||||||
| msgstr "關閉" | msgstr "關閉" | ||||||
|  |  | ||||||
| @@ -210,10 +229,10 @@ msgstr "你確定要刪掉這個科目嗎?" | |||||||
| #: src/accounting/templates/accounting/account/detail.html:76 | #: src/accounting/templates/accounting/account/detail.html:76 | ||||||
| #: src/accounting/templates/accounting/account/include/form.html:112 | #: src/accounting/templates/accounting/account/include/form.html:112 | ||||||
| #: src/accounting/templates/accounting/currency/detail.html:72 | #: src/accounting/templates/accounting/currency/detail.html:72 | ||||||
| #: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:49 | #: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:49 | ||||||
| #: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:49 |  | ||||||
| #: src/accounting/templates/accounting/transaction/include/detail.html:77 | #: src/accounting/templates/accounting/transaction/include/detail.html:77 | ||||||
| #: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:52 | #: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:54 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:175 | ||||||
| msgid "Cancel" | msgid "Cancel" | ||||||
| msgstr "取消" | msgstr "取消" | ||||||
|  |  | ||||||
| @@ -255,7 +274,7 @@ msgstr "科目管理" | |||||||
| #: src/accounting/templates/accounting/account/list.html:32 | #: src/accounting/templates/accounting/account/list.html:32 | ||||||
| #: src/accounting/templates/accounting/currency/list.html:32 | #: src/accounting/templates/accounting/currency/list.html:32 | ||||||
| #: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:75 | #: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:75 | ||||||
| #: src/accounting/templates/accounting/transaction/include/form.html:60 | #: src/accounting/templates/accounting/transaction/include/form.html:62 | ||||||
| #: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:75 | #: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:75 | ||||||
| #: src/accounting/templates/accounting/transaction/list.html:37 | #: src/accounting/templates/accounting/transaction/list.html:37 | ||||||
| #: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:77 | #: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:77 | ||||||
| @@ -276,8 +295,7 @@ msgstr "桌機版檢索" | |||||||
| #: src/accounting/templates/accounting/base-account/list.html:34 | #: src/accounting/templates/accounting/base-account/list.html:34 | ||||||
| #: src/accounting/templates/accounting/currency/list.html:40 | #: src/accounting/templates/accounting/currency/list.html:40 | ||||||
| #: src/accounting/templates/accounting/currency/list.html:52 | #: src/accounting/templates/accounting/currency/list.html:52 | ||||||
| #: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:34 | #: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:34 | ||||||
| #: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:34 |  | ||||||
| #: src/accounting/templates/accounting/transaction/list.html:62 | #: src/accounting/templates/accounting/transaction/list.html:62 | ||||||
| #: src/accounting/templates/accounting/transaction/list.html:74 | #: src/accounting/templates/accounting/transaction/list.html:74 | ||||||
| msgid "Search" | msgid "Search" | ||||||
| @@ -294,8 +312,7 @@ msgstr "行動版檢索" | |||||||
| #: src/accounting/templates/accounting/account/order.html:81 | #: src/accounting/templates/accounting/account/order.html:81 | ||||||
| #: src/accounting/templates/accounting/base-account/list.html:51 | #: src/accounting/templates/accounting/base-account/list.html:51 | ||||||
| #: src/accounting/templates/accounting/currency/list.html:77 | #: src/accounting/templates/accounting/currency/list.html:77 | ||||||
| #: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:46 | #: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:46 | ||||||
| #: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:46 |  | ||||||
| #: src/accounting/templates/accounting/transaction/list.html:93 | #: src/accounting/templates/accounting/transaction/list.html:93 | ||||||
| #: src/accounting/templates/accounting/transaction/order.html:80 | #: src/accounting/templates/accounting/transaction/order.html:80 | ||||||
| msgid "There is no data." | msgid "There is no data." | ||||||
| @@ -309,8 +326,9 @@ msgstr "%(base)s下的科目" | |||||||
| #: src/accounting/templates/accounting/account/include/form.html:75 | #: src/accounting/templates/accounting/account/include/form.html:75 | ||||||
| #: src/accounting/templates/accounting/account/order.html:62 | #: src/accounting/templates/accounting/account/order.html:62 | ||||||
| #: src/accounting/templates/accounting/currency/include/form.html:57 | #: src/accounting/templates/accounting/currency/include/form.html:57 | ||||||
| #: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:53 | #: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:55 | ||||||
| #: src/accounting/templates/accounting/transaction/include/form.html:76 | #: src/accounting/templates/accounting/transaction/include/form.html:78 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:176 | ||||||
| #: src/accounting/templates/accounting/transaction/order.html:61 | #: src/accounting/templates/accounting/transaction/order.html:61 | ||||||
| msgid "Save" | msgid "Save" | ||||||
| msgstr "儲存" | msgstr "儲存" | ||||||
| @@ -337,8 +355,7 @@ msgstr "選擇基本科目" | |||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/account/include/form.html:114 | #: src/accounting/templates/accounting/account/include/form.html:114 | ||||||
| #: src/accounting/templates/accounting/account/include/form.html:116 | #: src/accounting/templates/accounting/account/include/form.html:116 | ||||||
| #: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:50 | #: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:50 | ||||||
| #: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:50 |  | ||||||
| msgid "Clear" | msgid "Clear" | ||||||
| msgstr "清除" | msgstr "清除" | ||||||
|  |  | ||||||
| @@ -432,7 +449,7 @@ msgstr "改轉帳" | |||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/transaction/expense/detail.html:37 | #: src/accounting/templates/accounting/transaction/expense/detail.html:37 | ||||||
| #: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:45 | #: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:45 | ||||||
| #: src/accounting/templates/accounting/transaction/include/form.html:52 | #: src/accounting/templates/accounting/transaction/include/form.html:54 | ||||||
| #: src/accounting/templates/accounting/transaction/income/detail.html:37 | #: src/accounting/templates/accounting/transaction/income/detail.html:37 | ||||||
| #: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:45 | #: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:45 | ||||||
| msgid "Content" | msgid "Content" | ||||||
| @@ -462,6 +479,14 @@ msgstr "編輯%(txn)s" | |||||||
| msgid "Currency" | msgid "Currency" | ||||||
| msgstr "貨幣" | msgstr "貨幣" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:26 | ||||||
|  | msgid "Select Account" | ||||||
|  | msgstr "選擇科目" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:44 | ||||||
|  | msgid "More…" | ||||||
|  | msgstr "更多…" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/transaction/include/add-new-material-fab.html:26 | #: src/accounting/templates/accounting/transaction/include/add-new-material-fab.html:26 | ||||||
| msgid "Cash expense" | msgid "Cash expense" | ||||||
| msgstr "現金支出" | msgstr "現金支出" | ||||||
| @@ -470,19 +495,6 @@ msgstr "現金支出" | |||||||
| msgid "Cash income" | msgid "Cash income" | ||||||
| msgstr "現金收入" | msgstr "現金收入" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:26 |  | ||||||
| msgid "Select Credit Account" |  | ||||||
| msgstr "選擇貸方科目科目" |  | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:44 |  | ||||||
| #: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:44 |  | ||||||
| msgid "More…" |  | ||||||
| msgstr "更多…" |  | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:26 |  | ||||||
| msgid "Select Debit Account" |  | ||||||
| msgstr "選擇借方科目" |  | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/transaction/include/detail.html:70 | #: src/accounting/templates/accounting/transaction/include/detail.html:70 | ||||||
| msgid "Delete Transaction Confirmation" | msgid "Delete Transaction Confirmation" | ||||||
| msgstr "傳票刪除確認" | msgstr "傳票刪除確認" | ||||||
| @@ -500,21 +512,66 @@ msgid "Account" | |||||||
| msgstr "科目" | msgstr "科目" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:41 | #: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:41 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:28 | ||||||
| msgid "Summary" | msgid "Summary" | ||||||
| msgstr "摘要" | msgstr "摘要" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:47 | #: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:49 | ||||||
| msgid "Amount" | msgid "Amount" | ||||||
| msgstr "金額" | msgstr "金額" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/transaction/include/form.html:46 | #: src/accounting/templates/accounting/transaction/include/form.html:48 | ||||||
| msgid "Date" | msgid "Date" | ||||||
| msgstr "日期" | msgstr "日期" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/transaction/include/form.html:69 | #: src/accounting/templates/accounting/transaction/include/form.html:71 | ||||||
| msgid "Note" | msgid "Note" | ||||||
| msgstr "備註" | msgstr "備註" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:39 | ||||||
|  | msgid "General" | ||||||
|  | msgstr "一般" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:44 | ||||||
|  | msgid "Travel" | ||||||
|  | msgstr "差旅" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:49 | ||||||
|  | msgid "Bus" | ||||||
|  | msgstr "公車" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:54 | ||||||
|  | msgid "Regular" | ||||||
|  | msgstr "帳單" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:59 | ||||||
|  | msgid "Number" | ||||||
|  | msgstr "數量" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:67 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:84 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:119 | ||||||
|  | msgid "Tag" | ||||||
|  | msgstr "標籤" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:99 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:140 | ||||||
|  | msgid "From" | ||||||
|  | msgstr "從" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:108 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:145 | ||||||
|  | msgid "To" | ||||||
|  | msgstr "至" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:124 | ||||||
|  | msgid "Route" | ||||||
|  | msgstr "路線" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:160 | ||||||
|  | msgid "The number of items" | ||||||
|  | msgstr "數量" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/transaction/income/create.html:24 | #: src/accounting/templates/accounting/transaction/income/create.html:24 | ||||||
| msgid "Add a New Cash Income Transaction" | msgid "Add a New Cash Income Transaction" | ||||||
| msgstr "新增現金收入傳票" | msgstr "新增現金收入傳票" | ||||||
| @@ -533,27 +590,27 @@ msgstr "借方" | |||||||
| msgid "Credit" | msgid "Credit" | ||||||
| msgstr "貸方" | msgstr "貸方" | ||||||
|  |  | ||||||
| #: src/accounting/transaction/forms.py:44 | #: src/accounting/transaction/forms.py:45 | ||||||
| msgid "Please select the currency." | msgid "Please select the currency." | ||||||
| msgstr "請選擇貨幣。" | msgstr "請選擇貨幣。" | ||||||
|  |  | ||||||
| #: src/accounting/transaction/forms.py:67 | #: src/accounting/transaction/forms.py:68 | ||||||
| msgid "The currency does not exist." | msgid "The currency does not exist." | ||||||
| msgstr "沒有這個貨幣。" | msgstr "沒有這個貨幣。" | ||||||
|  |  | ||||||
| #: src/accounting/transaction/forms.py:88 | #: src/accounting/transaction/forms.py:89 | ||||||
| msgid "The account does not exist." | msgid "The account does not exist." | ||||||
| msgstr "沒有這個科目。" | msgstr "沒有這個科目。" | ||||||
|  |  | ||||||
| #: src/accounting/transaction/forms.py:99 | #: src/accounting/transaction/forms.py:100 | ||||||
| msgid "Please fill in a positive amount." | msgid "Please fill in a positive amount." | ||||||
| msgstr "金額請填正數。" | msgstr "金額請填正數。" | ||||||
|  |  | ||||||
| #: src/accounting/transaction/forms.py:113 | #: src/accounting/transaction/forms.py:114 | ||||||
| msgid "This account is not for debit entries." | msgid "This account is not for debit entries." | ||||||
| msgstr "科目不是借方科目。" | msgstr "科目不是借方科目。" | ||||||
|  |  | ||||||
| #: src/accounting/transaction/forms.py:200 | #: src/accounting/transaction/forms.py:201 | ||||||
| msgid "This account is not for credit entries." | msgid "This account is not for credit entries." | ||||||
| msgstr "科目不是貸方科目。" | msgstr "科目不是貸方科目。" | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										327
									
								
								tests/test_summary_helper.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										327
									
								
								tests/test_summary_helper.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,327 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/28 | ||||||
|  |  | ||||||
|  | #  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. | ||||||
|  | """The test for the summary helper. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import unittest | ||||||
|  | from datetime import date | ||||||
|  |  | ||||||
|  | from click.testing import Result | ||||||
|  | from flask import Flask | ||||||
|  | from flask.testing import FlaskCliRunner | ||||||
|  |  | ||||||
|  | from test_site import create_app | ||||||
|  | from testlib import get_client | ||||||
|  | from testlib_txn import Accounts, NEXT_URI, add_txn | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SummeryHelperTestCase(unittest.TestCase): | ||||||
|  |     """The summary helper test case.""" | ||||||
|  |  | ||||||
|  |     def setUp(self) -> None: | ||||||
|  |         """Sets up the test. | ||||||
|  |         This is run once per test. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         self.app: Flask = create_app(is_testing=True) | ||||||
|  |  | ||||||
|  |         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             from accounting.models import BaseAccount, Transaction, \ | ||||||
|  |                 JournalEntry | ||||||
|  |             result: Result | ||||||
|  |             result = runner.invoke(args="init-db") | ||||||
|  |             self.assertEqual(result.exit_code, 0) | ||||||
|  |             if BaseAccount.query.first() is None: | ||||||
|  |                 result = runner.invoke(args="accounting-init-base") | ||||||
|  |                 self.assertEqual(result.exit_code, 0) | ||||||
|  |             result = runner.invoke(args=["accounting-init-currencies", | ||||||
|  |                                          "-u", "editor"]) | ||||||
|  |             self.assertEqual(result.exit_code, 0) | ||||||
|  |             result = runner.invoke(args=["accounting-init-accounts", | ||||||
|  |                                          "-u", "editor"]) | ||||||
|  |             self.assertEqual(result.exit_code, 0) | ||||||
|  |             Transaction.query.delete() | ||||||
|  |             JournalEntry.query.delete() | ||||||
|  |  | ||||||
|  |         self.client, self.csrf_token = get_client(self.app, "editor") | ||||||
|  |  | ||||||
|  |     def test_summary_helper(self) -> None: | ||||||
|  |         """Test the summary helper. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.transaction.summary_helper import SummaryHelper | ||||||
|  |         for form in get_form_data(self.csrf_token): | ||||||
|  |             add_txn(self.client, form) | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             helper: SummaryHelper = SummaryHelper() | ||||||
|  |  | ||||||
|  |         # Debit-General | ||||||
|  |         self.assertEqual(len(helper.debit.general.tags), 2) | ||||||
|  |         self.assertEqual(helper.debit.general.tags[0].name, "Lunch") | ||||||
|  |         self.assertEqual(len(helper.debit.general.tags[0].accounts), 2) | ||||||
|  |         self.assertEqual(helper.debit.general.tags[0].accounts[0].code, | ||||||
|  |                          Accounts.MEAL) | ||||||
|  |         self.assertEqual(helper.debit.general.tags[0].accounts[1].code, | ||||||
|  |                          Accounts.PAYABLE) | ||||||
|  |         self.assertEqual(helper.debit.general.tags[1].name, "Dinner") | ||||||
|  |         self.assertEqual(len(helper.debit.general.tags[1].accounts), 2) | ||||||
|  |         self.assertEqual(helper.debit.general.tags[1].accounts[0].code, | ||||||
|  |                          Accounts.MEAL) | ||||||
|  |         self.assertEqual(helper.debit.general.tags[1].accounts[1].code, | ||||||
|  |                          Accounts.PAYABLE) | ||||||
|  |  | ||||||
|  |         # Debit-Travel | ||||||
|  |         self.assertEqual(len(helper.debit.travel.tags), 3) | ||||||
|  |         self.assertEqual(helper.debit.travel.tags[0].name, "Bike") | ||||||
|  |         self.assertEqual(len(helper.debit.travel.tags[0].accounts), 1) | ||||||
|  |         self.assertEqual(helper.debit.travel.tags[0].accounts[0].code, | ||||||
|  |                          Accounts.TRAVEL) | ||||||
|  |         self.assertEqual(helper.debit.travel.tags[1].name, "Taxi") | ||||||
|  |         self.assertEqual(len(helper.debit.travel.tags[1].accounts), 1) | ||||||
|  |         self.assertEqual(helper.debit.travel.tags[1].accounts[0].code, | ||||||
|  |                          Accounts.TRAVEL) | ||||||
|  |         self.assertEqual(helper.debit.travel.tags[2].name, "Airplane") | ||||||
|  |         self.assertEqual(len(helper.debit.travel.tags[2].accounts), 1) | ||||||
|  |         self.assertEqual(helper.debit.travel.tags[2].accounts[0].code, | ||||||
|  |                          Accounts.TRAVEL) | ||||||
|  |  | ||||||
|  |         # Debit-Bus | ||||||
|  |         self.assertEqual(len(helper.debit.bus.tags), 2) | ||||||
|  |         self.assertEqual(helper.debit.bus.tags[0].name, "Train") | ||||||
|  |         self.assertEqual(len(helper.debit.bus.tags[0].accounts), 1) | ||||||
|  |         self.assertEqual(helper.debit.bus.tags[0].accounts[0].code, | ||||||
|  |                          Accounts.TRAVEL) | ||||||
|  |         self.assertEqual(helper.debit.bus.tags[1].name, "Bus") | ||||||
|  |         self.assertEqual(len(helper.debit.bus.tags[1].accounts), 1) | ||||||
|  |         self.assertEqual(helper.debit.bus.tags[1].accounts[0].code, | ||||||
|  |                          Accounts.TRAVEL) | ||||||
|  |  | ||||||
|  |         # Credit-General | ||||||
|  |         self.assertEqual(len(helper.credit.general.tags), 2) | ||||||
|  |         self.assertEqual(helper.credit.general.tags[0].name, "Lunch") | ||||||
|  |         self.assertEqual(len(helper.credit.general.tags[0].accounts), 3) | ||||||
|  |         self.assertEqual(helper.credit.general.tags[0].accounts[0].code, | ||||||
|  |                          Accounts.PAYABLE) | ||||||
|  |         self.assertEqual(helper.credit.general.tags[0].accounts[1].code, | ||||||
|  |                          Accounts.BANK) | ||||||
|  |         self.assertEqual(helper.credit.general.tags[0].accounts[2].code, | ||||||
|  |                          Accounts.CASH) | ||||||
|  |         self.assertEqual(helper.credit.general.tags[1].name, "Dinner") | ||||||
|  |         self.assertEqual(len(helper.credit.general.tags[1].accounts), 2) | ||||||
|  |         self.assertEqual(helper.credit.general.tags[1].accounts[0].code, | ||||||
|  |                          Accounts.BANK) | ||||||
|  |         self.assertEqual(helper.credit.general.tags[1].accounts[1].code, | ||||||
|  |                          Accounts.PAYABLE) | ||||||
|  |  | ||||||
|  |         # Credit-Travel | ||||||
|  |         self.assertEqual(len(helper.credit.travel.tags), 2) | ||||||
|  |         self.assertEqual(helper.credit.travel.tags[0].name, "Bike") | ||||||
|  |         self.assertEqual(len(helper.credit.travel.tags[0].accounts), 2) | ||||||
|  |         self.assertEqual(helper.credit.travel.tags[0].accounts[0].code, | ||||||
|  |                          Accounts.PAYABLE) | ||||||
|  |         self.assertEqual(helper.credit.travel.tags[0].accounts[1].code, | ||||||
|  |                          Accounts.PREPAID) | ||||||
|  |         self.assertEqual(helper.credit.travel.tags[1].name, "Taxi") | ||||||
|  |         self.assertEqual(len(helper.credit.travel.tags[1].accounts), 2) | ||||||
|  |         self.assertEqual(helper.credit.travel.tags[1].accounts[0].code, | ||||||
|  |                          Accounts.PAYABLE) | ||||||
|  |         self.assertEqual(helper.credit.travel.tags[1].accounts[1].code, | ||||||
|  |                          Accounts.CASH) | ||||||
|  |  | ||||||
|  |         # Credit-Bus | ||||||
|  |         self.assertEqual(len(helper.credit.bus.tags), 2) | ||||||
|  |         self.assertEqual(helper.credit.bus.tags[0].name, "Train") | ||||||
|  |         self.assertEqual(len(helper.credit.bus.tags[0].accounts), 2) | ||||||
|  |         self.assertEqual(helper.credit.bus.tags[0].accounts[0].code, | ||||||
|  |                          Accounts.PREPAID) | ||||||
|  |         self.assertEqual(helper.credit.bus.tags[0].accounts[1].code, | ||||||
|  |                          Accounts.PAYABLE) | ||||||
|  |         self.assertEqual(helper.credit.bus.tags[1].name, "Bus") | ||||||
|  |         self.assertEqual(len(helper.credit.bus.tags[1].accounts), 1) | ||||||
|  |         self.assertEqual(helper.credit.bus.tags[1].accounts[0].code, | ||||||
|  |                          Accounts.PREPAID) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_form_data(csrf_token: str) -> list[dict[str, str]]: | ||||||
|  |     """Returns the form data for multiple transaction forms. | ||||||
|  |  | ||||||
|  |     :param csrf_token: The CSRF token. | ||||||
|  |     :return: A list of the form data. | ||||||
|  |     """ | ||||||
|  |     txn_date: str = date.today().isoformat() | ||||||
|  |     return [{"csrf_token": csrf_token, | ||||||
|  |              "next": NEXT_URI, | ||||||
|  |              "date": txn_date, | ||||||
|  |              "currency-0-code": "USD", | ||||||
|  |              "currency-0-credit-0-account_code": Accounts.SERVICE, | ||||||
|  |              "currency-0-credit-0-summary": " Salary ", | ||||||
|  |              "currency-0-credit-0-amount": "2500"}, | ||||||
|  |             {"csrf_token": csrf_token, | ||||||
|  |              "next": NEXT_URI, | ||||||
|  |              "date": txn_date, | ||||||
|  |              "currency-0-code": "USD", | ||||||
|  |              "currency-0-debit-0-account_code": Accounts.MEAL, | ||||||
|  |              "currency-0-debit-0-summary": " Lunch—Fish ", | ||||||
|  |              "currency-0-debit-0-amount": "4.7", | ||||||
|  |              "currency-0-credit-0-account_code": Accounts.BANK, | ||||||
|  |              "currency-0-credit-0-summary": " Lunch—Fish ", | ||||||
|  |              "currency-0-credit-0-amount": "4.7", | ||||||
|  |              "currency-0-debit-1-account_code": Accounts.MEAL, | ||||||
|  |              "currency-0-debit-1-summary": " Lunch—Fries ", | ||||||
|  |              "currency-0-debit-1-amount": "2.15", | ||||||
|  |              "currency-0-credit-1-account_code": Accounts.PAYABLE, | ||||||
|  |              "currency-0-credit-1-summary": " Lunch—Fries ", | ||||||
|  |              "currency-0-credit-1-amount": "2.15", | ||||||
|  |              "currency-0-debit-2-account_code": Accounts.MEAL, | ||||||
|  |              "currency-0-debit-2-summary": " Dinner—Hamburger ", | ||||||
|  |              "currency-0-debit-2-amount": "4.25", | ||||||
|  |              "currency-0-credit-2-account_code": Accounts.BANK, | ||||||
|  |              "currency-0-credit-2-summary": " Dinner—Hamburger ", | ||||||
|  |              "currency-0-credit-2-amount": "4.25"}, | ||||||
|  |             {"csrf_token": csrf_token, | ||||||
|  |              "next": NEXT_URI, | ||||||
|  |              "date": txn_date, | ||||||
|  |              "currency-0-code": "USD", | ||||||
|  |              "currency-0-debit-0-account_code": Accounts.MEAL, | ||||||
|  |              "currency-0-debit-0-summary": " Lunch—Salad ", | ||||||
|  |              "currency-0-debit-0-amount": "4.99", | ||||||
|  |              "currency-0-credit-0-account_code": Accounts.CASH, | ||||||
|  |              "currency-0-credit-0-summary": " Lunch—Salad ", | ||||||
|  |              "currency-0-credit-0-amount": "4.99", | ||||||
|  |              "currency-0-debit-1-account_code": Accounts.MEAL, | ||||||
|  |              "currency-0-debit-1-summary": " Dinner—Steak  ", | ||||||
|  |              "currency-0-debit-1-amount": "8.28", | ||||||
|  |              "currency-0-credit-1-account_code": Accounts.PAYABLE, | ||||||
|  |              "currency-0-credit-1-summary": " Dinner—Steak ", | ||||||
|  |              "currency-0-credit-1-amount": "8.28"}, | ||||||
|  |             {"csrf_token": csrf_token, | ||||||
|  |              "next": NEXT_URI, | ||||||
|  |              "date": txn_date, | ||||||
|  |              "currency-0-code": "USD", | ||||||
|  |              "currency-0-debit-0-account_code": Accounts.MEAL, | ||||||
|  |              "currency-0-debit-0-summary": " Lunch—Pizza  ", | ||||||
|  |              "currency-0-debit-0-amount": "5.49", | ||||||
|  |              "currency-0-credit-0-account_code": Accounts.PAYABLE, | ||||||
|  |              "currency-0-credit-0-summary": " Lunch—Pizza ", | ||||||
|  |              "currency-0-credit-0-amount": "5.49", | ||||||
|  |              "currency-0-debit-1-account_code": Accounts.MEAL, | ||||||
|  |              "currency-0-debit-1-summary": " Lunch—Noodles ", | ||||||
|  |              "currency-0-debit-1-amount": "7.47", | ||||||
|  |              "currency-0-credit-1-account_code": Accounts.PAYABLE, | ||||||
|  |              "currency-0-credit-1-summary": " Lunch—Noodles ", | ||||||
|  |              "currency-0-credit-1-amount": "7.47"}, | ||||||
|  |             {"csrf_token": csrf_token, | ||||||
|  |              "next": NEXT_URI, | ||||||
|  |              "date": txn_date, | ||||||
|  |              "currency-0-code": "USD", | ||||||
|  |              "currency-0-debit-0-account_code": Accounts.TRAVEL, | ||||||
|  |              "currency-0-debit-0-summary": " Airplane—Lake City↔Hill Town ", | ||||||
|  |              "currency-0-debit-0-amount": "800"}, | ||||||
|  |             {"csrf_token": csrf_token, | ||||||
|  |              "next": NEXT_URI, | ||||||
|  |              "date": txn_date, | ||||||
|  |              "currency-0-code": "USD", | ||||||
|  |              "currency-0-debit-0-account_code": Accounts.TRAVEL, | ||||||
|  |              "currency-0-debit-0-summary": " Bus—323—Downtown→Museum ", | ||||||
|  |              "currency-0-debit-0-amount": "2.5", | ||||||
|  |              "currency-0-credit-0-account_code": Accounts.PREPAID, | ||||||
|  |              "currency-0-credit-0-summary": " Bus—323—Downtown→Museum ", | ||||||
|  |              "currency-0-credit-0-amount": "2.5", | ||||||
|  |              "currency-0-debit-1-account_code": Accounts.TRAVEL, | ||||||
|  |              "currency-0-debit-1-summary": " Train—Blue—Museum→Central ", | ||||||
|  |              "currency-0-debit-1-amount": "3.2", | ||||||
|  |              "currency-0-credit-1-account_code": Accounts.PREPAID, | ||||||
|  |              "currency-0-credit-1-summary": " Train—Blue—Museum→Central ", | ||||||
|  |              "currency-0-credit-1-amount": "3.2", | ||||||
|  |              "currency-0-debit-2-account_code": Accounts.TRAVEL, | ||||||
|  |              "currency-0-debit-2-summary": " Train—Green—Central→Mall ", | ||||||
|  |              "currency-0-debit-2-amount": "3.2", | ||||||
|  |              "currency-0-credit-2-account_code": Accounts.PREPAID, | ||||||
|  |              "currency-0-credit-2-summary": " Train—Green—Central→Mall ", | ||||||
|  |              "currency-0-credit-2-amount": "3.2", | ||||||
|  |              "currency-0-debit-3-account_code": Accounts.TRAVEL, | ||||||
|  |              "currency-0-debit-3-summary": " Train—Red—Mall→Museum ", | ||||||
|  |              "currency-0-debit-3-amount": "4.4", | ||||||
|  |              "currency-0-credit-3-account_code": Accounts.PAYABLE, | ||||||
|  |              "currency-0-credit-3-summary": " Train—Red—Mall→Museum ", | ||||||
|  |              "currency-0-credit-3-amount": "4.4"}, | ||||||
|  |             {"csrf_token": csrf_token, | ||||||
|  |              "next": NEXT_URI, | ||||||
|  |              "date": txn_date, | ||||||
|  |              "currency-0-code": "USD", | ||||||
|  |              "currency-0-debit-0-account_code": Accounts.TRAVEL, | ||||||
|  |              "currency-0-debit-0-summary": " Taxi—Museum→Office ", | ||||||
|  |              "currency-0-debit-0-amount": "15.5", | ||||||
|  |              "currency-0-credit-0-account_code": Accounts.CASH, | ||||||
|  |              "currency-0-credit-0-summary": " Taxi—Museum→Office ", | ||||||
|  |              "currency-0-credit-0-amount": "15.5", | ||||||
|  |              "currency-0-debit-1-account_code": Accounts.TRAVEL, | ||||||
|  |              "currency-0-debit-1-summary": " Taxi—Office→Restaurant ", | ||||||
|  |              "currency-0-debit-1-amount": "12", | ||||||
|  |              "currency-0-credit-1-account_code": Accounts.PAYABLE, | ||||||
|  |              "currency-0-credit-1-summary": " Taxi—Office→Restaurant ", | ||||||
|  |              "currency-0-credit-1-amount": "12", | ||||||
|  |              "currency-0-debit-2-account_code": Accounts.TRAVEL, | ||||||
|  |              "currency-0-debit-2-summary": " Taxi—Restaurant→City Hall ", | ||||||
|  |              "currency-0-debit-2-amount": "8", | ||||||
|  |              "currency-0-credit-2-account_code": Accounts.PAYABLE, | ||||||
|  |              "currency-0-credit-2-summary": " Taxi—Restaurant→City Hall ", | ||||||
|  |              "currency-0-credit-2-amount": "8", | ||||||
|  |              "currency-0-debit-3-account_code": Accounts.TRAVEL, | ||||||
|  |              "currency-0-debit-3-summary": " Bike—City Hall→Office ", | ||||||
|  |              "currency-0-debit-3-amount": "3.5", | ||||||
|  |              "currency-0-credit-3-account_code": Accounts.PAYABLE, | ||||||
|  |              "currency-0-credit-3-summary": " Bike—City Hall→Office ", | ||||||
|  |              "currency-0-credit-3-amount": "3.5", | ||||||
|  |              "currency-0-debit-4-account_code": Accounts.TRAVEL, | ||||||
|  |              "currency-0-debit-4-summary": " Bike—Restaurant→Office ", | ||||||
|  |              "currency-0-debit-4-amount": "4", | ||||||
|  |              "currency-0-credit-4-account_code": Accounts.PAYABLE, | ||||||
|  |              "currency-0-credit-4-summary": " Bike—Restaurant→Office ", | ||||||
|  |              "currency-0-credit-4-amount": "4", | ||||||
|  |              "currency-0-debit-5-account_code": Accounts.TRAVEL, | ||||||
|  |              "currency-0-debit-5-summary": " Bike—Office→Theatre ", | ||||||
|  |              "currency-0-debit-5-amount": "1.5", | ||||||
|  |              "currency-0-credit-5-account_code": Accounts.PAYABLE, | ||||||
|  |              "currency-0-credit-5-summary": " Bike—Office→Theatre ", | ||||||
|  |              "currency-0-credit-5-amount": "1.5", | ||||||
|  |              "currency-0-debit-6-account_code": Accounts.TRAVEL, | ||||||
|  |              "currency-0-debit-6-summary": " Bike—Theatre→Home ", | ||||||
|  |              "currency-0-debit-6-amount": "5.5", | ||||||
|  |              "currency-0-credit-6-account_code": Accounts.PREPAID, | ||||||
|  |              "currency-0-credit-6-summary": " Bike—Theatre→Home ", | ||||||
|  |              "currency-0-credit-6-amount": "5.5"}, | ||||||
|  |             {"csrf_token": csrf_token, | ||||||
|  |              "next": NEXT_URI, | ||||||
|  |              "date": txn_date, | ||||||
|  |              "currency-0-code": "USD", | ||||||
|  |              "currency-0-debit-0-account_code": Accounts.PAYABLE, | ||||||
|  |              "currency-0-debit-0-summary": " Dinner—Steak  ", | ||||||
|  |              "currency-0-debit-0-amount": "8.28", | ||||||
|  |              "currency-0-credit-0-account_code": Accounts.BANK, | ||||||
|  |              "currency-0-credit-0-summary": " Dinner—Steak ", | ||||||
|  |              "currency-0-credit-0-amount": "8.28", | ||||||
|  |              "currency-0-debit-1-account_code": Accounts.PAYABLE, | ||||||
|  |              "currency-0-debit-1-summary": " Lunch—Pizza ", | ||||||
|  |              "currency-0-debit-1-amount": "5.49", | ||||||
|  |              "currency-0-credit-1-account_code": Accounts.BANK, | ||||||
|  |              "currency-0-credit-1-summary": " Lunch—Pizza  ", | ||||||
|  |              "currency-0-credit-1-amount": "5.49"}] | ||||||
|  |  | ||||||
| @@ -31,7 +31,7 @@ from testlib import get_client | |||||||
| from testlib_txn import Accounts, get_add_form, get_unchanged_update_form, \ | from testlib_txn import Accounts, get_add_form, get_unchanged_update_form, \ | ||||||
|     get_update_form, match_txn_detail, set_negative_amount, \ |     get_update_form, match_txn_detail, set_negative_amount, \ | ||||||
|     remove_debit_in_a_currency, remove_credit_in_a_currency, NEXT_URI, \ |     remove_debit_in_a_currency, remove_credit_in_a_currency, NEXT_URI, \ | ||||||
|     NON_EMPTY_NOTE, EMPTY_NOTE |     NON_EMPTY_NOTE, EMPTY_NOTE, add_txn | ||||||
|  |  | ||||||
| PREFIX: str = "/accounting/transactions" | PREFIX: str = "/accounting/transactions" | ||||||
| """The URL prefix of the transaction management.""" | """The URL prefix of the transaction management.""" | ||||||
| @@ -75,7 +75,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase): | |||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         client, csrf_token = get_client(self.app, "nobody") |         client, csrf_token = get_client(self.app, "nobody") | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         add_form: dict[str, str] = self.__get_add_form() |         add_form: dict[str, str] = self.__get_add_form() | ||||||
|         add_form["csrf_token"] = csrf_token |         add_form["csrf_token"] = csrf_token | ||||||
|         update_form: dict[str, str] = self.__get_update_form(txn_id) |         update_form: dict[str, str] = self.__get_update_form(txn_id) | ||||||
| @@ -110,7 +110,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase): | |||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         client, csrf_token = get_client(self.app, "viewer") |         client, csrf_token = get_client(self.app, "viewer") | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         add_form: dict[str, str] = self.__get_add_form() |         add_form: dict[str, str] = self.__get_add_form() | ||||||
|         add_form["csrf_token"] = csrf_token |         add_form["csrf_token"] = csrf_token | ||||||
|         update_form: dict[str, str] = self.__get_update_form(txn_id) |         update_form: dict[str, str] = self.__get_update_form(txn_id) | ||||||
| @@ -144,7 +144,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase): | |||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         add_form: dict[str, str] = self.__get_add_form() |         add_form: dict[str, str] = self.__get_add_form() | ||||||
|         update_form: dict[str, str] = self.__get_update_form(txn_id) |         update_form: dict[str, str] = self.__get_update_form(txn_id) | ||||||
|         response: httpx.Response |         response: httpx.Response | ||||||
| @@ -326,7 +326,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase): | |||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         from accounting.models import Transaction, TransactionCurrency |         from accounting.models import Transaction, TransactionCurrency | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" |         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" | ||||||
|         edit_uri: str = f"{PREFIX}/{txn_id}/edit?next=%2F_next" |         edit_uri: str = f"{PREFIX}/{txn_id}/edit?next=%2F_next" | ||||||
|         update_uri: str = f"{PREFIX}/{txn_id}/update" |         update_uri: str = f"{PREFIX}/{txn_id}/update" | ||||||
| @@ -479,7 +479,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase): | |||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         from accounting.models import Transaction |         from accounting.models import Transaction | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" |         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" | ||||||
|         update_uri: str = f"{PREFIX}/{txn_id}/update" |         update_uri: str = f"{PREFIX}/{txn_id}/update" | ||||||
|         txn: Transaction |         txn: Transaction | ||||||
| @@ -513,7 +513,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase): | |||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         from accounting.models import Transaction |         from accounting.models import Transaction | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         editor_username, editor2_username = "editor", "editor2" |         editor_username, editor2_username = "editor", "editor2" | ||||||
|         client, csrf_token = get_client(self.app, editor2_username) |         client, csrf_token = get_client(self.app, editor2_username) | ||||||
|         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" |         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" | ||||||
| @@ -542,7 +542,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase): | |||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" |         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" | ||||||
|         delete_uri: str = f"{PREFIX}/{txn_id}/delete" |         delete_uri: str = f"{PREFIX}/{txn_id}/delete" | ||||||
|         response: httpx.Response |         response: httpx.Response | ||||||
| @@ -562,17 +562,6 @@ class CashIncomeTransactionTestCase(unittest.TestCase): | |||||||
|                                           "next": NEXT_URI}) |                                           "next": NEXT_URI}) | ||||||
|         self.assertEqual(response.status_code, 404) |         self.assertEqual(response.status_code, 404) | ||||||
|  |  | ||||||
|     def __add_txn(self) -> int: |  | ||||||
|         """Adds a transaction. |  | ||||||
|  |  | ||||||
|         :return: The newly-added transaction ID.. |  | ||||||
|         """ |  | ||||||
|         store_uri: str = f"{PREFIX}/store/income" |  | ||||||
|         form: dict[str, str] = self.__get_add_form() |  | ||||||
|         response: httpx.Response = self.client.post(store_uri, data=form) |  | ||||||
|         self.assertEqual(response.status_code, 302) |  | ||||||
|         return match_txn_detail(response.headers["Location"]) |  | ||||||
|  |  | ||||||
|     def __get_add_form(self) -> dict[str, str]: |     def __get_add_form(self) -> dict[str, str]: | ||||||
|         """Returns the form data to add a new transaction. |         """Returns the form data to add a new transaction. | ||||||
|  |  | ||||||
| @@ -647,7 +636,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase): | |||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         client, csrf_token = get_client(self.app, "nobody") |         client, csrf_token = get_client(self.app, "nobody") | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         add_form: dict[str, str] = self.__get_add_form() |         add_form: dict[str, str] = self.__get_add_form() | ||||||
|         add_form["csrf_token"] = csrf_token |         add_form["csrf_token"] = csrf_token | ||||||
|         update_form: dict[str, str] = self.__get_update_form(txn_id) |         update_form: dict[str, str] = self.__get_update_form(txn_id) | ||||||
| @@ -682,7 +671,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase): | |||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         client, csrf_token = get_client(self.app, "viewer") |         client, csrf_token = get_client(self.app, "viewer") | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         add_form: dict[str, str] = self.__get_add_form() |         add_form: dict[str, str] = self.__get_add_form() | ||||||
|         add_form["csrf_token"] = csrf_token |         add_form["csrf_token"] = csrf_token | ||||||
|         update_form: dict[str, str] = self.__get_update_form(txn_id) |         update_form: dict[str, str] = self.__get_update_form(txn_id) | ||||||
| @@ -716,7 +705,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase): | |||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         add_form: dict[str, str] = self.__get_add_form() |         add_form: dict[str, str] = self.__get_add_form() | ||||||
|         update_form: dict[str, str] = self.__get_update_form(txn_id) |         update_form: dict[str, str] = self.__get_update_form(txn_id) | ||||||
|         response: httpx.Response |         response: httpx.Response | ||||||
| @@ -901,7 +890,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase): | |||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         from accounting.models import Transaction, TransactionCurrency |         from accounting.models import Transaction, TransactionCurrency | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" |         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" | ||||||
|         edit_uri: str = f"{PREFIX}/{txn_id}/edit?next=%2F_next" |         edit_uri: str = f"{PREFIX}/{txn_id}/edit?next=%2F_next" | ||||||
|         update_uri: str = f"{PREFIX}/{txn_id}/update" |         update_uri: str = f"{PREFIX}/{txn_id}/update" | ||||||
| @@ -1058,7 +1047,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase): | |||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         from accounting.models import Transaction |         from accounting.models import Transaction | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" |         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" | ||||||
|         update_uri: str = f"{PREFIX}/{txn_id}/update" |         update_uri: str = f"{PREFIX}/{txn_id}/update" | ||||||
|         txn: Transaction |         txn: Transaction | ||||||
| @@ -1092,7 +1081,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase): | |||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         from accounting.models import Transaction |         from accounting.models import Transaction | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         editor_username, editor2_username = "editor", "editor2" |         editor_username, editor2_username = "editor", "editor2" | ||||||
|         client, csrf_token = get_client(self.app, editor2_username) |         client, csrf_token = get_client(self.app, editor2_username) | ||||||
|         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" |         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" | ||||||
| @@ -1121,7 +1110,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase): | |||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" |         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" | ||||||
|         delete_uri: str = f"{PREFIX}/{txn_id}/delete" |         delete_uri: str = f"{PREFIX}/{txn_id}/delete" | ||||||
|         response: httpx.Response |         response: httpx.Response | ||||||
| @@ -1141,17 +1130,6 @@ class CashExpenseTransactionTestCase(unittest.TestCase): | |||||||
|                                           "next": NEXT_URI}) |                                           "next": NEXT_URI}) | ||||||
|         self.assertEqual(response.status_code, 404) |         self.assertEqual(response.status_code, 404) | ||||||
|  |  | ||||||
|     def __add_txn(self) -> int: |  | ||||||
|         """Adds a transaction. |  | ||||||
|  |  | ||||||
|         :return: The newly-added transaction ID.. |  | ||||||
|         """ |  | ||||||
|         store_uri: str = f"{PREFIX}/store/expense" |  | ||||||
|         form: dict[str, str] = self.__get_add_form() |  | ||||||
|         response: httpx.Response = self.client.post(store_uri, data=form) |  | ||||||
|         self.assertEqual(response.status_code, 302) |  | ||||||
|         return match_txn_detail(response.headers["Location"]) |  | ||||||
|  |  | ||||||
|     def __get_add_form(self) -> dict[str, str]: |     def __get_add_form(self) -> dict[str, str]: | ||||||
|         """Returns the form data to add a new transaction. |         """Returns the form data to add a new transaction. | ||||||
|  |  | ||||||
| @@ -1226,7 +1204,7 @@ class TransferTransactionTestCase(unittest.TestCase): | |||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         client, csrf_token = get_client(self.app, "nobody") |         client, csrf_token = get_client(self.app, "nobody") | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         add_form: dict[str, str] = self.__get_add_form() |         add_form: dict[str, str] = self.__get_add_form() | ||||||
|         add_form["csrf_token"] = csrf_token |         add_form["csrf_token"] = csrf_token | ||||||
|         update_form: dict[str, str] = self.__get_update_form(txn_id) |         update_form: dict[str, str] = self.__get_update_form(txn_id) | ||||||
| @@ -1261,7 +1239,7 @@ class TransferTransactionTestCase(unittest.TestCase): | |||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         client, csrf_token = get_client(self.app, "viewer") |         client, csrf_token = get_client(self.app, "viewer") | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         add_form: dict[str, str] = self.__get_add_form() |         add_form: dict[str, str] = self.__get_add_form() | ||||||
|         add_form["csrf_token"] = csrf_token |         add_form["csrf_token"] = csrf_token | ||||||
|         update_form: dict[str, str] = self.__get_update_form(txn_id) |         update_form: dict[str, str] = self.__get_update_form(txn_id) | ||||||
| @@ -1295,7 +1273,7 @@ class TransferTransactionTestCase(unittest.TestCase): | |||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         add_form: dict[str, str] = self.__get_add_form() |         add_form: dict[str, str] = self.__get_add_form() | ||||||
|         update_form: dict[str, str] = self.__get_update_form(txn_id) |         update_form: dict[str, str] = self.__get_update_form(txn_id) | ||||||
|         response: httpx.Response |         response: httpx.Response | ||||||
| @@ -1507,7 +1485,7 @@ class TransferTransactionTestCase(unittest.TestCase): | |||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         from accounting.models import Transaction, TransactionCurrency |         from accounting.models import Transaction, TransactionCurrency | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" |         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" | ||||||
|         edit_uri: str = f"{PREFIX}/{txn_id}/edit?next=%2F_next" |         edit_uri: str = f"{PREFIX}/{txn_id}/edit?next=%2F_next" | ||||||
|         update_uri: str = f"{PREFIX}/{txn_id}/update" |         update_uri: str = f"{PREFIX}/{txn_id}/update" | ||||||
| @@ -1698,7 +1676,7 @@ class TransferTransactionTestCase(unittest.TestCase): | |||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         from accounting.models import Transaction |         from accounting.models import Transaction | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" |         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" | ||||||
|         update_uri: str = f"{PREFIX}/{txn_id}/update" |         update_uri: str = f"{PREFIX}/{txn_id}/update" | ||||||
|         txn: Transaction |         txn: Transaction | ||||||
| @@ -1732,7 +1710,7 @@ class TransferTransactionTestCase(unittest.TestCase): | |||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         from accounting.models import Transaction |         from accounting.models import Transaction | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         editor_username, editor2_username = "editor", "editor2" |         editor_username, editor2_username = "editor", "editor2" | ||||||
|         client, csrf_token = get_client(self.app, editor2_username) |         client, csrf_token = get_client(self.app, editor2_username) | ||||||
|         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" |         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" | ||||||
| @@ -1762,7 +1740,7 @@ class TransferTransactionTestCase(unittest.TestCase): | |||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         from accounting.models import Transaction, TransactionCurrency |         from accounting.models import Transaction, TransactionCurrency | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         detail_uri: str = f"{PREFIX}/{txn_id}?as=income&next=%2F_next" |         detail_uri: str = f"{PREFIX}/{txn_id}?as=income&next=%2F_next" | ||||||
|         update_uri: str = f"{PREFIX}/{txn_id}/update?as=income" |         update_uri: str = f"{PREFIX}/{txn_id}/update?as=income" | ||||||
|         form_0: dict[str, str] = self.__get_update_form(txn_id) |         form_0: dict[str, str] = self.__get_update_form(txn_id) | ||||||
| @@ -1861,7 +1839,7 @@ class TransferTransactionTestCase(unittest.TestCase): | |||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         from accounting.models import Transaction, TransactionCurrency |         from accounting.models import Transaction, TransactionCurrency | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         detail_uri: str = f"{PREFIX}/{txn_id}?as=expense&next=%2F_next" |         detail_uri: str = f"{PREFIX}/{txn_id}?as=expense&next=%2F_next" | ||||||
|         update_uri: str = f"{PREFIX}/{txn_id}/update?as=expense" |         update_uri: str = f"{PREFIX}/{txn_id}/update?as=expense" | ||||||
|         form_0: dict[str, str] = self.__get_update_form(txn_id) |         form_0: dict[str, str] = self.__get_update_form(txn_id) | ||||||
| @@ -1963,7 +1941,7 @@ class TransferTransactionTestCase(unittest.TestCase): | |||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         txn_id: int = self.__add_txn() |         txn_id: int = add_txn(self.client, self.__get_add_form()) | ||||||
|         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" |         detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next" | ||||||
|         delete_uri: str = f"{PREFIX}/{txn_id}/delete" |         delete_uri: str = f"{PREFIX}/{txn_id}/delete" | ||||||
|         response: httpx.Response |         response: httpx.Response | ||||||
| @@ -1983,17 +1961,6 @@ class TransferTransactionTestCase(unittest.TestCase): | |||||||
|                                           "next": NEXT_URI}) |                                           "next": NEXT_URI}) | ||||||
|         self.assertEqual(response.status_code, 404) |         self.assertEqual(response.status_code, 404) | ||||||
|  |  | ||||||
|     def __add_txn(self) -> int: |  | ||||||
|         """Adds a transaction. |  | ||||||
|  |  | ||||||
|         :return: The newly-added transaction ID.. |  | ||||||
|         """ |  | ||||||
|         store_uri: str = f"{PREFIX}/store/transfer" |  | ||||||
|         form: dict[str, str] = self.__get_add_form() |  | ||||||
|         response: httpx.Response = self.client.post(store_uri, data=form) |  | ||||||
|         self.assertEqual(response.status_code, 302) |  | ||||||
|         return match_txn_detail(response.headers["Location"]) |  | ||||||
|  |  | ||||||
|     def __get_add_form(self) -> dict[str, str]: |     def __get_add_form(self) -> dict[str, str]: | ||||||
|         """Returns the form data to add a new transaction. |         """Returns the form data to add a new transaction. | ||||||
|  |  | ||||||
| @@ -2062,11 +2029,11 @@ class TransactionReorderTestCase(unittest.TestCase): | |||||||
|         from accounting.models import Transaction |         from accounting.models import Transaction | ||||||
|         response: httpx.Response |         response: httpx.Response | ||||||
|  |  | ||||||
|         id_1: int = self.__add_income_txn() |         id_1: int = add_txn(self.client, self.__get_add_income_form()) | ||||||
|         id_2: int = self.__add_expense_txn() |         id_2: int = add_txn(self.client, self.__get_add_expense_form()) | ||||||
|         id_3: int = self.__add_transfer_txn() |         id_3: int = add_txn(self.client, self.__get_add_transfer_form()) | ||||||
|         id_4: int = self.__add_income_txn() |         id_4: int = add_txn(self.client, self.__get_add_income_form()) | ||||||
|         id_5: int = self.__add_expense_txn() |         id_5: int = add_txn(self.client, self.__get_add_expense_form()) | ||||||
|  |  | ||||||
|         with self.app.app_context(): |         with self.app.app_context(): | ||||||
|             txn_1: Transaction = db.session.get(Transaction, id_1) |             txn_1: Transaction = db.session.get(Transaction, id_1) | ||||||
| @@ -2108,11 +2075,11 @@ class TransactionReorderTestCase(unittest.TestCase): | |||||||
|         from accounting.models import Transaction |         from accounting.models import Transaction | ||||||
|         response: httpx.Response |         response: httpx.Response | ||||||
|  |  | ||||||
|         id_1: int = self.__add_income_txn() |         id_1: int = add_txn(self.client, self.__get_add_income_form()) | ||||||
|         id_2: int = self.__add_expense_txn() |         id_2: int = add_txn(self.client, self.__get_add_expense_form()) | ||||||
|         id_3: int = self.__add_transfer_txn() |         id_3: int = add_txn(self.client, self.__get_add_transfer_form()) | ||||||
|         id_4: int = self.__add_income_txn() |         id_4: int = add_txn(self.client, self.__get_add_income_form()) | ||||||
|         id_5: int = self.__add_expense_txn() |         id_5: int = add_txn(self.client, self.__get_add_expense_form()) | ||||||
|  |  | ||||||
|         with self.app.app_context(): |         with self.app.app_context(): | ||||||
|             txn_date: date = db.session.get(Transaction, id_1).date |             txn_date: date = db.session.get(Transaction, id_1).date | ||||||
| @@ -2160,17 +2127,6 @@ class TransactionReorderTestCase(unittest.TestCase): | |||||||
|             self.assertEqual(db.session.get(Transaction, id_4).no, 1) |             self.assertEqual(db.session.get(Transaction, id_4).no, 1) | ||||||
|             self.assertEqual(db.session.get(Transaction, id_5).no, 5) |             self.assertEqual(db.session.get(Transaction, id_5).no, 5) | ||||||
|  |  | ||||||
|     def __add_income_txn(self) -> int: |  | ||||||
|         """Adds a transaction. |  | ||||||
|  |  | ||||||
|         :return: The newly-added transaction ID.. |  | ||||||
|         """ |  | ||||||
|         store_uri: str = f"{PREFIX}/store/income" |  | ||||||
|         form: dict[str, str] = self.__get_add_income_form() |  | ||||||
|         response: httpx.Response = self.client.post(store_uri, data=form) |  | ||||||
|         self.assertEqual(response.status_code, 302) |  | ||||||
|         return match_txn_detail(response.headers["Location"]) |  | ||||||
|  |  | ||||||
|     def __get_add_income_form(self) -> dict[str, str]: |     def __get_add_income_form(self) -> dict[str, str]: | ||||||
|         """Returns the form data to add a new transaction. |         """Returns the form data to add a new transaction. | ||||||
|  |  | ||||||
| @@ -2180,17 +2136,6 @@ class TransactionReorderTestCase(unittest.TestCase): | |||||||
|         form = {x: form[x] for x in form if "-debit-" not in x} |         form = {x: form[x] for x in form if "-debit-" not in x} | ||||||
|         return form |         return form | ||||||
|  |  | ||||||
|     def __add_expense_txn(self) -> int: |  | ||||||
|         """Adds a transaction. |  | ||||||
|  |  | ||||||
|         :return: The newly-added transaction ID.. |  | ||||||
|         """ |  | ||||||
|         store_uri: str = f"{PREFIX}/store/expense" |  | ||||||
|         form: dict[str, str] = self.__get_add_expense_form() |  | ||||||
|         response: httpx.Response = self.client.post(store_uri, data=form) |  | ||||||
|         self.assertEqual(response.status_code, 302) |  | ||||||
|         return match_txn_detail(response.headers["Location"]) |  | ||||||
|  |  | ||||||
|     def __get_add_expense_form(self) -> dict[str, str]: |     def __get_add_expense_form(self) -> dict[str, str]: | ||||||
|         """Returns the form data to add a new transaction. |         """Returns the form data to add a new transaction. | ||||||
|  |  | ||||||
| @@ -2214,17 +2159,6 @@ class TransactionReorderTestCase(unittest.TestCase): | |||||||
|         form = {x: form[x] for x in form if "-credit-" not in x} |         form = {x: form[x] for x in form if "-credit-" not in x} | ||||||
|         return form |         return form | ||||||
|  |  | ||||||
|     def __add_transfer_txn(self) -> int: |  | ||||||
|         """Adds a transaction. |  | ||||||
|  |  | ||||||
|         :return: The newly-added transaction ID.. |  | ||||||
|         """ |  | ||||||
|         store_uri: str = f"{PREFIX}/store/transfer" |  | ||||||
|         form: dict[str, str] = self.__get_add_transfer_form() |  | ||||||
|         response: httpx.Response = self.client.post(store_uri, data=form) |  | ||||||
|         self.assertEqual(response.status_code, 302) |  | ||||||
|         return match_txn_detail(response.headers["Location"]) |  | ||||||
|  |  | ||||||
|     def __get_add_transfer_form(self) -> dict[str, str]: |     def __get_add_transfer_form(self) -> dict[str, str]: | ||||||
|         """Returns the form data to add a new transaction. |         """Returns the form data to add a new transaction. | ||||||
|  |  | ||||||
|   | |||||||
| @@ -22,6 +22,7 @@ from decimal import Decimal | |||||||
| from datetime import date | from datetime import date | ||||||
| from secrets import randbelow | from secrets import randbelow | ||||||
|  |  | ||||||
|  | import httpx | ||||||
| from flask import Flask | from flask import Flask | ||||||
|  |  | ||||||
| from test_site import db | from test_site import db | ||||||
| @@ -38,11 +39,14 @@ class Accounts: | |||||||
|     """The shortcuts to the common accounts.""" |     """The shortcuts to the common accounts.""" | ||||||
|     CASH: str = "1111-001" |     CASH: str = "1111-001" | ||||||
|     BANK: str = "1113-001" |     BANK: str = "1113-001" | ||||||
|  |     PREPAID: str = "1258-001" | ||||||
|  |     PAYABLE: str = "2141-001" | ||||||
|     SALES: str = "4111-001" |     SALES: str = "4111-001" | ||||||
|     SERVICE: str = "4611-001" |     SERVICE: str = "4611-001" | ||||||
|     AGENCY: str = "4711-001" |     AGENCY: str = "4711-001" | ||||||
|     OFFICE: str = "5153-001" |     OFFICE: str = "6153-001" | ||||||
|     TRAVEL: str = "5154-001" |     TRAVEL: str = "6154-001" | ||||||
|  |     MEAL: str = "6172-001" | ||||||
|     INTEREST: str = "4111-001" |     INTEREST: str = "4111-001" | ||||||
|     DONATION: str = "7481-001" |     DONATION: str = "7481-001" | ||||||
|     RENT: str = "7482-001" |     RENT: str = "7482-001" | ||||||
| @@ -381,6 +385,25 @@ def __get_currency_prefix(form: dict[str, str], code: str) -> str: | |||||||
|     return m.group(1) |     return m.group(1) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def add_txn(client: httpx.Client, form: dict[str, str]) -> int: | ||||||
|  |     """Adds a transfer transaction. | ||||||
|  |  | ||||||
|  |     :param client: The client. | ||||||
|  |     :param form: The form data. | ||||||
|  |     :return: The newly-added transaction ID. | ||||||
|  |     """ | ||||||
|  |     prefix: str = "/accounting/transactions" | ||||||
|  |     txn_type: str = "transfer" | ||||||
|  |     if len({x for x in form if "-debit-" in x}) == 0: | ||||||
|  |         txn_type = "income" | ||||||
|  |     elif len({x for x in form if "-credit-" in x}) == 0: | ||||||
|  |         txn_type = "expense" | ||||||
|  |     store_uri = f"{prefix}/store/{txn_type}" | ||||||
|  |     response: httpx.Response = client.post(store_uri, data=form) | ||||||
|  |     assert response.status_code == 302 | ||||||
|  |     return match_txn_detail(response.headers["Location"]) | ||||||
|  |  | ||||||
|  |  | ||||||
| def match_txn_detail(location: str) -> int: | def match_txn_detail(location: str) -> int: | ||||||
|     """Validates if the redirect location is the transaction detail, and |     """Validates if the redirect location is the transaction detail, and | ||||||
|     returns the transaction ID on success. |     returns the transaction ID on success. | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user