153 Commits

Author SHA1 Message Date
621020b0f0 Advanced to version 1.3.2. 2023-04-12 18:05:13 +08:00
6ad36cfaa3 Updated the translation of the test site. 2023-04-12 18:05:13 +08:00
20b0412091 Added the sample data generation and database reset on the test site for live demonstration. 2023-04-12 18:05:13 +08:00
3ca246d3e0 Revised the strings in babel-utils.py and babel-utils-test-site.py. 2023-04-12 15:04:32 +08:00
85d1b13ccd Added the "populate" method to the BaseTestData class, and changed it so that the tests need to call the "populate" method to populate the data, so that it may return the data with populating the database in the future. 2023-04-12 12:28:34 +08:00
3bada28b8f Revised the BaseTestData class in testlib.py to add journal entries directly to the database instead of through the API, in order to allow the data to be reused, and to speed up the test. 2023-04-12 12:12:11 +08:00
8f2cef8d81 Revised the imports in the accounting.journal_entry.converters module. 2023-04-12 00:41:29 +08:00
e62316c477 Advanced to version 1.3.1. 2023-04-11 22:32:22 +08:00
24ddb0c278 Updated the translation of the test site. 2023-04-11 22:27:31 +08:00
536f3390aa Revised the home page of the test site. 2023-04-11 22:27:31 +08:00
fadd8e73b6 Revised the log in process of the test site to return to the previous page after logging in. 2023-04-11 22:27:11 +08:00
12ccf658bf Revised the documentation in README.rst and intro.rst. 2023-04-11 21:56:49 +08:00
e30d1257e5 Revised the navigation bar so that viewers do not see the menu of the unmatched offsets. 2023-04-11 07:50:10 +08:00
404b902d88 Advanced to version 1.3.0. 2023-04-11 00:08:53 +08:00
a560ff175a Updated the Sphinx documentation. 2023-04-11 00:06:17 +08:00
4be1ead6b5 Added the "accounting-init-db" console command to the database initialization of the test site, for simplicity. 2023-04-10 23:58:08 +08:00
700e4f822a Merged the "init-db" console command to the Flask application initialization in the test site, to simplify the code. 2023-04-10 23:50:16 +08:00
c21ed59dfe Replaced SQLAlchemy 1.x-style bulk_save_objects(objects) with SQLAlchemy 2.x-style execute(insert(model), data). 2023-04-10 23:38:31 +08:00
c4a8326bfc Added the "accounting-init-db" console command to replace the trivial "accounting-init-base", "accounting-init-accounts" and "accounting-init-currencies" console commands. 2023-04-10 23:38:27 +08:00
371c80f668 Removed the unused CurrencyData custom type from the "accounting.currency.commands" module. 2023-04-10 23:12:49 +08:00
40be3fb664 Replaced SQLAlchemy 1.x-style bulk_insert_mappings(model, data) with SQLAlchemy 2.x-style execute(insert(model), data). 2023-04-10 19:56:16 +08:00
1e56403b35 Advanced to version 1.2.1. 2023-04-09 21:04:18 +08:00
650c26036a Fixed the search result to allow full year/month/day specification. 2023-04-09 21:03:18 +08:00
b19a6e5ffe Advanced to version 1.2.0. 2023-04-09 12:16:20 +08:00
1224d6f83e Added the CSV_MIME constant to test_report.py to simplify the ReportTestCase test case. 2023-04-09 12:09:52 +08:00
3a8618f7c3 Fixed the csv_download function when downloading data with non-US-ASCII filenames in the "accounting.report.utils.csv_export" module. 2023-04-09 12:07:31 +08:00
5d87205659 Changed the data in the ReportTestData class to be non-US-ASCII. 2023-04-09 11:55:15 +08:00
04de4f5c5e Merged testlib_offset.py into testlib.py. 2023-04-09 11:46:55 +08:00
f8ea863b80 Moved the add_journal_entry and match_journal_entry_detail functions from testlib_journal_entry.py to testlib.py. They are used by everyone, and testlib_journal_entry.py is only for test_journal_entry.py to shorten the code in one single file. 2023-04-09 11:46:55 +08:00
5ae0d03b32 Revised the imports in testlib_journal_entry.py. 2023-04-09 11:46:55 +08:00
a9a3ad5871 Fixed the data type of the original line item ID in the forms in the OffsetTestCase test case. 2023-04-09 11:46:55 +08:00
5edc95afce Moved the TestData class from testlib_offset.py to test_offset.py, and renamed it to OffsetTestData. It is only used in test_offset.py now. 2023-04-09 11:46:55 +08:00
943ace6fc7 Added ReportTestData as the test data for the ReportTestCase test case. 2023-04-09 11:46:46 +08:00
a63bc977e9 Added the _add_simple_journal_entry method to the BaseTestData class in testlib_offset.py to simplify the code. 2023-04-09 10:50:50 +08:00
dabe6ddbca Renamed the _set_is_need_offset method to _set_need_offset in the BaseTestData class in testlib_offset.py. 2023-04-09 10:42:18 +08:00
f47e9b3150 Renamed the CurrencyData class to JournalEntryCurrencyData in testlib_offset.py, to be clear. 2023-04-09 10:42:18 +08:00
bb5383febe Removed the test data from the OptionTestCase test case. It does not need data. 2023-04-09 10:42:18 +08:00
87f9063ceb Added the BaseTestData class in testlib_offset.py to simplify the test data, and changed the TestData, DifferentTestData, and SameTestData classes to its subclasses. 2023-04-09 10:31:44 +08:00
51f0185bcf Added to test the search in the ReportTestCase test case. 2023-04-09 10:08:23 +08:00
7ca08d6cc8 Added to test the CSV download in the ReportTestCase test case. 2023-04-09 10:08:23 +08:00
c8e9e562be Fixed a URL in the test_nobody test of the ReportTestCase test case. 2023-04-09 10:08:23 +08:00
ba43bd7e90 Simplify the URL of the default reports. 2023-04-09 10:08:23 +08:00
4e550413ba Revised the styles for blueprints to specify the URL, for consistency in the base account, account, currency, and journal entry management. 2023-04-09 10:08:22 +08:00
59a3cbb472 Added the ReportTestCase test case. 2023-04-09 10:08:22 +08:00
d1b64d069e Added the test_empty_db test to the UnmatchedOffsetTestCase test case. 2023-04-09 10:08:11 +08:00
d823d3254f Fixed the date in test_unmatched_offset.py. 2023-04-09 10:07:56 +08:00
5e9a2fb0c3 Renamed test_offset_matcher.py to test_unmatched_offset.py, and the OffsetMatcherTestCase test case to UnmatchedOffsetTestCase. 2023-04-09 10:06:53 +08:00
3f2e659ba5 Added the test_nobody, test_viewer, and test_editor tests to test the permissions in the OffsetMatcherTestCase test case. 2023-04-09 10:06:33 +08:00
9f7bb6b9de Added match_uri to the tests of the OffsetMatcherTestCase test case, for readability. 2023-04-09 08:25:34 +08:00
6857164702 Added the PREFIX constant to simplify the OffsetMatcherTestCase test case. 2023-04-09 08:22:25 +08:00
6bac76be64 Fixed an error in the formatted string in the translation. 2023-04-09 01:41:42 +08:00
370d2668e5 Advanced to version 1.1.0. 2023-04-09 00:48:57 +08:00
5e3e695e62 Updated the Sphinx documentation. 2023-04-09 00:41:14 +08:00
510d369e9c Updated the translation. 2023-04-09 00:39:46 +08:00
b65cae9252 Added the OffsetMatcherTestCase test case. 2023-04-09 00:39:46 +08:00
285c12406b Revised the property names in the TestData class in testlib_offset.py. 2023-04-09 00:39:46 +08:00
df240472a4 Changed the permission to the offset matcher so that editors can use it. 2023-04-09 00:39:45 +08:00
1218b224fc Renamed the "accounting.unmatched_offset.forms" module to "accounting.utils.offset_matcher". 2023-04-09 00:39:45 +08:00
79689ac0e5 Revised the unapplied original line item report to mark matched offsets for administrators when there are unmatched offsets. 2023-04-09 00:39:45 +08:00
1660e66766 Revised the background color of the report tables, for better look on non-white backgrounds. 2023-04-09 00:39:45 +08:00
12d00c9c7d Added the unmatched offset list and the offset matcher. 2023-04-09 00:39:11 +08:00
428018e4a9 Added the match pseudo property to the JournalEntryLineItem data model. 2023-04-08 18:12:57 +08:00
a8f318b0bb Reordered the methods in the JournalEntryLineItem data model. 2023-04-08 18:12:57 +08:00
a3507494e5 Added the refundable deposit accounts to the default list of accounts that need offset in the accounting-init-accounts console command. 2023-04-08 18:12:57 +08:00
3aa6c8d6f6 Removed the empty value in the __is_need_offset function in the "accounting.account.commands" console.command. 2023-04-08 18:12:56 +08:00
052b62cdd4 Moved the __query_line_items method in the UnappliedOriginalLineItems report to the new "accounting.utils.unapplied" module, to share this query. 2023-04-08 18:12:56 +08:00
3728a4037d Renamed the UnappliedAccountConverter path converter to NeedOffsetAccountConverter. 2023-04-08 18:12:56 +08:00
6eee17d44f Added the account list as the default page for the unapplied original line items. 2023-04-08 18:12:55 +08:00
e5cc2b5a2f Added the "count" pseudo property to the Account data model. 2023-04-08 18:12:55 +08:00
ac3b5523b1 Fixed the documentation of the default_currency and default_ie_account pseudo property in the Options class. 2023-04-08 18:12:55 +08:00
5af6fd9619 Moved the "accounting.journal_entry.utils.offset_alias" module to "accounting.utils.offset_alias". 2023-04-08 18:12:55 +08:00
71a20cba29 Replaced the "default_currency_text" pseudo property with the "default_currency" pseudo property in the Options class. 2023-04-08 18:12:54 +08:00
4a4cf1ea40 Removed the redundant "default_ie_account_code_text" pseudo property from the Options class. 2023-04-08 18:12:54 +08:00
e9824808ec Added the UnappliedAccountConverter path converter to only allow the accounts that need offsets. 2023-04-08 18:12:54 +08:00
c984d2d596 Renamed the IncomeExpensesAccountConverter path converter to CurrentAccountConverter. 2023-04-08 18:12:54 +08:00
720e77c814 Fixed the documentation of the PeriodConverter and IncomeExpensesAccountConverter path converters. 2023-04-08 18:12:54 +08:00
0f0412827d Added the unapplied original line item report. 2023-04-08 18:12:45 +08:00
3a0e978f76 Removed an unused import from the "accounting.journal_entry.forms.line_item" module. 2023-04-08 00:44:13 +08:00
8c10d42d7b Added documentation to the currency and account parameters of the CSVRow class, and the pagination parameter of the PageParams class in the "accounting.report.reports.journal" module. 2023-04-08 00:44:13 +08:00
04ec51afbe Changed the "offsets" relationship to a pseudo property, to apply the correct but complex ordering rules. 2023-04-07 16:04:54 +08:00
fe7a8842ce Fixed the query in the JournalEntryConverter converter. 2023-04-07 15:31:06 +08:00
66daa5c42c Fixed the query in the KeepAccountWhenHavingOffset validator. 2023-04-07 15:29:17 +08:00
27fb44937d Fixed the incorrect query in the "offsets" pseudo property of the LineItemForm form. 2023-04-07 15:11:04 +08:00
7026ed3a65 Fixed the order of the items in the "offsets" pseudo property of the LineItemForm form. 2023-04-07 15:01:22 +08:00
fdd3e93778 Fixed the net balance in the line items in the journal entry detail. 2023-04-07 14:57:24 +08:00
def7559457 Fixed the #filterOptions in the JavaScript JournalEntryAccountSelector to show the "more" option when there is no matches, but it is not showing all the accounts. 2023-04-07 12:34:24 +08:00
7905820d68 Revised the imports in the "accounting.base_account.views" and "accounting.currency.views" modules. 2023-04-06 16:09:36 +08:00
7ae332c975 Moved the "Test Site and Live Demonstration" section to the front of the documentation. 2023-04-06 10:00:24 +08:00
86c5b91697 Advanced to version 1.0.1. 2023-04-06 08:43:14 +08:00
9168840e64 Fixed an error in the example configuration. 2023-04-06 08:38:39 +08:00
21b9cfa8b8 Revised the documentation. 2023-04-06 08:31:19 +08:00
b0b3b3acb1 Moved the history section out from README.rst and intro.rst, to the new history.rst. 2023-04-06 08:21:32 +08:00
cb1d254cf0 Advanced to version 1.0.0. Hooray! 2023-04-06 02:55:19 +08:00
eb9ad57e72 Updated the translation. 2023-04-06 02:55:17 +08:00
ec26f8ef4d Added the documentation. 2023-04-06 02:54:45 +08:00
7ed29115ed Revised the inclusion in the base template of the test site. 2023-04-06 02:01:05 +08:00
95955197ac Updated the copyright year in pyproject.toml. 2023-04-05 22:50:54 +08:00
d5a0f79e4b Revised the Read the Docs configuration, and removed the redundant requirements.txt for Read the Docs. 2023-04-05 22:01:53 +08:00
6aa655aa64 Replaced setup.cfg with pyproject.toml for the package settings, and rewrote the packaging rules in MANIFEST.in. 2023-04-05 19:49:52 +08:00
6e532af26e Added the Read the Docs documentation link to README.rst. 2023-04-05 14:25:33 +08:00
fa1818d124 Added the Read the Docs configuration file. 2023-04-05 14:12:46 +08:00
f21ecc2aa9 Added requirements.txt for Read the Docs. 2023-04-05 14:07:37 +08:00
5ae1ab95ae Advanced to version 0.11.1. 2023-04-05 13:00:46 +08:00
7a5b3b78fc Removed the rows with zero balance from the income statement. 2023-04-05 12:59:50 +08:00
7df4051452 Removed the rows with zero balance from the trial balance. 2023-04-05 12:56:28 +08:00
85084c68fd Removed the rows with zero balance from the balance sheet. 2023-04-05 12:29:58 +08:00
0185c16654 Advanced to version 0.11.0. 2023-04-05 09:59:23 +08:00
7dd007f3cf Revised README.rst. 2023-04-05 09:57:34 +08:00
38b8a028d5 Reversed the original line items in the original line item selector. 2023-04-05 09:25:41 +08:00
213981a8b2 Revised the style of the buttons in the description editor, to avoid overwhelming the modal when there are too many buttons. 2023-04-05 09:11:27 +08:00
a4d1789b58 Moved the income and expenses log to the first item of the report chooser. 2023-04-05 08:15:16 +08:00
91620d7db2 Revised the init_app function in the "accounting" module. 2023-04-05 08:07:17 +08:00
02fcabb0ce Updated the URI of the reports to be the default views of the application. 2023-04-05 08:06:00 +08:00
4c2dcc5070 Renamed the project from "Mia! Accounting Flask" to "Mia! Accounting". 2023-04-04 18:26:54 +08:00
c9166fda4d Fixed the order in the get_selectable_original_line_items function in the "accounting.journal_entry.utils.original_line_item" module. 2023-04-04 10:54:43 +08:00
3a0f0873e2 Added documentation to the bp, babel_js, csrf, and db variables in the test site. 2023-04-03 22:18:58 +08:00
a17395b43e Advanced to version 0.10.0. 2023-04-03 22:08:02 +08:00
17c8d9d1a9 Revised the styles of the buttons of the suggested accounts in the description editor. 2023-04-03 22:07:56 +08:00
fa94cd407e Added the JavaScript setElementShown function in the journal entry form for readability. 2023-04-03 21:37:51 +08:00
9a704c8185 Revised the JavaScript account reorder code to avoid nested template literals, for readability. 2023-04-03 21:20:24 +08:00
8286c0c6d8 Revised the JavaScript MonthTab class in the period chooser to avoid nested template literals, for readability. 2023-04-03 21:19:48 +08:00
f7efacad75 Added the unauthorized method to the UserUtilityInterface interface, so that when the user has not logged in, the permission decorator can ask the user to log in instead of failing with HTTP 403 Forbidden. 2023-04-03 19:50:47 +08:00
9263ae0274 Changed the "account" property to private as "__account" in the DescriptionAccount class. 2023-04-03 19:50:47 +08:00
78a9d7794c Revised the JavaScript OriginalLineItem class to store the form instead of the selector. The selector is only used in the constructor. 2023-04-03 19:50:47 +08:00
f3ae37a409 Removed the "#selector" attribute from the JavaScript RecurringAccount class. It is only used in the constructor. There is no need to hold a reference to it. 2023-04-03 19:50:47 +08:00
ddc1081252 Removed the "#selector" attribute from the JavaScript BaseAccountOption class. It is only used in the constructor. There is no need to hold a reference to it. 2023-04-03 19:50:46 +08:00
202d51a032 Removed the "#selector" attribute from the JavaScript JournalEntryAccountOption class. It is only used in the constructor. There is no need to hold a reference to it. 2023-04-03 19:50:46 +08:00
562bc47be7 Revised the saveDescription method of the JournalEntryLineItemEditor editor to also save the isAccountConfirmed status of the DescriptionEditor editor, so that when the user selected any suggested account other than the confirmed account, the confirmed account is released from the next edit. 2023-04-03 19:50:46 +08:00
f3d43a66cc Fixed the operator in the selectAccount method of the JavaScript DescriptionEditor editor. 2023-04-03 19:50:46 +08:00
c3fc6d9a87 Revised the onOpen method of the JavaScript DescriptionEditor editor, to clear the tab planes after the confirmed account is set, so that it works in an environment where the confirmed account is already set. 2023-04-03 19:50:46 +08:00
e1a0380628 Revised the saveDescription method of the JavaScript JournalEntryLineItemEditor to accept the description editor instead of the separated description and account values. 2023-04-03 19:50:46 +08:00
f2a2fcdd32 Revised the "#onDescriptionChange" method to also reset the selected account in the JavaScript DescriptionEditor editor. 2023-04-03 19:50:46 +08:00
ab29166f1e Renamed the "#reset" method to "#resetTabPlanes" in the JavaScript DescriptionEditor, to be clear. 2023-04-03 19:50:46 +08:00
8033921181 Revised the JavaScript DescriptionEditor class so that the #reset() method is triggered by the #onDescriptionChange event, but not the onOpen event, so that user-edited description updates also clear the tab planes. 2023-04-03 19:50:45 +08:00
08732c1e66 Renamed the description attribute to #descriptionInput, and added the description getter and setter to the JavaScript DescriptionEditor editor, to hide the actual implementation of the description input. 2023-04-03 19:50:45 +08:00
4adc464d3d Merged the saveDescriptionWithAccount into the saveDescription method in the JavaScript JournalEntryLineItemEditor class. 2023-04-03 19:12:06 +08:00
2f9d2e36cb Revised the parameters of the saveDescriptionWithAccount method of the JavaScript JournalEntryLineItemEditor class to accept an DescriptionEditorAccount instance instead of the individual account values. 2023-04-03 19:12:06 +08:00
5bb10bf6ba Added the JavaScript DescriptionEditorAccount, DescriptionEditorSuggestedAccount, and DescriptionEditorConfirmedAccount classes, and revised the DescriptionEditor editor to work with these class instances instead of the HTML elements, for simplicity and readability. 2023-04-03 19:12:06 +08:00
06e7b6ddff Added the missing "is_need_offset" property to the DescriptionAccount class. 2023-04-03 19:11:10 +08:00
20e1982984 Renamed the "accounting-is-need-offset" class to "accounting-account-is-need-offset" in the line item sub-form of the journal entry form, for consistency. 2023-04-02 22:29:27 +08:00
a70720be50 Renamed the #selectedAccount attribute to #selectedAccountButton, and the filterSuggestedAccounts, #selectSuggestedAccount, clearSuggestedAccounts, #initializeSuggestedAccounts, #selectAccount, #setConfirmedAccount, and #setSuggestedAccounts methods to filterSuggestedAccountButtons, #selectSuggestedAccountButton, clearSuggestedAccountButtons, #initializeSuggestedAccountButtons, #selectAccountButton, #setConfirmedAccountButton, and #setSuggestedAccountButtons, respectively, in the JavaScript DescriptionEditor class. 2023-04-02 22:16:29 +08:00
cb6de08152 Moved the JournalEntryAccount class from journal-entry-line-item-editor.js to journal-entry-form.js. 2023-04-01 22:42:58 +08:00
211821b4d7 Added the "confirmed account" to the description editor so that it does not override the user's selected account when the user specifically selected it or already confirmed it. 2023-04-01 18:05:48 +08:00
0faca49540 Revised the save method of the JavaScript LineItemSubForm class to update whether it needs offsetting, too. 2023-04-01 00:34:29 +08:00
14e79df571 Revised the line item sub-form to store the information whether it needs offsetting as a class instead of a dataset attribute, and store it in the account code input instead of the whole element, for simplicity and readability. 2023-04-01 00:29:04 +08:00
04fbb725d2 Revised the logic to save the account in the save method of the LineItemSubForm class, since when saving from the line item editor, the account is never null. 2023-04-01 00:19:32 +08:00
a1d6844e52 Replaced the accountCode and accountText getters with the account getter in the JavaScript LineItemSubForm class. 2023-04-01 00:14:47 +08:00
94391b02a6 Added the copy() method to the JavaScript JournalEntryAccount class, and replaced the accountCode and accountText fields with the account field in the OriginalLineItem class. 2023-03-31 23:54:56 +08:00
1cb8a7563e Added the JavaScript JournalEntryAccount class, and added the account field to the JournalEntryLineItemEditor class to replace the accountCode, accountText, and isNeedOffset fields. 2023-03-31 23:33:38 +08:00
63f0f28948 Prefix the classes in the JavaScript description editor with the "DescriptionEditor". 2023-03-27 07:22:36 +08:00
3431922f12 Removed an unused import from the "accounting.models" module. 2023-03-26 01:06:19 +08:00
d5a9e1af18 Removed an unnecessary "start" variable in the constructor of the JavaScript MonthTab class. 2023-03-25 08:37:17 +08:00
73f5d63f44 Replaced string concatenations with ES6 template literals. 2023-03-25 08:37:13 +08:00
219 changed files with 5441 additions and 1860 deletions

2
.gitignore vendored
View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/27 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/27
# Copyright (c) 2022 imacat. # Copyright (c) 2022 imacat.

40
.readthedocs.yaml Normal file
View File

@ -0,0 +1,40 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/5
# 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.
# .readthedocs.yaml
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Set the version of Python and other tools you might need
build:
os: ubuntu-22.04
tools:
python: "3.11"
# Build documentation in the docs/ directory with Sphinx
# If using Sphinx, optionally build your docs in additional formats such as PDF
formats: all
# Optionally declare the Python requirements required to build your docs
python:
install:
- method: pip
path: .

View File

@ -1,4 +1,4 @@
# The Mia! Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21 # Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
# Copyright (c) 2022-2023 imacat. # Copyright (c) 2022-2023 imacat.
@ -15,17 +15,14 @@
# 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.
recursive-include src/accounting/static *
exclude src/accounting/static/js/dummy.js exclude src/accounting/static/js/dummy.js
include src/accounting/translations/* recursive-include src/accounting/templates *
include src/accounting/translations/*/LC_MESSAGES/* recursive-include src/accounting/translations *
include docs/* recursive-include src/accounting/data *
include docs/source/* recursive-include docs *
include docs/source/_static/* recursive-exclude docs/build *
include docs/source/_templates/* recursive-include tests *
include tests/*
exclude tests/test_temp.py exclude tests/test_temp.py
include tests/test_site/* recursive-exclude tests *.pyc
include tests/test_site/static/* recursive-exclude tests/instance *
include tests/test_site/templates/*
include tests/test_site/translations/*
include tests/test_site/translations/*/LC_MESSAGES/*

View File

@ -1,24 +1,167 @@
===================== ===============
Mia! Accounting Flask Mia! Accounting
===================== ===============
Description Description
=========== ===========
This is the Mia! Accounting Flask project. It is an accounting *Mia! Accounting* is an accounting module for Flask_ applications.
module for the Flask_ applications. It is designed both for mobile and desktop environments. It
implements `double-entry bookkeeping`_. It generates the following
accounting reports:
* Trial balance
* Income statement
* Balance sheet
In addition, *Mia! Accounting* tracks offsets for unpaid payables and
receivables.
Install Live Demonstration and Test Site
======= ================================
Install the latest source from the There is a `live demonstration`_ for *Mia! Accounting*. It runs the
`Mia! Accounting Flask repository`_. same code as the `test site`_ in the `source distribution`_. It is
the simplest website that works with *Mia! Accounting*. It is also
used in the automatic tests.
If you do not have a running Flask application or do not know how to
start one, you may start with the test site.
Installation
============
Install *Mia! Accounting* with ``pip``:
:: ::
pip install git+https://gitea.imacat.idv.tw/imacat/mia-accounting-flask.git pip install mia-accounting
You may also download from the `PyPI project page`_ or the
`release page`_ on the `Git repository`_.
Prerequisites
=============
You need a running Flask application with database user login.
The primary key of the user data model must be integer. You also
need at least one user.
The following front-end JavaScript libraries must be loaded. You may
download it locally or use CDN_.
* Bootstrap_ 5.2.3 or above
* FontAwesome_ 6.2.1 or above
* `Decimal.js`_ 6.4.3 or above
* `Tempus-Dominus`_ 6.4.3 or above
Configuration
=============
You need to pass the Flask *app* and an implementation of
`UserUtilityInterface`_ to the `init_app`_ function.
``UserUtilityInterface`` contains everything *Mia! Accounting* needs.
The following is an example configuration for *Mia! Accounting*.
::
from flask import Response, redirect
from .auth import current_user()
from .modules import User
def create_app(test_config=None) -> Flask:
app: Flask = Flask(__name__)
... (Configuration of SQLAlchemy, CSRF, Babel_JS, ... etc) ...
import accounting
class UserUtils(accounting.UserUtilityInterface[User]):
def can_view(self) -> bool:
return True
def can_edit(self) -> bool:
return "editor" in current_user().roles
def can_admin(self) -> bool:
return current_user().is_admin
def unauthorized(self) -> Response:
return redirect("/login")
@property
def cls(self) -> t.Type[User]:
return User
@property
def pk_column(self) -> Column:
return User.id
@property
def current_user(self) -> User | None:
return current_user()
def get_by_username(self, username: str) -> User | None:
return User.query.filter(User.username == username).first()
def get_pk(self, user: User) -> int:
return user.id
accounting.init_app(app, UserUtils())
... (Any other configuration) ...
return app
Database Initialization
=======================
After the configuration, run the ``accounting-init-db`` console
command to initialize the accounting database. You need to specify
the username of a user as the data creator.
::
% flask --app myapp accounting-init-db -u username
Navigation Menu
===============
Include the navigation menu in the `Bootstrap navigation bar`_ in your
base template:
::
<nav class="navbar navbar-expand-lg bg-body-tertiary bg-dark navbar-dark">
<div class="container-fluid">
...
<div id="collapsible-navbar" class="collapse navbar-collapse">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
...
{% include "accounting/include/nav.html" %}
...
</ul>
...
</div>
</div>
</nav>
Check your Flask application and see how it works.
Documentation
=============
Refer to the `documentation on Read the Docs`_.
Copyright Copyright
@ -46,5 +189,21 @@ Authors
| imacat@mail.imacat.idv.tw | imacat@mail.imacat.idv.tw
| 2023/1/27 | 2023/1/27
.. _Flask: https://flask.palletsprojects.com .. _Flask: https://flask.palletsprojects.com
.. _Mia! Accounting Flask repository: https://gitea.imacat.idv.tw/imacat/mia-accounting-flask .. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping
.. _live demonstration: https://accounting.imacat.idv.tw
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
.. _source distribution: https://pypi.org/project/mia-accounting/#files
.. _PyPI project page: https://pypi.org/project/mia-accounting
.. _release page: https://github.com/imacat/mia-accounting/releases
.. _Git repository: https://github.com/imacat/mia-accounting
.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network
.. _Bootstrap: https://getbootstrap.com
.. _FontAwesome: https://fontawesome.com
.. _Decimal.js: https://mikemcl.github.io/decimal.js
.. _Tempus-Dominus: https://getdatepicker.com
.. _UserUtilityInterface: https://mia-accounting.readthedocs.io/en/latest/accounting.utils.html#accounting.utils.user.UserUtilityInterface
.. _init_app: https://mia-accounting.readthedocs.io/en/latest/accounting.html#accounting.init_app
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io

View File

@ -20,14 +20,6 @@ accounting.journal\_entry.utils.description\_editor module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.journal\_entry.utils.offset\_alias module
----------------------------------------------------
.. automodule:: accounting.journal_entry.utils.offset_alias
:members:
:undoc-members:
:show-inheritance:
accounting.journal\_entry.utils.operators module accounting.journal\_entry.utils.operators module
------------------------------------------------ ------------------------------------------------

View File

@ -60,6 +60,22 @@ accounting.report.reports.trial\_balance module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.report.reports.unapplied module
------------------------------------------
.. automodule:: accounting.report.reports.unapplied
:members:
:undoc-members:
:show-inheritance:
accounting.report.reports.unapplied\_accounts module
----------------------------------------------------
.. automodule:: accounting.report.reports.unapplied_accounts
:members:
:undoc-members:
:show-inheritance:
Module contents Module contents
--------------- ---------------

View File

@ -52,6 +52,14 @@ accounting.report.utils.report\_type module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.report.utils.unapplied module
----------------------------------------
.. automodule:: accounting.report.utils.unapplied
:members:
:undoc-members:
:show-inheritance:
accounting.report.utils.urls module accounting.report.utils.urls module
----------------------------------- -----------------------------------

View File

@ -13,11 +13,20 @@ Subpackages
accounting.journal_entry accounting.journal_entry
accounting.option accounting.option
accounting.report accounting.report
accounting.unmatched_offset
accounting.utils accounting.utils
Submodules Submodules
---------- ----------
accounting.commands module
--------------------------
.. automodule:: accounting.commands
:members:
:undoc-members:
:show-inheritance:
accounting.forms module accounting.forms module
----------------------- -----------------------

View File

@ -0,0 +1,29 @@
accounting.unmatched\_offset package
====================================
Submodules
----------
accounting.unmatched\_offset.queries module
-------------------------------------------
.. automodule:: accounting.unmatched_offset.queries
:members:
:undoc-members:
:show-inheritance:
accounting.unmatched\_offset.views module
-----------------------------------------
.. automodule:: accounting.unmatched_offset.views
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.unmatched_offset
:members:
:undoc-members:
:show-inheritance:

View File

@ -44,6 +44,22 @@ accounting.utils.next\_uri module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.utils.offset\_alias module
-------------------------------------
.. automodule:: accounting.utils.offset_alias
:members:
:undoc-members:
:show-inheritance:
accounting.utils.offset\_matcher module
---------------------------------------
.. automodule:: accounting.utils.offset_matcher
:members:
:undoc-members:
:show-inheritance:
accounting.utils.options module accounting.utils.options module
------------------------------- -------------------------------
@ -92,6 +108,14 @@ accounting.utils.strip\_text module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.utils.unapplied module
---------------------------------
.. automodule:: accounting.utils.unapplied
:members:
:undoc-members:
:show-inheritance:
accounting.utils.user module accounting.utils.user module
---------------------------- ----------------------------

View File

@ -10,10 +10,10 @@ sys.path.insert(0, os.path.abspath('../../src/'))
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = 'Mia! Accounting Flask' project = 'Mia! Accounting'
copyright = '2023, imacat' copyright = '2023, imacat'
author = 'imacat' author = 'imacat'
release = '0.9.1' release = '1.3.2'
# -- 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

61
docs/source/examples.rst Normal file
View File

@ -0,0 +1,61 @@
Examples
========
.. _example-userutils:
An Example Configuration
------------------------
The following is an example configuration for *Mia! Accounting*.
::
from flask import Response, redirect
from .auth import current_user()
from .modules import User
def create_app(test_config=None) -> Flask:
app: Flask = Flask(__name__)
... (Configuration of SQLAlchemy, CSRF, Babel_JS, ... etc) ...
import accounting
class UserUtils(accounting.UserUtilityInterface[User]):
def can_view(self) -> bool:
return True
def can_edit(self) -> bool:
return "editor" in current_user().roles
def can_admin(self) -> bool:
return current_user().is_admin
def unauthorized(self) -> Response:
return redirect("/login")
@property
def cls(self) -> t.Type[User]:
return User
@property
def pk_column(self) -> Column:
return User.id
@property
def current_user(self) -> User | None:
return current_user()
def get_by_username(self, username: str) -> User | None:
return User.query.filter(User.username == username).first()
def get_pk(self, user: User) -> int:
return user.id
accounting.init_app(app, UserUtils())
... (Any other configuration) ...
return app

57
docs/source/history.rst Normal file
View File

@ -0,0 +1,57 @@
History
=======
I created my own private accounting application in Perl_/mod_perl_ in
2007, as part of my personal website. The first revision was made
using Perl/Mojolicious_ in 2019, with the aim of making it
mobile-friendly using Bootstrap_, and with modern back-end and
front-end technologies such as jQuery.
The second revision was done in Python_/Django_ in 2020, as I was
looking to change my career from PHP_/Laravel_ to Python, but lacked
experience with large Python projects. I needed something in my
portfolio and decided to work on the somewhat outdated Mojolicious
project.
Despite having no prior experience with Django, I spent two months
working late nights to create the `Mia! Accounting Django`_
application. It took me another 1.5 months to make it an independent
module, which I later released as an open source project on PyPI.
The application worked nicely for my household bookkeeping for two
years. However, new demands arose over time, especially with tracking
payables and receivables. This was critical `during the pandemic`_ as
more payments were made online with credit cards.
The biggest issue I encountered was with
`Django's MTV architectural pattern`_. Django takes over the control
flow. I had to override several parts of the `class-based views`_ for
different but yet simple control flow logic. In the end, it became
very difficult to track whether things went wrong because I overrode
something or because it just wouldn't work with the basic assumption
of the class-based views. By the time I realized it, it was too late
for me to drop Django's MTV and rewrite everything from class-based
views to function-based views.
Therefore, I decided to turn to microframeworks_ like Flask_. After
working with modularized Flask and FastAPI_ applications for two
years, I returned to the project and wrote its third revision using
Flask in 2023.
.. _Perl: https://www.perl.org
.. _mod_perl: https://perl.apache.org
.. _Mojolicious: https://mojolicious.org
.. _Bootstrap: https://getbootstrap.com
.. _jQuery: https://jquery.com
.. _Python: https://www.python.org
.. _Django: https://www.djangoproject.com
.. _PHP: https://www.php.net
.. _Laravel: https://laravel.com
.. _Mia! Accounting Django: https://github.com/imacat/mia-accounting-django
.. _during the pandemic: https://en.wikipedia.org/wiki/COVID-19_pandemic
.. _FastAPI: https://fastapi.tiangolo.com
.. _Django's MTV architectural pattern: https://docs.djangoproject.com/en/dev/faq/general/#django-appears-to-be-a-mvc-framework-but-you-call-the-controller-the-view-and-the-view-the-template-how-come-you-don-t-use-the-standard-names
.. _class-based views: https://docs.djangoproject.com/en/4.2/topics/class-based-views/
.. _microframeworks: https://en.wikipedia.org/wiki/Microframework
.. _Flask: https://flask.palletsprojects.com

View File

@ -1,15 +1,20 @@
.. Mia! Accounting Flask documentation master file, created by .. Mia! Accounting documentation master file, created by
sphinx-quickstart on Fri Jan 27 12:20:04 2023. sphinx-quickstart on Fri Jan 27 12:20:04 2023.
You can adapt this file completely to your liking, but it should at least You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive. contain the root `toctree` directive.
Welcome to Mia! Accounting Flask's documentation! Welcome to Mia! Accounting's documentation!
================================================= ===========================================
.. toctree:: .. toctree::
:maxdepth: 2 :maxdepth: 2
:caption: Contents: :caption: Contents:
intro
accounting
examples
history
Indices and tables Indices and tables

126
docs/source/intro.rst Normal file
View File

@ -0,0 +1,126 @@
Introduction
============
*Mia! Accounting* is an accounting module for Flask_ applications.
It is designed both for mobile and desktop environments. It
implements `double-entry bookkeeping`_. It generates the following
accounting reports:
* Trial balance
* Income statement
* Balance sheet
In addition, *Mia! Accounting* tracks offsets for unpaid payables and
receivables.
Live Demonstration and Test Site
--------------------------------
There is a `live demonstration`_ for *Mia! Accounting*. It runs the
same code as the `test site`_ in the `source distribution`_. It is
the simplest website that works with *Mia! Accounting*. It is also
used in the automatic tests.
If you do not have a running Flask application or do not know how to
start one, you may start with the test site.
Installation
------------
Install *Mia! Accounting* with ``pip``:
::
pip install mia-accounting
You may also download from the `PyPI project page`_ or the
`release page`_ on the `Git repository`_.
Prerequisites
-------------
You need a running Flask application with database user login.
The primary key of the user data model must be integer. You also
need at least one user.
The following front-end JavaScript libraries must be loaded. You may
download it locally or use CDN_.
* Bootstrap_ 5.2.3 or above
* FontAwesome_ 6.2.1 or above
* `Decimal.js`_ 6.4.3 or above
* `Tempus-Dominus`_ 6.4.3 or above
Configuration
-------------
You need to pass the Flask *app* and an implementation of
:py:class:`accounting.utils.user.UserUtilityInterface` to the
:py:func:`accounting.init_app` function. ``UserUtilityInterface``
contains everything *Mia! Accounting* needs.
See an example in :ref:`example-userutils`.
Database Initialization
-----------------------
After the configuration, run the ``accounting-init-db`` console
command to initialize the accounting database. You need to specify
the username of a user as the data creator.
::
% flask --app myapp accounting-init-db -u username
Navigation Menu
---------------
Include the navigation menu in the `Bootstrap navigation bar`_ in your
base template:
::
<nav class="navbar navbar-expand-lg bg-body-tertiary bg-dark navbar-dark">
<div class="container-fluid">
...
<div id="collapsible-navbar" class="collapse navbar-collapse">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
...
{% include "accounting/include/nav.html" %}
...
</ul>
...
</div>
</div>
</nav>
Check your Flask application and see how it works.
Documentation
-------------
Refer to the `documentation on Read the Docs`_.
.. _Flask: https://flask.palletsprojects.com
.. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping
.. _live demonstration: https://accounting.imacat.idv.tw
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
.. _source distribution: https://pypi.org/project/mia-accounting/#files
.. _PyPI project page: https://pypi.org/project/mia-accounting
.. _release page: https://github.com/imacat/mia-accounting/releases
.. _Git repository: https://github.com/imacat/mia-accounting
.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network
.. _Bootstrap: https://getbootstrap.com
.. _FontAwesome: https://fontawesome.com
.. _Decimal.js: https://mikemcl.github.io/decimal.js
.. _Tempus-Dominus: https://getdatepicker.com
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io

View File

@ -1,7 +1,7 @@
# The Mia! Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21 # Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
# Copyright (c) 2022 imacat. # Copyright (c) 2022-2023 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -15,6 +15,51 @@
# 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.
[project]
name = "mia-accounting"
version = "1.3.2"
description = "A Flask accounting module."
readme = "README.rst"
requires-python = ">=3.11"
authors = [
{name = "imacat", email = "imacat@mail.imacat.idv.tw"},
]
keywords = ["mia", "accounting", "flask"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Framework :: Flask",
"Topic :: Office/Business :: Financial :: Accounting",
]
dependencies = [
"flask",
"Flask-SQLAlchemy",
"Flask-WTF",
"Flask-Babel >= 3",
"Flask-Babel-JS",
]
[project.optional-dependencies]
test = [
"unittest",
"httpx",
"OpenCC",
]
[project.urls]
"Documentation" = "https://mia-accounting.readthedocs.io"
"Repository" = "https://github.com/imacat/mia-accounting"
"Bug Tracker" = "https://github.com/imacat/mia-accounting/issues"
"Demonstration" = "https://accounting.imacat.idv.tw"
[build-system] [build-system]
requires = ["setuptools>=42"] requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.setuptools.exclude-package-data]
"*" = [
"babel.cfg",
"*.pot",
"*.po",
]

View File

@ -1,56 +0,0 @@
# The Mia! Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
# Copyright (c) 2022-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.
[metadata]
name = mia-accounting-flask
version = 0.9.1
author = imacat
author_email = imacat@mail.imacat.idv.tw
description = The Mia! Accounting Flask project.
long_description = file: README.rst
long_description_content_type = text/x-rst
url = https://github.com/imacat/mia-accounting-flask
project_urls =
Bug Tracker = https://github.com/imacat/mia-accounting-flask/issues
classifiers =
Programming Language :: Python :: 3
License :: OSI Approved :: Apache Software License
Operating System :: OS Independent
Framework :: Flask
Topic :: Office/Business :: Financial :: Accounting
[options]
package_dir =
= src
python_requires = >=3.11
install_requires =
flask
Flask-SQLAlchemy
Flask-WTF
Flask-Babel >= 3
Flask-Babel-JS
tests_require =
unittest
httpx
OpenCC
[options.package_data]
accounting =
static/**
templates/**
translations/*/LC_MESSAGES/*.mo
data/**

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -47,7 +47,6 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
init_user_utils(user_utils) init_user_utils(user_utils)
bp: Blueprint = Blueprint("accounting", __name__, bp: Blueprint = Blueprint("accounting", __name__,
url_prefix=url_prefix,
template_folder="templates", template_folder="templates",
static_folder="static") static_folder="static")
@ -62,6 +61,9 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
bp.add_app_template_global(default_currency_code, bp.add_app_template_global(default_currency_code,
"accounting_default_currency_code") "accounting_default_currency_code")
from .commands import init_db_command
app.cli.add_command(init_db_command)
from . import locale from . import locale
locale.init_app(app, bp) locale.init_app(app, bp)
@ -84,9 +86,12 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
journal_entry.init_app(app, bp) journal_entry.init_app(app, bp)
from . import report from . import report
report.init_app(app, bp) report.init_app(app, url_prefix)
from . import option from . import option
option.init_app(bp) option.init_app(bp)
app.register_blueprint(bp) from . import unmatched_offset
unmatched_offset.init_app(bp)
app.register_blueprint(bp, url_prefix=url_prefix)

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -19,6 +19,8 @@
""" """
from flask import Flask, Blueprint from flask import Flask, Blueprint
from .commands import init_accounts_command
def init_app(app: Flask, bp: Blueprint) -> None: def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application. """Initialize the application.
@ -32,6 +34,3 @@ def init_app(app: Flask, bp: Blueprint) -> None:
from .views import bp as account_bp from .views import bp as account_bp
bp.register_blueprint(account_bp, url_prefix="/accounts") bp.register_blueprint(account_bp, url_prefix="/accounts")
from .commands import init_accounts_command
app.cli.add_command(init_accounts_command)

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -17,44 +17,21 @@
"""The console commands for the account management. """The console commands for the account management.
""" """
import os import typing as t
from secrets import randbelow from secrets import randbelow
import click import click
from flask.cli import with_appcontext
from accounting import db from accounting import db
from accounting.models import BaseAccount, Account, AccountL10n from accounting.models import BaseAccount, Account, AccountL10n
from accounting.utils.user import has_user, get_user_pk from accounting.utils.user import get_user_pk
import sqlalchemy as sa
AccountData = tuple[int, str, int, str, str, str, bool] AccountData = tuple[int, str, int, str, str, str, bool]
"""The format of the account data, as a list of (ID, base account code, number, """The format of the account data, as a list of (ID, base account code, number,
English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples.""" English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples."""
def __validate_username(ctx: click.core.Context, param: click.core.Option,
value: str) -> str:
"""Validates the username for the click console command.
:param ctx: The console command context.
:param param: The console command option.
:param value: The username.
:raise click.BadParameter: When validation fails.
:return: The username.
"""
value = value.strip()
if value == "":
raise click.BadParameter("Username empty.")
if not has_user(value):
raise click.BadParameter(f"User {value} does not exist.")
return value
@click.command("accounting-init-accounts")
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@with_appcontext
def init_accounts_command(username: str) -> None: def init_accounts_command(username: str) -> None:
"""Initializes the accounts.""" """Initializes the accounts."""
creator_pk: int = get_user_pk(username) creator_pk: int = get_user_pk(username)
@ -63,8 +40,6 @@ def init_accounts_command(username: str) -> None:
.filter(db.func.length(BaseAccount.code) == 4)\ .filter(db.func.length(BaseAccount.code) == 4)\
.order_by(BaseAccount.code).all() .order_by(BaseAccount.code).all()
if len(bases) == 0: if len(bases) == 0:
click.echo("Please initialize the base accounts with "
"\"flask accounting-init-base\" first.")
raise click.Abort raise click.Abort
existing: list[Account] = Account.query.all() existing: list[Account] = Account.query.all()
@ -73,7 +48,6 @@ def init_accounts_command(username: str) -> None:
bases_to_add: list[BaseAccount] = [x for x in bases bases_to_add: list[BaseAccount] = [x for x in bases
if x.code not in existing_base_code] if x.code not in existing_base_code]
if len(bases_to_add) == 0: if len(bases_to_add) == 0:
click.echo("No more account to import.")
return return
existing_id: set[int] = {x.id for x in existing} existing_id: set[int] = {x.id for x in existing}
@ -89,14 +63,24 @@ def init_accounts_command(username: str) -> None:
existing_id.add(new_id) existing_id.add(new_id)
return new_id return new_id
data: list[AccountData] = [] data: list[dict[str, t.Any]] = []
l10n_data: list[dict[str, t.Any]] = []
for base in bases_to_add: for base in bases_to_add:
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n} l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
is_need_offset: bool = __is_need_offset(base.code) account_id: int = get_new_id()
data.append((get_new_id(), base.code, 1, base.title_l10n, data.append({"id": account_id,
l10n["zh_Hant"], l10n["zh_Hans"], is_need_offset)) "base_code": base.code,
__add_accounting_accounts(data, creator_pk) "no": 1,
click.echo(F"{len(data)} added. Accounting accounts initialized.") "title_l10n": base.title_l10n,
"is_need_offset": __is_need_offset(base.code),
"created_by_id": creator_pk,
"updated_by_id": creator_pk})
for locale in {"zh_Hant", "zh_Hans"}:
l10n_data.append({"account_id": account_id,
"locale": locale,
"title": l10n[locale]})
db.session.execute(sa.insert(Account), data)
db.session.execute(sa.insert(AccountL10n), l10n_data)
def __is_need_offset(base_code: str) -> bool: def __is_need_offset(base_code: str) -> bool:
@ -108,42 +92,16 @@ def __is_need_offset(base_code: str) -> bool:
""" """
# Assets # Assets
if base_code[0] == "1": if base_code[0] == "1":
if base_code[:3] in {"113", "114", "118", "184"}: if base_code[:3] in {"113", "114", "118", "184", "186"}:
return True return True
if base_code in {"1411", "1421", "1431", "1441", "1511", "1521", if base_code in {"1286", "1411", "1421", "1431", "1441", "1511",
"1581", "1611", "1851", ""}: "1521", "1581", "1611", "1851"}:
return True return True
return False return False
# Liabilities # Liabilities
if base_code[0] == "2": if base_code[0] == "2":
if base_code in {"2111", "2114", "2284", "2293"}: if base_code in {"2111", "2114", "2284", "2293", "2861"}:
return False return False
return True return True
# Only assets and liabilities need offset # Only assets and liabilities need offset
return False return False
def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
-> None:
"""Adds the accounts.
:param data: A list of (base code, number, title) tuples.
:param creator_pk: The primary key of the creator.
:return: None.
"""
accounts: list[Account] = [Account(id=x[0],
base_code=x[1],
no=x[2],
title_l10n=x[3],
is_need_offset=x[6],
created_by_id=creator_pk,
updated_by_id=creator_pk)
for x in data]
l10n: list[AccountL10n] = [AccountL10n(account_id=x[0],
locale=y[0],
title=y[1])
for x in data
for y in (("zh_Hant", x[4]), ("zh_Hans", x[5]))]
db.session.bulk_save_objects(accounts)
db.session.bulk_save_objects(l10n)
db.session.commit()

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/31 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/31
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -53,7 +53,7 @@ def list_accounts() -> str:
list=pagination.list, pagination=pagination) list=pagination.list, pagination=pagination)
@bp.get("/create", endpoint="create") @bp.get("create", endpoint="create")
@has_permission(can_edit) @has_permission(can_edit)
def show_add_account_form() -> str: def show_add_account_form() -> str:
"""Shows the form to add an account. """Shows the form to add an account.
@ -70,7 +70,7 @@ def show_add_account_form() -> str:
form=form) form=form)
@bp.post("/store", endpoint="store") @bp.post("store", endpoint="store")
@has_permission(can_edit) @has_permission(can_edit)
def add_account() -> redirect: def add_account() -> redirect:
"""Adds an account. """Adds an account.
@ -91,7 +91,7 @@ def add_account() -> redirect:
return redirect(inherit_next(__get_detail_uri(account))) return redirect(inherit_next(__get_detail_uri(account)))
@bp.get("/<account:account>", endpoint="detail") @bp.get("<account:account>", endpoint="detail")
@has_permission(can_view) @has_permission(can_view)
def show_account_detail(account: Account) -> str: def show_account_detail(account: Account) -> str:
"""Shows the account detail. """Shows the account detail.
@ -102,7 +102,7 @@ def show_account_detail(account: Account) -> str:
return render_template("accounting/account/detail.html", obj=account) return render_template("accounting/account/detail.html", obj=account)
@bp.get("/<account:account>/edit", endpoint="edit") @bp.get("<account:account>/edit", endpoint="edit")
@has_permission(can_edit) @has_permission(can_edit)
def show_account_edit_form(account: Account) -> str: def show_account_edit_form(account: Account) -> str:
"""Shows the form to edit an account. """Shows the form to edit an account.
@ -121,7 +121,7 @@ def show_account_edit_form(account: Account) -> str:
account=account, form=form) account=account, form=form)
@bp.post("/<account:account>/update", endpoint="update") @bp.post("<account:account>/update", endpoint="update")
@has_permission(can_edit) @has_permission(can_edit)
def update_account(account: Account) -> redirect: def update_account(account: Account) -> redirect:
"""Updates an account. """Updates an account.
@ -148,7 +148,7 @@ def update_account(account: Account) -> redirect:
return redirect(inherit_next(__get_detail_uri(account))) return redirect(inherit_next(__get_detail_uri(account)))
@bp.post("/<account:account>/delete", endpoint="delete") @bp.post("<account:account>/delete", endpoint="delete")
@has_permission(can_edit) @has_permission(can_edit)
def delete_account(account: Account) -> redirect: def delete_account(account: Account) -> redirect:
"""Deletes an account. """Deletes an account.
@ -167,7 +167,7 @@ def delete_account(account: Account) -> redirect:
return redirect(or_next(__get_list_uri())) return redirect(or_next(__get_list_uri()))
@bp.get("/bases/<baseAccount:base>", endpoint="order") @bp.get("bases/<baseAccount:base>", endpoint="order")
@has_permission(can_view) @has_permission(can_view)
def show_account_order(base: BaseAccount) -> str: def show_account_order(base: BaseAccount) -> str:
"""Shows the order of the accounts under a same base account. """Shows the order of the accounts under a same base account.
@ -178,7 +178,7 @@ def show_account_order(base: BaseAccount) -> str:
return render_template("accounting/account/order.html", base=base) return render_template("accounting/account/order.html", base=base)
@bp.post("/bases/<baseAccount:base>", endpoint="sort") @bp.post("bases/<baseAccount:base>", endpoint="sort")
@has_permission(can_edit) @has_permission(can_edit)
def sort_accounts(base: BaseAccount) -> redirect: def sort_accounts(base: BaseAccount) -> redirect:
"""Reorders the accounts under a base account. """Reorders the accounts under a base account.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -19,6 +19,8 @@
""" """
from flask import Flask, Blueprint from flask import Flask, Blueprint
from .commands import init_base_accounts_command
def init_app(app: Flask, bp: Blueprint) -> None: def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application. """Initialize the application.
@ -32,6 +34,3 @@ def init_app(app: Flask, bp: Blueprint) -> None:
from .views import bp as base_account_bp from .views import bp as base_account_bp
bp.register_blueprint(base_account_bp, url_prefix="/base-accounts") bp.register_blueprint(base_account_bp, url_prefix="/base-accounts")
from .commands import init_base_accounts_command
app.cli.add_command(init_base_accounts_command)

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -19,21 +19,17 @@
""" """
import csv import csv
import click import sqlalchemy as sa
from flask.cli import with_appcontext
from accounting import data_dir from accounting import data_dir
from accounting import db from accounting import db
from accounting.models import BaseAccount, BaseAccountL10n from accounting.models import BaseAccount, BaseAccountL10n
@click.command("accounting-init-base")
@with_appcontext
def init_base_accounts_command() -> None: def init_base_accounts_command() -> None:
"""Initializes the base accounts.""" """Initializes the base accounts."""
if BaseAccount.query.first() is not None: if BaseAccount.query.first() is not None:
click.echo("Base accounts already exist.") return
raise click.Abort
with open(data_dir / "base_accounts.csv") as fp: with open(data_dir / "base_accounts.csv") as fp:
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)] data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
@ -45,7 +41,5 @@ def init_base_accounts_command() -> None:
"locale": y, "locale": y,
"title": x[f"l10n-{y}"]} "title": x[f"l10n-{y}"]}
for x in data for y in locales] for x in data for y in locales]
db.session.bulk_insert_mappings(BaseAccount, account_data) db.session.execute(sa.insert(BaseAccount), account_data)
db.session.bulk_insert_mappings(BaseAccountL10n, l10n_data) db.session.execute(sa.insert(BaseAccountL10n), l10n_data)
db.session.commit()
click.echo("Base accounts initialized.")

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -22,6 +22,7 @@ from flask import Blueprint, render_template
from accounting.models import BaseAccount from accounting.models import BaseAccount
from accounting.utils.pagination import Pagination from accounting.utils.pagination import Pagination
from accounting.utils.permission import has_permission, can_view from accounting.utils.permission import has_permission, can_view
from .queries import get_base_account_query
bp: Blueprint = Blueprint("base-account", __name__) bp: Blueprint = Blueprint("base-account", __name__)
"""The view blueprint for the base account management.""" """The view blueprint for the base account management."""
@ -34,14 +35,13 @@ def list_accounts() -> str:
:return: The account list. :return: The account list.
""" """
from .queries import get_base_account_query
accounts: list[BaseAccount] = get_base_account_query() accounts: list[BaseAccount] = get_base_account_query()
pagination: Pagination = Pagination[BaseAccount](accounts) pagination: Pagination = Pagination[BaseAccount](accounts)
return render_template("accounting/base-account/list.html", return render_template("accounting/base-account/list.html",
list=pagination.list, pagination=pagination) list=pagination.list, pagination=pagination)
@bp.get("/<baseAccount:account>", endpoint="detail") @bp.get("<baseAccount:account>", endpoint="detail")
@has_permission(can_view) @has_permission(can_view)
def show_account_detail(account: BaseAccount) -> str: def show_account_detail(account: BaseAccount) -> str:
"""Shows the account detail. """Shows the account detail.

View File

@ -0,0 +1,62 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/10
# 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 console commands.
"""
import os
import click
from flask.cli import with_appcontext
from accounting import db
from accounting.account import init_accounts_command
from accounting.base_account import init_base_accounts_command
from accounting.currency import init_currencies_command
from accounting.utils.user import has_user
def __validate_username(ctx: click.core.Context, param: click.core.Option,
value: str) -> str:
"""Validates the username for the click console command.
:param ctx: The console command context.
:param param: The console command option.
:param value: The username.
:raise click.BadParameter: When validation fails.
:return: The username.
"""
value = value.strip()
if value == "":
raise click.BadParameter("Username empty.")
if not has_user(value):
raise click.BadParameter(f"User {value} does not exist.")
return value
@click.command("accounting-init-db")
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@with_appcontext
def init_db_command(username: str) -> None:
"""Initializes the accounting database."""
db.create_all()
init_base_accounts_command()
init_accounts_command(username)
init_currencies_command(username)
db.session.commit()
click.echo("Accounting database initialized.")

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -19,6 +19,8 @@
""" """
from flask import Flask, Blueprint from flask import Flask, Blueprint
from .commands import init_currencies_command
def init_app(app: Flask, bp: Blueprint) -> None: def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application. """Initialize the application.
@ -33,6 +35,3 @@ def init_app(app: Flask, bp: Blueprint) -> None:
from .views import bp as currency_bp, api_bp as currency_api_bp from .views import bp as currency_bp, api_bp as currency_api_bp
bp.register_blueprint(currency_bp, url_prefix="/currencies") bp.register_blueprint(currency_bp, url_prefix="/currencies")
bp.register_blueprint(currency_api_bp, url_prefix="/api/currencies") bp.register_blueprint(currency_api_bp, url_prefix="/api/currencies")
from .commands import init_currencies_command
app.cli.add_command(init_currencies_command)

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -18,42 +18,15 @@
""" """
import csv import csv
import os
import typing as t import typing as t
import click import sqlalchemy as sa
from flask.cli import with_appcontext
from accounting import db, data_dir from accounting import db, data_dir
from accounting.models import Currency, CurrencyL10n from accounting.models import Currency, CurrencyL10n
from accounting.utils.user import has_user, get_user_pk from accounting.utils.user import get_user_pk
CurrencyData = tuple[str, str, str, str]
def __validate_username(ctx: click.core.Context, param: click.core.Option,
value: str) -> str:
"""Validates the username for the click console command.
:param ctx: The console command context.
:param param: The console command option.
:param value: The username.
:raise click.BadParameter: When validation fails.
:return: The username.
"""
value = value.strip()
if value == "":
raise click.BadParameter("Username empty.")
if not has_user(value):
raise click.BadParameter(f"User {value} does not exist.")
return value
@click.command("accounting-init-currencies")
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@with_appcontext
def init_currencies_command(username: str) -> None: def init_currencies_command(username: str) -> None:
"""Initializes the currencies.""" """Initializes the currencies."""
existing_codes: set[str] = {x.code for x in Currency.query.all()} existing_codes: set[str] = {x.code for x in Currency.query.all()}
@ -63,7 +36,6 @@ def init_currencies_command(username: str) -> None:
to_add: list[dict[str, str]] = [x for x in data to_add: list[dict[str, str]] = [x for x in data
if x["code"] not in existing_codes] if x["code"] not in existing_codes]
if len(to_add) == 0: if len(to_add) == 0:
click.echo("No more currency to add.")
return return
creator_pk: int = get_user_pk(username) creator_pk: int = get_user_pk(username)
@ -77,8 +49,5 @@ def init_currencies_command(username: str) -> None:
"locale": y, "locale": y,
"name": x[f"l10n-{y}"]} "name": x[f"l10n-{y}"]}
for x in to_add for y in locales] for x in to_add for y in locales]
db.session.bulk_insert_mappings(Currency, currency_data) db.session.execute(sa.insert(Currency), currency_data)
db.session.bulk_insert_mappings(CurrencyL10n, l10n_data) db.session.execute(sa.insert(CurrencyL10n), l10n_data)
db.session.commit()
click.echo(F"{len(to_add)} added. Currencies initialized.")

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -34,6 +34,7 @@ from accounting.utils.pagination import Pagination
from accounting.utils.permission import has_permission, can_view, can_edit from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.user import get_current_user_pk from accounting.utils.user import get_current_user_pk
from .forms import CurrencyForm from .forms import CurrencyForm
from .queries import get_currency_query
bp: Blueprint = Blueprint("currency", __name__) bp: Blueprint = Blueprint("currency", __name__)
"""The view blueprint for the currency management.""" """The view blueprint for the currency management."""
@ -48,14 +49,13 @@ def list_currencies() -> str:
:return: The currency list. :return: The currency list.
""" """
from .queries import get_currency_query
currencies: list[Currency] = get_currency_query() currencies: list[Currency] = get_currency_query()
pagination: Pagination = Pagination[Currency](currencies) pagination: Pagination = Pagination[Currency](currencies)
return render_template("accounting/currency/list.html", return render_template("accounting/currency/list.html",
list=pagination.list, pagination=pagination) list=pagination.list, pagination=pagination)
@bp.get("/create", endpoint="create") @bp.get("create", endpoint="create")
@has_permission(can_edit) @has_permission(can_edit)
def show_add_currency_form() -> str: def show_add_currency_form() -> str:
"""Shows the form to add a currency. """Shows the form to add a currency.
@ -72,7 +72,7 @@ def show_add_currency_form() -> str:
form=form) form=form)
@bp.post("/store", endpoint="store") @bp.post("store", endpoint="store")
@has_permission(can_edit) @has_permission(can_edit)
def add_currency() -> redirect: def add_currency() -> redirect:
"""Adds a currency. """Adds a currency.
@ -93,7 +93,7 @@ def add_currency() -> redirect:
return redirect(inherit_next(__get_detail_uri(currency))) return redirect(inherit_next(__get_detail_uri(currency)))
@bp.get("/<currency:currency>", endpoint="detail") @bp.get("<currency:currency>", endpoint="detail")
@has_permission(can_view) @has_permission(can_view)
def show_currency_detail(currency: Currency) -> str: def show_currency_detail(currency: Currency) -> str:
"""Shows the currency detail. """Shows the currency detail.
@ -104,7 +104,7 @@ def show_currency_detail(currency: Currency) -> str:
return render_template("accounting/currency/detail.html", obj=currency) return render_template("accounting/currency/detail.html", obj=currency)
@bp.get("/<currency:currency>/edit", endpoint="edit") @bp.get("<currency:currency>/edit", endpoint="edit")
@has_permission(can_edit) @has_permission(can_edit)
def show_currency_edit_form(currency: Currency) -> str: def show_currency_edit_form(currency: Currency) -> str:
"""Shows the form to edit a currency. """Shows the form to edit a currency.
@ -123,7 +123,7 @@ def show_currency_edit_form(currency: Currency) -> str:
currency=currency, form=form) currency=currency, form=form)
@bp.post("/<currency:currency>/update", endpoint="update") @bp.post("<currency:currency>/update", endpoint="update")
@has_permission(can_edit) @has_permission(can_edit)
def update_currency(currency: Currency) -> redirect: def update_currency(currency: Currency) -> redirect:
"""Updates a currency. """Updates a currency.
@ -151,7 +151,7 @@ def update_currency(currency: Currency) -> redirect:
return redirect(inherit_next(__get_detail_uri(currency))) return redirect(inherit_next(__get_detail_uri(currency)))
@bp.post("/<currency:currency>/delete", endpoint="delete") @bp.post("<currency:currency>/delete", endpoint="delete")
@has_permission(can_edit) @has_permission(can_edit)
def delete_currency(currency: Currency) -> redirect: def delete_currency(currency: Currency) -> redirect:
"""Deletes a currency. """Deletes a currency.
@ -169,7 +169,7 @@ def delete_currency(currency: Currency) -> redirect:
return redirect(or_next(url_for("accounting.currency.list"))) return redirect(or_next(url_for("accounting.currency.list")))
@api_bp.get("/exists-code", endpoint="exists") @api_bp.get("exists-code", endpoint="exists")
@has_permission(can_edit) @has_permission(can_edit)
def exists_code() -> dict[str, bool]: def exists_code() -> dict[str, bool]:
"""Validates whether a currency code exists. """Validates whether a currency code exists.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -20,10 +20,10 @@
from datetime import date from datetime import date
from flask import abort from flask import abort
from sqlalchemy.orm import selectinload
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from accounting.models import JournalEntry, JournalEntryLineItem from accounting import db
from accounting.models import JournalEntry
from accounting.utils.journal_entry_types import JournalEntryType from accounting.utils.journal_entry_types import JournalEntryType
@ -37,13 +37,7 @@ class JournalEntryConverter(BaseConverter):
:param value: The journal entry ID. :param value: The journal entry ID.
:return: The corresponding journal entry. :return: The corresponding journal entry.
""" """
journal_entry: JournalEntry | None = JournalEntry.query\ journal_entry: JournalEntry | None = db.session.get(JournalEntry, value)
.join(JournalEntryLineItem)\
.filter(JournalEntry.id == value)\
.options(selectinload(JournalEntry.line_items)
.selectinload(JournalEntryLineItem.offsets)
.selectinload(JournalEntryLineItem.journal_entry))\
.first()
if journal_entry is None: if journal_entry is None:
abort(404) abort(404)
return journal_entry return journal_entry

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -28,14 +28,13 @@ from wtforms.validators import DataRequired
from accounting import db from accounting import db
from accounting.forms import CurrencyExists from accounting.forms import CurrencyExists
from accounting.journal_entry.utils.offset_alias import offset_alias
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import JournalEntryLineItem from accounting.models import JournalEntryLineItem
from accounting.utils.cast import be from accounting.utils.cast import be
from accounting.utils.offset_alias import offset_alias
from accounting.utils.strip_text import strip_text from accounting.utils.strip_text import strip_text
from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
CURRENCY_REQUIRED: DataRequired = DataRequired( CURRENCY_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the currency.")) lazy_gettext("Please select the currency."))
"""The validator to check if the currency code is empty.""" """The validator to check if the currency code is empty."""

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -31,7 +31,7 @@ from accounting import db
from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \ from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
IsCreditAccount IsCreditAccount
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import Account, JournalEntryLineItem from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.template_filters import format_amount from accounting.template_filters import format_amount
from accounting.utils.cast import be from accounting.utils.cast import be
from accounting.utils.random_id import new_id from accounting.utils.random_id import new_id
@ -127,10 +127,8 @@ class KeepAccountWhenHavingOffset:
assert isinstance(form, LineItemForm) assert isinstance(form, LineItemForm)
if field.data is None or form.id.data is None: if field.data is None or form.id.data is None:
return return
line_item: JournalEntryLineItem | None = db.session\ line_item: JournalEntryLineItem | None \
.query(JournalEntryLineItem)\ = db.session.get(JournalEntryLineItem, form.id.data)
.filter(JournalEntryLineItem.id == form.id.data)\
.options(selectinload(JournalEntryLineItem.offsets)).first()
if line_item is None or len(line_item.offsets) == 0: if line_item is None or len(line_item.offsets) == 0:
return return
if field.data != line_item.account_code: if field.data != line_item.account_code:
@ -344,14 +342,13 @@ class LineItemForm(FlaskForm):
def get_offsets() -> list[JournalEntryLineItem]: def get_offsets() -> list[JournalEntryLineItem]:
if not self.is_need_offset or self.id.data is None: if not self.is_need_offset or self.id.data is None:
return [] return []
return JournalEntryLineItem.query\ return JournalEntryLineItem.query.join(JournalEntry)\
.filter(JournalEntryLineItem.original_line_item_id .filter(JournalEntryLineItem.original_line_item_id
== self.id.data)\ == self.id.data)\
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.no)\
.options(selectinload(JournalEntryLineItem.journal_entry), .options(selectinload(JournalEntryLineItem.journal_entry),
selectinload(JournalEntryLineItem.account), selectinload(JournalEntryLineItem.account)).all()
selectinload(JournalEntryLineItem.offsets)
.selectinload(
JournalEntryLineItem.journal_entry)).all()
setattr(self, "__offsets", get_offsets()) setattr(self, "__offsets", get_offsets())
return getattr(self, "__offsets") return getattr(self, "__offsets")

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/25 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/25
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -36,12 +36,14 @@ class DescriptionAccount:
:param account: The account. :param account: The account.
:param freq: The frequency of the tag with the account. :param freq: The frequency of the tag with the account.
""" """
self.account: Account = account self.__account: Account = account
"""The account.""" """The account."""
self.id: int = account.id self.id: int = account.id
"""The account ID.""" """The account ID."""
self.code: str = account.code self.code: str = account.code
"""The account code.""" """The account code."""
self.is_need_offset: bool = account.is_need_offset
"""Whether the journal entry line items of this account need offset."""
self.freq: int = freq self.freq: int = freq
"""The frequency of the tag with the account.""" """The frequency of the tag with the account."""
@ -50,7 +52,7 @@ class DescriptionAccount:
:return: The string representation of the account. :return: The string representation of the account.
""" """
return str(self.account) return str(self.__account)
def add_freq(self, freq: int) -> None: def add_freq(self, freq: int) -> None:
"""Adds the frequency of an account. """Adds the frequency of an account.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -25,7 +25,7 @@ from sqlalchemy.orm import selectinload
from accounting import db from accounting import db
from accounting.models import Account, JournalEntry, JournalEntryLineItem from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.utils.cast import be from accounting.utils.cast import be
from .offset_alias import offset_alias from accounting.utils.offset_alias import offset_alias
def get_selectable_original_line_items( def get_selectable_original_line_items(
@ -72,11 +72,12 @@ def get_selectable_original_line_items(
line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query\ line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query\
.filter(JournalEntryLineItem.id.in_({x for x in net_balances}))\ .filter(JournalEntryLineItem.id.in_({x for x in net_balances}))\
.join(JournalEntry)\ .join(JournalEntry)\
.order_by(JournalEntry.date, JournalEntryLineItem.is_debit, .order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.no)\ JournalEntryLineItem.is_debit, JournalEntryLineItem.no)\
.options(selectinload(JournalEntryLineItem.currency), .options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.account), selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.journal_entry)).all() selectinload(JournalEntryLineItem.journal_entry)).all()
line_items.reverse()
for line_item in line_items: for line_item in line_items:
line_item.net_balance = line_item.amount \ line_item.net_balance = line_item.amount \
if net_balances[line_item.id] is None \ if net_balances[line_item.id] is None \

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -49,7 +49,7 @@ bp.add_app_template_filter(format_amount_input,
bp.add_app_template_filter(text2html, "accounting_journal_entry_text2html") bp.add_app_template_filter(text2html, "accounting_journal_entry_text2html")
@bp.get("/create/<journalEntryType:journal_entry_type>", endpoint="create") @bp.get("create/<journalEntryType:journal_entry_type>", endpoint="create")
@has_permission(can_edit) @has_permission(can_edit)
def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str: def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
"""Shows the form to add a journal entry. """Shows the form to add a journal entry.
@ -71,7 +71,7 @@ def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
return journal_entry_op.render_create_template(form) return journal_entry_op.render_create_template(form)
@bp.post("/store/<journalEntryType:journal_entry_type>", endpoint="store") @bp.post("store/<journalEntryType:journal_entry_type>", endpoint="store")
@has_permission(can_edit) @has_permission(can_edit)
def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect: def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect:
"""Adds a journal entry. """Adds a journal entry.
@ -98,7 +98,7 @@ def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect:
return redirect(inherit_next(__get_detail_uri(journal_entry))) return redirect(inherit_next(__get_detail_uri(journal_entry)))
@bp.get("/<journalEntry:journal_entry>", endpoint="detail") @bp.get("<journalEntry:journal_entry>", endpoint="detail")
@has_permission(can_view) @has_permission(can_view)
def show_journal_entry_detail(journal_entry: JournalEntry) -> str: def show_journal_entry_detail(journal_entry: JournalEntry) -> str:
"""Shows the journal entry detail. """Shows the journal entry detail.
@ -111,7 +111,7 @@ def show_journal_entry_detail(journal_entry: JournalEntry) -> str:
return journal_entry_op.render_detail_template(journal_entry) return journal_entry_op.render_detail_template(journal_entry)
@bp.get("/<journalEntry:journal_entry>/edit", endpoint="edit") @bp.get("<journalEntry:journal_entry>/edit", endpoint="edit")
@has_permission(can_edit) @has_permission(can_edit)
def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str: def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
"""Shows the form to edit a journal entry. """Shows the form to edit a journal entry.
@ -133,7 +133,7 @@ def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
return journal_entry_op.render_edit_template(journal_entry, form) return journal_entry_op.render_edit_template(journal_entry, form)
@bp.post("/<journalEntry:journal_entry>/update", endpoint="update") @bp.post("<journalEntry:journal_entry>/update", endpoint="update")
@has_permission(can_edit) @has_permission(can_edit)
def update_journal_entry(journal_entry: JournalEntry) -> redirect: def update_journal_entry(journal_entry: JournalEntry) -> redirect:
"""Updates a journal entry. """Updates a journal entry.
@ -166,7 +166,7 @@ def update_journal_entry(journal_entry: JournalEntry) -> redirect:
return redirect(inherit_next(__get_detail_uri(journal_entry))) return redirect(inherit_next(__get_detail_uri(journal_entry)))
@bp.post("/<journalEntry:journal_entry>/delete", endpoint="delete") @bp.post("<journalEntry:journal_entry>/delete", endpoint="delete")
@has_permission(can_edit) @has_permission(can_edit)
def delete_journal_entry(journal_entry: JournalEntry) -> redirect: def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
"""Deletes a journal entry. """Deletes a journal entry.
@ -186,7 +186,7 @@ def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
return redirect(or_next(__get_default_page_uri())) return redirect(or_next(__get_default_page_uri()))
@bp.get("/dates/<date:journal_entry_date>", endpoint="order") @bp.get("dates/<date:journal_entry_date>", endpoint="order")
@has_permission(can_view) @has_permission(can_view)
def show_journal_entry_order(journal_entry_date: date) -> str: def show_journal_entry_order(journal_entry_date: date) -> str:
"""Shows the order of the journal entries in a same date. """Shows the order of the journal entries in a same date.
@ -201,7 +201,7 @@ def show_journal_entry_order(journal_entry_date: date) -> str:
date=journal_entry_date, list=journal_entries) date=journal_entry_date, list=journal_entries)
@bp.post("/dates/<date:journal_entry_date>", endpoint="sort") @bp.post("dates/<date:journal_entry_date>", endpoint="sort")
@has_permission(can_edit) @has_permission(can_edit)
def sort_journal_entries(journal_entry_date: date) -> redirect: def sort_journal_entries(journal_entry_date: date) -> redirect:
"""Reorders the journal entries in a date. """Reorders the journal entries in a date.
@ -235,4 +235,4 @@ def __get_default_page_uri() -> str:
:return: The URI for the default page. :return: The URI for the default page.
""" """
return url_for("accounting.report.default") return url_for("accounting-report.default")

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -21,7 +21,6 @@ from __future__ import annotations
import re import re
import typing as t import typing as t
from datetime import date
from decimal import Decimal from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
@ -215,6 +214,25 @@ class Account(db.Model):
""" """
return not self.is_real return not self.is_real
@property
def count(self) -> int:
"""Returns the number of items in the account.
:return: The number of items in the account.
"""
if not hasattr(self, "__count"):
setattr(self, "__count", 0)
return getattr(self, "__count")
@count.setter
def count(self, count: int) -> None:
"""Sets the number of items in the account.
:param count: The number of items in the account.
:return: None.
"""
setattr(self, "__count", count)
@property @property
def query_values(self) -> list[str]: def query_values(self) -> list[str]:
"""Returns the values to be queried. """Returns the values to be queried.
@ -661,12 +679,8 @@ class JournalEntryLineItem(db.Model):
nullable=True) nullable=True)
"""The ID of the original line item.""" """The ID of the original line item."""
original_line_item = db.relationship("JournalEntryLineItem", original_line_item = db.relationship("JournalEntryLineItem",
back_populates="offsets",
remote_side=id, passive_deletes=True) remote_side=id, passive_deletes=True)
"""The original line item.""" """The original line item."""
offsets = db.relationship("JournalEntryLineItem",
back_populates="original_line_item")
"""The offset items."""
currency_code = db.Column(db.String, currency_code = db.Column(db.String,
db.ForeignKey(Currency.code, onupdate="CASCADE"), db.ForeignKey(Currency.code, onupdate="CASCADE"),
nullable=False) nullable=False)
@ -708,14 +722,6 @@ class JournalEntryLineItem(db.Model):
""" """
return self.account.code return self.account.code
@property
def debit(self) -> Decimal | None:
"""Returns the debit amount.
:return: The debit amount, or None if this is not a debit line item.
"""
return self.amount if self.is_debit else None
@property @property
def is_need_offset(self) -> bool: def is_need_offset(self) -> bool:
"""Returns whether the line item needs offset. """Returns whether the line item needs offset.
@ -730,6 +736,14 @@ class JournalEntryLineItem(db.Model):
return False return False
return True return True
@property
def debit(self) -> Decimal | None:
"""Returns the debit amount.
:return: The debit amount, or None if this is not a debit line item.
"""
return self.amount if self.is_debit else None
@property @property
def credit(self) -> Decimal | None: def credit(self) -> Decimal | None:
"""Returns the credit amount. """Returns the credit amount.
@ -759,6 +773,40 @@ class JournalEntryLineItem(db.Model):
""" """
setattr(self, "__net_balance", net_balance) setattr(self, "__net_balance", net_balance)
@property
def offsets(self) -> list[t.Self]:
"""Returns the offset items.
:return: The offset items.
"""
if not hasattr(self, "__offsets"):
cls: t.Type[t.Self] = self.__class__
offsets: list[t.Self] = cls.query.join(JournalEntry)\
.filter(JournalEntryLineItem.original_line_item_id == self.id)\
.order_by(JournalEntry.date, JournalEntry.no,
cls.is_debit, cls.no).all()
setattr(self, "__offsets", offsets)
return getattr(self, "__offsets")
@property
def match(self) -> t.Self | None:
"""Returns the match of the line item.
:return: The match of the line item.
"""
if not hasattr(self, "__match"):
setattr(self, "__match", None)
return getattr(self, "__match")
@match.setter
def match(self, match: t.Self) -> None:
"""Sets the match of the line item.
:param match: The matcho of the line item.
:return: None.
"""
setattr(self, "__match", match)
@property @property
def query_values(self) -> list[str]: def query_values(self) -> list[str]:
"""Returns the values to be queried. """Returns the values to be queried.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -17,19 +17,21 @@
"""The report management. """The report management.
""" """
from flask import Flask, Blueprint from flask import Flask
def init_app(app: Flask, bp: Blueprint) -> None: def init_app(app: Flask, url_prefix: str) -> None:
"""Initialize the application. """Initialize the application.
:param app: The Flask application. :param app: The Flask application.
:param bp: The blueprint of the accounting application. :param url_prefix: The URL prefix of the accounting application.
:return: None. :return: None.
""" """
from .converters import PeriodConverter, IncomeExpensesAccountConverter from .converters import PeriodConverter, CurrentAccountConverter, \
NeedOffsetAccountConverter
app.url_map.converters["period"] = PeriodConverter app.url_map.converters["period"] = PeriodConverter
app.url_map.converters["ieAccount"] = IncomeExpensesAccountConverter app.url_map.converters["currentAccount"] = CurrentAccountConverter
app.url_map.converters["needOffsetAccount"] = NeedOffsetAccountConverter
from .views import bp as report_bp from .views import bp as report_bp
bp.register_blueprint(report_bp, url_prefix="/reports") app.register_blueprint(report_bp, url_prefix=url_prefix)

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -28,8 +28,8 @@ from .period import Period, get_period
class PeriodConverter(BaseConverter): class PeriodConverter(BaseConverter):
"""The supplier converter to convert the period specification from and to """The converter to convert the period specification from and to the
the corresponding period in the routes.""" corresponding period in the routes."""
def to_python(self, value: str) -> Period: def to_python(self, value: str) -> Period:
"""Converts a period specification to a period. """Converts a period specification to a period.
@ -51,9 +51,9 @@ class PeriodConverter(BaseConverter):
return value.spec return value.spec
class IncomeExpensesAccountConverter(BaseConverter): class CurrentAccountConverter(BaseConverter):
"""The supplier converter to convert the income and expenses log pseudo """The converter to convert the current account code from and to the
account code from and to the corresponding pseudo account in the routes.""" corresponding account in the routes."""
def to_python(self, value: str) -> CurrentAccount: def to_python(self, value: str) -> CurrentAccount:
"""Converts an account code to an account. """Converts an account code to an account.
@ -77,3 +77,29 @@ class IncomeExpensesAccountConverter(BaseConverter):
:return: Its code. :return: Its code.
""" """
return value.code return value.code
class NeedOffsetAccountConverter(BaseConverter):
"""The converter to convert the unapplied original line item account code
from and to the corresponding account in the routes."""
def to_python(self, value: str) -> Account:
"""Converts an account code to an account.
:param value: The account code.
:return: The corresponding account.
"""
account: Account | None = Account.find_by_code(value)
if account is None:
abort(404)
if not account.is_need_offset:
abort(404)
return account
def to_url(self, value: Account) -> str:
"""Converts an account to account code.
:param value: The account.
:return: Its code.
"""
return value.code

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -137,6 +137,7 @@ class AccountCollector:
.join(JournalEntry).join(Account)\ .join(JournalEntry).join(Account)\
.filter(*conditions)\ .filter(*conditions)\
.group_by(Account.id, Account.base_code, Account.no)\ .group_by(Account.id, Account.base_code, Account.no)\
.having(balance_func != 0)\
.order_by(Account.base_code, Account.no) .order_by(Account.base_code, Account.no)
account_balances: list[sa.Row] \ account_balances: list[sa.Row] \
= db.session.execute(select_balance).all() = db.session.execute(select_balance).all()

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -269,6 +269,7 @@ class IncomeStatement(BaseReport):
.join(JournalEntry).join(Account)\ .join(JournalEntry).join(Account)\
.filter(*conditions)\ .filter(*conditions)\
.group_by(Account.id)\ .group_by(Account.id)\
.having(balance_func != 0)\
.order_by(Account.base_code, Account.no) .order_by(Account.base_code, Account.no)
balances: list[sa.Row] = db.session.execute(select_balances).all() balances: list[sa.Row] = db.session.execute(select_balances).all()
accounts: dict[int, Account] \ accounts: dict[int, Account] \

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -77,6 +77,8 @@ class CSVRow(BaseCSVRow):
"""Constructs a row in the CSV. """Constructs a row in the CSV.
:param journal_entry_date: The journal entry date. :param journal_entry_date: The journal entry date.
:param currency: The currency.
:param account: The account.
:param description: The description. :param description: The description.
:param debit: The debit amount. :param debit: The debit amount.
:param credit: The credit amount. :param credit: The credit amount.
@ -116,6 +118,7 @@ class PageParams(BasePageParams):
"""Constructs the HTML page parameters. """Constructs the HTML page parameters.
:param period: The period. :param period: The period.
:param pagination: The pagination.
:param line_items: The line items. :param line_items: The line items.
""" """
self.period: Period = period self.period: Period = period

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -151,6 +151,17 @@ class LineItemCollector:
== journal_entry_date.day)) == journal_entry_date.day))
except ValueError: except ValueError:
pass pass
try:
journal_entry_date = datetime.strptime(k, "%Y/%m/%d")
conditions.append(sa.and_(
sa.extract("year", JournalEntry.date)
== journal_entry_date.year,
sa.extract("month", JournalEntry.date)
== journal_entry_date.month,
sa.extract("day", JournalEntry.date)
== journal_entry_date.day))
except ValueError:
pass
return sa.select(JournalEntry.id).filter(sa.or_(*conditions)) return sa.select(JournalEntry.id).filter(sa.or_(*conditions))

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -191,6 +191,7 @@ class TrialBalance(BaseReport):
.join(JournalEntry).join(Account)\ .join(JournalEntry).join(Account)\
.filter(*conditions)\ .filter(*conditions)\
.group_by(Account.id)\ .group_by(Account.id)\
.having(balance_func != 0)\
.order_by(Account.base_code, Account.no) .order_by(Account.base_code, Account.no)
balances: list[sa.Row] = db.session.execute(select_balances).all() balances: list[sa.Row] = db.session.execute(select_balances).all()
accounts: dict[int, Account] \ accounts: dict[int, Account] \

View File

@ -0,0 +1,185 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
# 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 unapplied original line items.
"""
from datetime import date
from decimal import Decimal
from flask import render_template, Response
from accounting.locale import gettext
from accounting.models import Account, JournalEntryLineItem
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.unapplied import get_accounts_with_unapplied
from accounting.report.utils.urls import unapplied_url
from accounting.utils.offset_matcher import OffsetMatcher
from accounting.utils.pagination import Pagination
from accounting.utils.permission import can_edit
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, journal_entry_date: str | date, currency: str,
description: str | None, amount: str | Decimal,
net_balance: str | Decimal):
"""Constructs a row in the CSV.
:param journal_entry_date: The journal entry date.
:param currency: The currency.
:param description: The description.
:param amount: The amount.
:param net_balance: The net balance.
"""
self.date: str | date = journal_entry_date
"""The date."""
self.currency: str = currency
"""The currency."""
self.description: str | None = description
"""The description."""
self.amount: str | Decimal = amount
"""The amount."""
self.net_balance: str | Decimal = net_balance
"""The net balance."""
@property
def values(self) -> list[str | date | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.date, self.currency, self.description, self.amount,
self.net_balance]
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, account: Account,
is_mark_matches: bool,
pagination: Pagination[JournalEntryLineItem],
line_items: list[JournalEntryLineItem]):
"""Constructs the HTML page parameters.
:param account: The account.
:param is_mark_matches: Whether to mark the matched offsets.
:param pagination: The pagination.
:param line_items: The line items.
"""
self.account: Account = account
"""The account."""
self.pagination: Pagination[JournalEntryLineItem] = pagination
"""The pagination."""
self.line_items: list[JournalEntryLineItem] = line_items
"""The line items."""
self.is_mark_matches: bool = is_mark_matches
"""Whether to mark the matched offsets."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return len(self.line_items) > 0
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.UNAPPLIED,
account=self.account)
@property
def account_options(self) -> list[OptionLink]:
"""Returns the account options.
:return: The account options.
"""
options: list[OptionLink] = [OptionLink(gettext("Accounts"),
unapplied_url(None),
False)]
options.extend([OptionLink(str(x),
unapplied_url(x),
x.id == self.account.id)
for x in get_accounts_with_unapplied()])
return options
def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
"""Composes and returns the CSV rows from the line items.
:param line_items: The line items.
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
gettext("Description"), gettext("Amount"),
gettext("Net Balance"))]
rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
x.description, x.amount, x.net_balance)
for x in line_items])
return rows
class UnappliedOriginalLineItems(BaseReport):
"""The unapplied original line items."""
def __init__(self, account: Account):
"""Constructs the unapplied original line items.
:param account: The account.
"""
self.__account: Account = account
"""The account."""
offset_matcher: OffsetMatcher = OffsetMatcher(self.__account)
self.__line_items: list[JournalEntryLineItem] \
= offset_matcher.unapplied
"""The line items."""
self.__is_mark_matches: bool \
= can_edit() and len(offset_matcher.unmatched_offsets) > 0
"""Whether to mark the matched offsets."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = f"unapplied-{self.__account.code}.csv"
return csv_download(filename, get_csv_rows(self.__line_items))
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
pagination: Pagination[JournalEntryLineItem] \
= Pagination[JournalEntryLineItem](self.__line_items,
is_reversed=True)
params: PageParams = PageParams(account=self.__account,
is_mark_matches=self.__is_mark_matches,
pagination=pagination,
line_items=pagination.list)
return render_template("accounting/report/unapplied.html",
report=params)

View File

@ -0,0 +1,137 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
# 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 accounts with unapplied original line items.
"""
from datetime import date
from decimal import Decimal
from flask import render_template, Response
from accounting.locale import gettext
from accounting.models import Account
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.unapplied import get_accounts_with_unapplied
from accounting.report.utils.urls import unapplied_url
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, account: str, count: int | str):
"""Constructs a row in the CSV.
:param account: The account.
:param count: The number of unapplied original line items.
"""
self.account: str = account
"""The currency."""
self.count: int | str = count
"""The number of unapplied original line items."""
@property
def values(self) -> list[str | date | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.account, self.count]
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, accounts: list[Account]):
"""Constructs the HTML page parameters.
:param accounts: The accounts.
"""
self.accounts: list[Account] = accounts
"""The accounts."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return len(self.accounts) > 0
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.UNAPPLIED)
@property
def account_options(self) -> list[OptionLink]:
"""Returns the account options.
:return: The account options.
"""
options: list[OptionLink] = [OptionLink(gettext("Accounts"),
unapplied_url(None),
True)]
options.extend([OptionLink(str(x),
unapplied_url(x),
False)
for x in self.accounts])
return options
def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
"""Composes and returns the CSV rows from the line items.
:param accounts: The accounts.
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
rows.extend([CSVRow(str(x).title(), x.count)
for x in accounts])
return rows
class AccountsWithUnappliedOriginalLineItems(BaseReport):
"""The accounts with unapplied original line items."""
def __init__(self):
"""Constructs the outstanding balances."""
self.__accounts: list[Account] = get_accounts_with_unapplied()
"""The accounts."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = f"unapplied-accounts.csv"
return csv_download(filename, get_csv_rows(self.__accounts))
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
return render_template("accounting/report/unapplied-accounts.html",
report=PageParams(accounts=self.__accounts))

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/6 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/6
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -22,6 +22,7 @@ from abc import ABC, abstractmethod
from datetime import timedelta, date from datetime import timedelta, date
from decimal import Decimal from decimal import Decimal
from io import StringIO from io import StringIO
from urllib.parse import quote
from flask import Response from flask import Response
@ -53,7 +54,7 @@ def csv_download(filename: str, rows: list[BaseCSVRow]) -> Response:
fp.seek(0) fp.seek(0)
response: Response = Response(fp.read(), mimetype="text/csv") response: Response = Response(fp.read(), mimetype="text/csv")
response.headers["Content-Disposition"] \ response.headers["Content-Disposition"] \
= f"attachment; filename={filename}" = f"attachment; filename={quote(filename)}"
return response return response

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/5 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/5
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -34,7 +34,7 @@ from accounting.utils.current_account import CurrentAccount
from .option_link import OptionLink from .option_link import OptionLink
from .report_type import ReportType from .report_type import ReportType
from .urls import journal_url, ledger_url, income_expenses_url, \ from .urls import journal_url, ledger_url, income_expenses_url, \
trial_balance_url, income_statement_url, balance_sheet_url trial_balance_url, income_statement_url, balance_sheet_url, unapplied_url
class ReportChooser: class ReportChooser:
@ -68,40 +68,19 @@ class ReportChooser:
"""The title of the current report.""" """The title of the current report."""
self.is_search: bool = active_report == ReportType.SEARCH self.is_search: bool = active_report == ReportType.SEARCH
"""Whether the current report is the search page.""" """Whether the current report is the search page."""
self.__reports.append(self.__journal)
self.__reports.append(self.__ledger)
self.__reports.append(self.__income_expenses) self.__reports.append(self.__income_expenses)
self.__reports.append(self.__ledger)
self.__reports.append(self.__journal)
self.__reports.append(self.__trial_balance) self.__reports.append(self.__trial_balance)
self.__reports.append(self.__income_statement) self.__reports.append(self.__income_statement)
self.__reports.append(self.__balance_sheet) self.__reports.append(self.__balance_sheet)
self.__reports.append(self.__unapplied)
for report in self.__reports: for report in self.__reports:
if report.is_active: if report.is_active:
self.current_report = report.title self.current_report = report.title
if self.is_search: if self.is_search:
self.current_report = gettext("Search") self.current_report = gettext("Search")
@property
def __journal(self) -> OptionLink:
"""Returns the journal.
:return: The journal.
"""
return OptionLink(gettext("Journal"), journal_url(self.__period),
self.__active_report == ReportType.JOURNAL,
fa_icon="fa-solid fa-book")
@property
def __ledger(self) -> OptionLink:
"""Returns the ledger.
:return: The ledger.
"""
return OptionLink(gettext("Ledger"),
ledger_url(self.__currency, self.__account,
self.__period),
self.__active_report == ReportType.LEDGER,
fa_icon="fa-solid fa-clipboard")
@property @property
def __income_expenses(self) -> OptionLink: def __income_expenses(self) -> OptionLink:
"""Returns the income and expenses log. """Returns the income and expenses log.
@ -118,6 +97,28 @@ class ReportChooser:
self.__active_report == ReportType.INCOME_EXPENSES, self.__active_report == ReportType.INCOME_EXPENSES,
fa_icon="fa-solid fa-money-bill-wave") fa_icon="fa-solid fa-money-bill-wave")
@property
def __ledger(self) -> OptionLink:
"""Returns the ledger.
:return: The ledger.
"""
return OptionLink(gettext("Ledger"),
ledger_url(self.__currency, self.__account,
self.__period),
self.__active_report == ReportType.LEDGER,
fa_icon="fa-solid fa-clipboard")
@property
def __journal(self) -> OptionLink:
"""Returns the journal.
:return: The journal.
"""
return OptionLink(gettext("Journal"), journal_url(self.__period),
self.__active_report == ReportType.JOURNAL,
fa_icon="fa-solid fa-book")
@property @property
def __trial_balance(self) -> OptionLink: def __trial_balance(self) -> OptionLink:
"""Returns the trial balance. """Returns the trial balance.
@ -151,6 +152,23 @@ class ReportChooser:
self.__active_report == ReportType.BALANCE_SHEET, self.__active_report == ReportType.BALANCE_SHEET,
fa_icon="fa-solid fa-scale-balanced") fa_icon="fa-solid fa-scale-balanced")
@property
def __unapplied(self) -> OptionLink:
"""Returns the unapplied original line items.
:return: The unapplied original line items.
"""
account: Account = self.__account
if not account.is_need_offset:
return OptionLink(gettext("Unapplied Original Line Items"),
unapplied_url(None),
self.__active_report == ReportType.UNAPPLIED,
fa_icon="fa-solid fa-link-slash")
return OptionLink(gettext("Unapplied Original Line Items"),
unapplied_url(account),
self.__active_report == ReportType.UNAPPLIED,
fa_icon="fa-solid fa-link-slash")
def __iter__(self) -> t.Iterator[OptionLink]: def __iter__(self) -> t.Iterator[OptionLink]:
"""Returns the iteration of the reports. """Returns the iteration of the reports.

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -34,5 +34,7 @@ class ReportType(Enum):
"""The income statement.""" """The income statement."""
BALANCE_SHEET: str = "balance-sheet" BALANCE_SHEET: str = "balance-sheet"
"""The balance sheet.""" """The balance sheet."""
UNAPPLIED: str = "unapplied"
"""The unapplied original line items."""
SEARCH: str = "search" SEARCH: str = "search"
"""The search.""" """The search."""

View File

@ -0,0 +1,67 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
# 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 unapplied original line item utilities.
"""
import sqlalchemy as sa
from accounting import db
from accounting.models import Account, JournalEntryLineItem
from accounting.utils.cast import be
from accounting.utils.offset_alias import offset_alias
def get_accounts_with_unapplied() -> list[Account]:
"""Returns the accounts with unapplied original line items.
:return: The accounts with unapplied original line items.
"""
offset: sa.Alias = offset_alias()
net_balance: sa.Label \
= (JournalEntryLineItem.amount
+ sa.func.sum(sa.case(
(be(offset.c.is_debit == JournalEntryLineItem.is_debit),
offset.c.amount),
else_=-offset.c.amount))).label("net_balance")
select_unapplied: sa.Select \
= sa.select(JournalEntryLineItem.id)\
.join(Account)\
.join(offset, be(JournalEntryLineItem.id
== offset.c.original_line_item_id),
isouter=True)\
.filter(Account.is_need_offset,
sa.or_(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)),
sa.and_(Account.base_code.startswith("1"),
JournalEntryLineItem.is_debit)))\
.group_by(JournalEntryLineItem.id)\
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
count_func: sa.Label \
= sa.func.count(JournalEntryLineItem.id).label("count")
select: sa.Select = sa.select(Account.id, count_func)\
.join(JournalEntryLineItem, isouter=True)\
.filter(JournalEntryLineItem.id.in_(select_unapplied))\
.group_by(Account.id)\
.having(count_func > 0)
counts: dict[int, int] \
= {x.id: x.count for x in db.session.execute(select)}
accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\
.order_by(Account.base_code, Account.no).all()
for account in accounts:
account.count = counts[account.id]
return accounts

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -34,8 +34,8 @@ def journal_url(period: Period) \
:return: The URL of the journal. :return: The URL of the journal.
""" """
if period.is_default: if period.is_default:
return url_for("accounting.report.journal-default") return url_for("accounting-report.journal-default")
return url_for("accounting.report.journal", period=period) return url_for("accounting-report.journal", period=period)
def ledger_url(currency: Currency, account: Account, period: Period) \ def ledger_url(currency: Currency, account: Account, period: Period) \
@ -47,10 +47,11 @@ def ledger_url(currency: Currency, account: Account, period: Period) \
:param period: The period. :param period: The period.
:return: The URL of the ledger. :return: The URL of the ledger.
""" """
if period.is_default: if currency.code == default_currency_code() \
return url_for("accounting.report.ledger-default", and account.code == Account.CASH_CODE \
currency=currency, account=account) and period.is_default:
return url_for("accounting.report.ledger", return url_for("accounting-report.ledger-default")
return url_for("accounting-report.ledger",
currency=currency, account=account, currency=currency, account=account,
period=period) period=period)
@ -67,11 +68,8 @@ def income_expenses_url(currency: Currency, account: CurrentAccount,
if currency.code == default_currency_code() \ if currency.code == default_currency_code() \
and account.code == options.default_ie_account_code \ and account.code == options.default_ie_account_code \
and period.is_default: and period.is_default:
return url_for("accounting.report.default") return url_for("accounting-report.default")
if period.is_default: return url_for("accounting-report.income-expenses",
return url_for("accounting.report.income-expenses-default",
currency=currency, account=account)
return url_for("accounting.report.income-expenses",
currency=currency, account=account, currency=currency, account=account,
period=period) period=period)
@ -83,10 +81,9 @@ def trial_balance_url(currency: Currency, period: Period) -> str:
:param period: The period. :param period: The period.
:return: The URL of the trial balance. :return: The URL of the trial balance.
""" """
if period.is_default: if currency.code == default_currency_code() and period.is_default:
return url_for("accounting.report.trial-balance-default", return url_for("accounting-report.trial-balance-default")
currency=currency) return url_for("accounting-report.trial-balance",
return url_for("accounting.report.trial-balance",
currency=currency, period=period) currency=currency, period=period)
@ -97,10 +94,9 @@ def income_statement_url(currency: Currency, period: Period) -> str:
:param period: The period. :param period: The period.
:return: The URL of the income statement. :return: The URL of the income statement.
""" """
if period.is_default: if currency.code == default_currency_code() and period.is_default:
return url_for("accounting.report.income-statement-default", return url_for("accounting-report.income-statement-default")
currency=currency) return url_for("accounting-report.income-statement",
return url_for("accounting.report.income-statement",
currency=currency, period=period) currency=currency, period=period)
@ -111,8 +107,19 @@ def balance_sheet_url(currency: Currency, period: Period) -> str:
:param period: The period. :param period: The period.
:return: The URL of the balance sheet. :return: The URL of the balance sheet.
""" """
if period.is_default: if currency.code == default_currency_code() and period.is_default:
return url_for("accounting.report.balance-sheet-default", return url_for("accounting-report.balance-sheet-default")
currency=currency) return url_for("accounting-report.balance-sheet",
return url_for("accounting.report.balance-sheet",
currency=currency, period=period) currency=currency, period=period)
def unapplied_url(account: Account | None) -> str:
"""Returns the URL of the unapplied original line items.
:param account: The account, or None to list the accounts with unapplied
original line items.
:return: The URL of the unapplied original line items.
"""
if account is None:
return url_for("accounting-report.unapplied-default")
return url_for("accounting-report.unapplied", account=account)

View File

@ -1,4 +1,4 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
@ -28,9 +28,11 @@ from accounting.utils.options import options
from accounting.utils.permission import has_permission, can_view from accounting.utils.permission import has_permission, can_view
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \ from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
IncomeStatement, BalanceSheet, Search IncomeStatement, BalanceSheet, Search
from .reports.unapplied import UnappliedOriginalLineItems
from .reports.unapplied_accounts import AccountsWithUnappliedOriginalLineItems
from .template_filters import format_amount from .template_filters import format_amount
bp: Blueprint = Blueprint("report", __name__) bp: Blueprint = Blueprint("accounting-report", __name__)
"""The view blueprint for the reports.""" """The view blueprint for the reports."""
bp.add_app_template_filter(format_amount, "accounting_report_format_amount") bp.add_app_template_filter(format_amount, "accounting_report_format_amount")
@ -42,10 +44,7 @@ def get_default_report() -> str | Response:
:return: The income and expenses log in the default period. :return: The income and expenses log in the default period.
""" """
return __get_income_expenses( return get_default_income_expenses()
db.session.get(Currency, default_currency_code()),
options.default_ie_account,
get_period())
@bp.get("journal", endpoint="journal-default") @bp.get("journal", endpoint="journal-default")
@ -81,17 +80,15 @@ def __get_journal(period: Period) -> str | Response:
return report.html() return report.html()
@bp.get("ledger/<currency:currency>/<account:account>", @bp.get("ledger", endpoint="ledger-default")
endpoint="ledger-default")
@has_permission(can_view) @has_permission(can_view)
def get_default_ledger(currency: Currency, account: Account) -> str | Response: def get_default_ledger() -> str | Response:
"""Returns the ledger in the default period. """Returns the ledger in the default currency, cash, and default period.
:param currency: The currency. :return: The ledger in the default currency, cash, and default period.
:param account: The account.
:return: The ledger in the default period.
""" """
return __get_ledger(currency, account, get_period()) return __get_ledger(db.session.get(Currency, default_currency_code()),
Account.cash(), get_period())
@bp.get("ledger/<currency:currency>/<account:account>/<period:period>", @bp.get("ledger/<currency:currency>/<account:account>/<period:period>",
@ -124,23 +121,21 @@ def __get_ledger(currency: Currency, account: Account, period: Period) \
return report.html() return report.html()
@bp.get("income-expenses/<currency:currency>/<ieAccount:account>", @bp.get("income-expenses", endpoint="income-expenses-default")
endpoint="income-expenses-default")
@has_permission(can_view) @has_permission(can_view)
def get_default_income_expenses(currency: Currency, account: CurrentAccount) \ def get_default_income_expenses() -> str | Response:
-> str | Response:
"""Returns the income and expenses log in the default period. """Returns the income and expenses log in the default period.
:param currency: The currency.
:param account: The account.
:return: The income and expenses log in the default period. :return: The income and expenses log in the default period.
""" """
return __get_income_expenses(currency, account, get_period()) return __get_income_expenses(
db.session.get(Currency, default_currency_code()),
options.default_ie_account,
get_period())
@bp.get( @bp.get("income-expenses/<currency:currency>/<currentAccount:account>/"
"income-expenses/<currency:currency>/<ieAccount:account>/<period:period>", "<period:period>", endpoint="income-expenses")
endpoint="income-expenses")
@has_permission(can_view) @has_permission(can_view)
def get_income_expenses(currency: Currency, account: CurrentAccount, def get_income_expenses(currency: Currency, account: CurrentAccount,
period: Period) -> str | Response: period: Period) -> str | Response:
@ -169,16 +164,15 @@ def __get_income_expenses(currency: Currency, account: CurrentAccount,
return report.html() return report.html()
@bp.get("trial-balance/<currency:currency>", @bp.get("trial-balance", endpoint="trial-balance-default")
endpoint="trial-balance-default")
@has_permission(can_view) @has_permission(can_view)
def get_default_trial_balance(currency: Currency) -> str | Response: def get_default_trial_balance() -> str | Response:
"""Returns the trial balance in the default period. """Returns the trial balance in the default period.
:param currency: The currency.
:return: The trial balance in the default period. :return: The trial balance in the default period.
""" """
return __get_trial_balance(currency, get_period()) return __get_trial_balance(
db.session.get(Currency, default_currency_code()), get_period())
@bp.get("trial-balance/<currency:currency>/<period:period>", @bp.get("trial-balance/<currency:currency>/<period:period>",
@ -207,16 +201,15 @@ def __get_trial_balance(currency: Currency, period: Period) -> str | Response:
return report.html() return report.html()
@bp.get("income-statement/<currency:currency>", @bp.get("income-statement", endpoint="income-statement-default")
endpoint="income-statement-default")
@has_permission(can_view) @has_permission(can_view)
def get_default_income_statement(currency: Currency) -> str | Response: def get_default_income_statement() -> str | Response:
"""Returns the income statement in the default period. """Returns the income statement in the default period.
:param currency: The currency.
:return: The income statement in the default period. :return: The income statement in the default period.
""" """
return __get_income_statement(currency, get_period()) return __get_income_statement(
db.session.get(Currency, default_currency_code()), get_period())
@bp.get("income-statement/<currency:currency>/<period:period>", @bp.get("income-statement/<currency:currency>/<period:period>",
@ -246,16 +239,15 @@ def __get_income_statement(currency: Currency, period: Period) \
return report.html() return report.html()
@bp.get("balance-sheet/<currency:currency>", @bp.get("balance-sheet", endpoint="balance-sheet-default")
endpoint="balance-sheet-default")
@has_permission(can_view) @has_permission(can_view)
def get_default_balance_sheet(currency: Currency) -> str | Response: def get_default_balance_sheet() -> str | Response:
"""Returns the balance sheet in the default period. """Returns the balance sheet in the default period.
:param currency: The currency.
:return: The balance sheet in the default period. :return: The balance sheet in the default period.
""" """
return __get_balance_sheet(currency, get_period()) return __get_balance_sheet(
db.session.get(Currency, default_currency_code()), get_period())
@bp.get("balance-sheet/<currency:currency>/<period:period>", @bp.get("balance-sheet/<currency:currency>/<period:period>",
@ -286,6 +278,34 @@ def __get_balance_sheet(currency: Currency, period: Period) \
return report.html() return report.html()
@bp.get("unapplied", endpoint="unapplied-default")
@has_permission(can_view)
def get_default_unapplied() -> str | Response:
"""Returns the accounts with unapplied original line items.
:return: The accounts with unapplied original line items.
"""
report: AccountsWithUnappliedOriginalLineItems \
= AccountsWithUnappliedOriginalLineItems()
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.get("unapplied/<needOffsetAccount:account>", endpoint="unapplied")
@has_permission(can_view)
def get_unapplied(account: Account) -> str | Response:
"""Returns the unapplied original line items.
:param account: The Account.
:return: The unapplied original line items.
"""
report: UnappliedOriginalLineItems = UnappliedOriginalLineItems(account)
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.get("search", endpoint="search") @bp.get("search", endpoint="search")
@has_permission(can_view) @has_permission(can_view)
def search() -> str | Response: def search() -> str | Response:

View File

@ -1,4 +1,4 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Project
* style.css: The style sheet for the accounting application. * style.css: The style sheet for the accounting application.
*/ */
@ -209,11 +209,23 @@ a.accounting-report-table-row {
.accounting-report-table-body .accounting-amount { .accounting-report-table-body .accounting-amount {
font-style: italic; font-style: italic;
} }
.accounting-report-table-body .accounting-report-table-row {
background-color: #f8f9fa;
}
.accounting-report-table-body .accounting-report-table-row:nth-child(2n+1) { .accounting-report-table-body .accounting-report-table-row:nth-child(2n+1) {
background-color: #f2f2f2; background-color: #ecedee;
} }
.accounting-report-table-body .accounting-report-table-row:hover { .accounting-report-table-body .accounting-report-table-row:hover {
background-color: rgba(0, 0, 0, 0.075); background-color: #e5e6e7;
}
.accounting-report-table-body .accounting-report-table-row-danger {
background-color: #f8d7da;
}
.accounting-report-table-body .accounting-report-table-row-danger:nth-child(2n+1) {
background-color: #eccccf;
}
.accounting-report-table-body .accounting-report-table-row-danger:hover {
background-color: #e5c7ca;
} }
.accounting-journal-table .accounting-report-table-row { .accounting-journal-table .accounting-report-table-row {
grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr; grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr;
@ -309,6 +321,16 @@ a.accounting-report-table-row {
.accounting-balance-sheet-total .accounting-amount, .accounting-balance-sheet-subtotal, .accounting-amount { .accounting-balance-sheet-total .accounting-amount, .accounting-balance-sheet-subtotal, .accounting-amount {
font-style: italic; font-style: italic;
} }
.accounting-unapplied-table .accounting-report-table-row {
grid-template-columns: 1fr 1fr 5fr 1fr 1fr;
}
.accounting-unapplied-account-table .accounting-report-table-row {
display: flex;
justify-content: space-between;
}
.accounting-unapplied-account-table .accounting-report-table-header .accounting-report-table-row {
display: block;
}
/* The accounting report */ /* The accounting report */
.accounting-mobile-journal-credit { .accounting-mobile-journal-credit {
@ -316,6 +338,10 @@ a.accounting-report-table-row {
} }
/* The description editor */ /* The description editor */
.accounting-description-editor-buttons {
max-height: 7rem;
overflow-y: scroll;
}
.accounting-description-editor-buttons .btn { .accounting-description-editor-buttons .btn {
margin-bottom: 0.3rem; margin-bottom: 0.3rem;
} }
@ -339,6 +365,12 @@ a.accounting-report-table-row {
margin: 0; margin: 0;
} }
/* The unmatched offsets */
.accounting-unmatched-offset-pair-list {
height: 20rem;
overflow-y: scroll;
}
/* The Material Design text field (floating form control in Bootstrap) */ /* The Material Design text field (floating form control in Bootstrap) */
.accounting-material-text-field { .accounting-material-text-field {
position: relative; position: relative;

View File

@ -1,4 +1,4 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Project
* account-form.js: The JavaScript for the account form * account-form.js: The JavaScript for the account form
*/ */
@ -342,12 +342,6 @@ class BaseAccountSelector {
*/ */
class BaseAccountOption { class BaseAccountOption {
/**
* The base account selector
* @type {BaseAccountSelector}
*/
#selector;
/** /**
* The element * The element
* @type {HTMLLIElement} * @type {HTMLLIElement}
@ -379,13 +373,12 @@ class BaseAccountOption {
* @param element {HTMLLIElement} the element * @param element {HTMLLIElement} the element
*/ */
constructor(selector, element) { constructor(selector, element) {
this.#selector = selector;
this.#element = element; this.#element = element;
this.code = element.dataset.code; this.code = element.dataset.code;
this.text = element.dataset.text; this.text = element.dataset.text;
this.#queryValues = JSON.parse(element.dataset.queryValues); this.#queryValues = JSON.parse(element.dataset.queryValues);
this.#element.onclick = () => this.#selector.form.saveBaseAccount(this); this.#element.onclick = () => selector.form.saveBaseAccount(this);
} }
/** /**

View File

@ -1,4 +1,4 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Project
* account-order.js: The JavaScript for the account order * account-order.js: The JavaScript for the account order
*/ */
@ -29,10 +29,11 @@ document.addEventListener("DOMContentLoaded", () => {
const onReorder = () => { const onReorder = () => {
const accounts = Array.from(list.children); const accounts = Array.from(list.children);
for (let i = 0; i < accounts.length; i++) { for (let i = 0; i < accounts.length; i++) {
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no"); const no = document.getElementById(`accounting-order-${accounts[i].dataset.id}-no`);
const code = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-code"); const code = document.getElementById(`accounting-order-${accounts[i].dataset.id}-code`);
no.value = String(i + 1); no.value = String(i + 1);
code.innerText = list.dataset.baseCode + "-" + ("000" + (i + 1)).slice(-3); const zeroPaddedNo = `000${no.value}`.slice(-3)
code.innerText = `${list.dataset.baseCode}-${zeroPaddedNo}`;
} }
}; };
initializeDragAndDropReordering(list, onReorder); initializeDragAndDropReordering(list, onReorder);

View File

@ -1,4 +1,4 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Project
* currency-form.js: The JavaScript for the currency form * currency-form.js: The JavaScript for the currency form
*/ */
@ -128,7 +128,7 @@ class CurrencyForm {
} }
const original = this.#code.dataset.original; const original = this.#code.dataset.original;
if (original === "" || this.#code.value !== original) { if (original === "" || this.#code.value !== original) {
const response = await fetch(this.#code.dataset.existsUrl + "?q=" + encodeURIComponent(this.#code.value)); const response = await fetch(`${this.#code.dataset.existsUrl}?q=${encodeURIComponent(this.#code.value)}`);
const data = await response.json(); const data = await response.json();
if (data["exists"]) { if (data["exists"]) {
this.#code.classList.add("is-invalid"); this.#code.classList.add("is-invalid");

View File

@ -1,4 +1,4 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Project
* description-editor.js: The JavaScript for the description editor * description-editor.js: The JavaScript for the description editor
*/ */
@ -60,7 +60,7 @@ class DescriptionEditor {
/** /**
* The current tab * The current tab
* @type {TabPlane} * @type {DescriptionEditorTabPlane}
*/ */
currentTab; currentTab;
@ -68,7 +68,7 @@ class DescriptionEditor {
* The description input * The description input
* @type {HTMLInputElement} * @type {HTMLInputElement}
*/ */
description; #descriptionInput;
/** /**
* The button to the original line item selector * The button to the original line item selector
@ -89,20 +89,44 @@ class DescriptionEditor {
note; note;
/** /**
* The account buttons * The placeholder of the confirmed account
* @type {HTMLButtonElement[]} * @type {DescriptionEditorConfirmedAccount}
*/ */
#accountButtons; #confirmedAccountPlaceholder;
/** /**
* The selected account button * All the suggested accounts
* @type {HTMLButtonElement|null} * @type {DescriptionEditorSuggestedAccount[]}
*/ */
#selectedAccount = null; #allSuggestedAccounts;
/**
* The current suggested accounts
* @type {DescriptionEditorSuggestedAccount[]}
*/
#currentSuggestedAccounts;
/**
* The account that the user specified or confirmed
* @type {DescriptionEditorConfirmedAccount|null}
*/
#confirmedAccount = null;
/**
* Whether the user has confirmed the account
* @type {boolean}
*/
isAccountConfirmed = false;
/**
* The selected account.
* @type {DescriptionEditorAccount|null}
*/
selectedAccount = null;
/** /**
* The tab planes * The tab planes
* @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, recurring: RecurringTransactionTab, annotation: AnnotationTab}} * @type {{general: DescriptionEditorGeneralTagTab, travel: DescriptionEditorGeneralTripTab, bus: DescriptionEditorBusTripTab, recurring: DescriptionEditorRecurringTab, annotation: DescriptionEditorAnnotationTab}}
*/ */
tabPlanes = {}; tabPlanes = {};
@ -115,23 +139,22 @@ class DescriptionEditor {
constructor(lineItemEditor, debitCredit) { constructor(lineItemEditor, debitCredit) {
this.lineItemEditor = lineItemEditor; this.lineItemEditor = lineItemEditor;
this.debitCredit = debitCredit; this.debitCredit = debitCredit;
this.prefix = "accounting-description-editor-" + debitCredit; this.prefix = `accounting-description-editor-${debitCredit}`;
this.#form = document.getElementById(this.prefix); this.#form = document.getElementById(this.prefix);
this.#modal = document.getElementById(this.prefix + "-modal"); this.#modal = document.getElementById(`${this.prefix}-modal`);
this.description = document.getElementById(this.prefix + "-description"); this.#descriptionInput = document.getElementById(`${this.prefix}-description`);
this.#offsetButton = document.getElementById(this.prefix + "-offset"); this.#offsetButton = document.getElementById(`${this.prefix}-offset`);
this.number = document.getElementById(this.prefix + "-annotation-number"); this.number = document.getElementById(`${this.prefix}-annotation-number`);
this.note = document.getElementById(this.prefix + "-annotation-note"); this.note = document.getElementById(`${this.prefix}-annotation-note`);
// noinspection JSValidateTypes this.#confirmedAccountPlaceholder = new DescriptionEditorConfirmedAccount(this, document.getElementById(`${this.prefix}-account-confirmed`));
this.#accountButtons = Array.from(document.getElementsByClassName(this.prefix + "-account")); this.#allSuggestedAccounts = Array.from(document.getElementsByClassName(`${this.prefix}-account`)).map((button) => new DescriptionEditorSuggestedAccount(this, button));
for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RecurringTransactionTab, AnnotationTab]) { for (const cls of [DescriptionEditorGeneralTagTab, DescriptionEditorGeneralTripTab, DescriptionEditorBusTripTab, DescriptionEditorRecurringTab, DescriptionEditorAnnotationTab]) {
const tab = new cls(this); const tab = new cls(this);
this.tabPlanes[tab.tabId()] = tab; this.tabPlanes[tab.tabId()] = tab;
} }
this.currentTab = this.tabPlanes.general; this.currentTab = this.tabPlanes.general;
this.#initializeSuggestedAccounts(); this.#descriptionInput.onchange = () => this.#onDescriptionChange();
this.description.onchange = () => this.#onDescriptionChange();
this.#offsetButton.onclick = () => this.lineItemEditor.originalLineItemSelector.onOpen(); this.#offsetButton.onclick = () => this.lineItemEditor.originalLineItemSelector.onOpen();
this.#form.onsubmit = () => { this.#form.onsubmit = () => {
if (this.currentTab.validate()) { if (this.currentTab.validate()) {
@ -141,12 +164,44 @@ class DescriptionEditor {
}; };
} }
/**
* Returns the description.
*
* @return {string} the description
*/
get description() {
return this.#descriptionInput.value;
}
/**
* Sets the description.
*
* @param description {string} the description
*/
set description(description) {
this.#descriptionInput.value = description;
}
/**
* Returns the current account options.
*
* @return {DescriptionEditorAccount[]} the current account options.
*/
get #currentAccountOptions() {
if (this.#confirmedAccount === null) {
return this.#currentSuggestedAccounts;
}
return [this.#confirmedAccount].concat(this.#currentSuggestedAccounts);
}
/** /**
* The callback when the description input is changed. * The callback when the description input is changed.
* *
*/ */
#onDescriptionChange() { #onDescriptionChange() {
this.description.value = this.description.value.trim(); this.#resetTabPlanes();
this.selectedAccount = null;
this.description = this.description.trim();
for (const tabPlane of [this.tabPlanes.recurring, this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) { for (const tabPlane of [this.tabPlanes.recurring, this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) {
if (tabPlane.populate()) { if (tabPlane.populate()) {
break; break;
@ -156,20 +211,49 @@ class DescriptionEditor {
} }
/** /**
* Filters the suggested accounts. * Resets the tab planes.
*
*/
#resetTabPlanes() {
for (const tabPlane of Object.values(this.tabPlanes)) {
tabPlane.reset();
}
this.tabPlanes.general.switchToMe();
}
/**
* Updates the current suggested accounts.
* *
* @param tagButton {HTMLButtonElement} the tag button * @param tagButton {HTMLButtonElement} the tag button
*/ */
filterSuggestedAccounts(tagButton) { updateCurrentSuggestedAccounts(tagButton) {
this.clearSuggestedAccounts(); this.clearSuggestedAccounts();
const suggested = JSON.parse(tagButton.dataset.accounts); const suggestedAccountCodes = JSON.parse(tagButton.dataset.accounts);
for (const accountButton of this.#accountButtons) { this.#currentSuggestedAccounts = this.#allSuggestedAccounts.filter((account) => {
if (suggested.includes(accountButton.dataset.code)) { if (this.#confirmedAccount !== null && account.code === this.#confirmedAccount.code) {
accountButton.classList.remove("d-none"); return false;
if (accountButton.dataset.code === suggested[0]) { }
this.#selectAccount(accountButton); return suggestedAccountCodes.includes(account.code);
return; });
} for (const account of this.#currentSuggestedAccounts) {
account.setShown(true);
}
this.#selectSuggestedAccount(suggestedAccountCodes[0]);
}
/**
* Selects the suggested account.
*
* @param code {string} the code of the most-frequent suggested account
*/
#selectSuggestedAccount(code) {
if (this.isAccountConfirmed) {
return;
}
for (const account of this.#currentAccountOptions) {
if (account.code === code) {
this.selectAccount(account);
return;
} }
} }
} }
@ -179,37 +263,29 @@ class DescriptionEditor {
* *
*/ */
clearSuggestedAccounts() { clearSuggestedAccounts() {
for (const accountButton of this.#accountButtons) { for (const account of this.#allSuggestedAccounts) {
accountButton.classList.add("d-none"); account.setShown(false);
account.setActive(false);
} }
this.#selectAccount(null); this.#currentSuggestedAccounts = [];
} }
/** /**
* Initializes the suggested accounts. * Select an account.
* *
* @param selectedAccount {DescriptionEditorAccount|null} the account, or null to deselect the account
*/ */
#initializeSuggestedAccounts() { selectAccount(selectedAccount) {
for (const accountButton of this.#accountButtons) { for (const account of this.#currentAccountOptions) {
accountButton.onclick = () => this.#selectAccount(accountButton); account.setActive(false);
} }
} if (selectedAccount !== null) {
selectedAccount.setActive(true);
/**
* Select a suggested account.
*
* @param selectedAccountButton {HTMLButtonElement|null} the account button, or null to deselect the account
*/
#selectAccount(selectedAccountButton) {
for (const accountButton of this.#accountButtons) {
accountButton.classList.remove("btn-primary");
accountButton.classList.add("btn-outline-primary");
} }
if (selectedAccountButton !== null) { this.selectedAccount = selectedAccount;
selectedAccountButton.classList.remove("btn-outline-primary"); if (this.selectedAccount !== null) {
selectedAccountButton.classList.add("btn-primary"); this.isAccountConfirmed &&= this.selectedAccount.isConfirmedAccount;
} }
this.#selectedAccount = selectedAccountButton;
} }
/** /**
@ -218,11 +294,7 @@ class DescriptionEditor {
*/ */
#submit() { #submit() {
bootstrap.Modal.getOrCreateInstance(this.#modal).hide(); bootstrap.Modal.getOrCreateInstance(this.#modal).hide();
if (this.#selectedAccount !== null) { this.lineItemEditor.saveDescription(this);
this.lineItemEditor.saveDescriptionWithAccount(this.description.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text, this.#selectedAccount.classList.contains("accounting-account-is-need-offset"));
} else {
this.lineItemEditor.saveDescription(this.description.value);
}
} }
/** /**
@ -230,21 +302,27 @@ class DescriptionEditor {
* *
*/ */
onOpen() { onOpen() {
this.#reset(); this.description = this.lineItemEditor.description === null? "": this.lineItemEditor.description;
this.description.value = this.lineItemEditor.description === null? "": this.lineItemEditor.description; this.#setConfirmedAccount();
this.#onDescriptionChange(); this.#onDescriptionChange();
if (this.isAccountConfirmed) {
this.selectAccount(this.#confirmedAccount);
}
} }
/** /**
* Resets the description editor. * Sets the confirmed account.
* *
*/ */
#reset() { #setConfirmedAccount() {
this.description.value = ""; this.isAccountConfirmed = this.lineItemEditor.isAccountConfirmed;
for (const tabPlane of Object.values(this.tabPlanes)) { this.#confirmedAccountPlaceholder.setShown(this.isAccountConfirmed);
tabPlane.reset(); if (this.isAccountConfirmed) {
this.#confirmedAccountPlaceholder.initializeFrom(this.lineItemEditor.account);
this.#confirmedAccount = this.#confirmedAccountPlaceholder;
} else {
this.#confirmedAccount = null;
} }
this.tabPlanes.general.switchToMe();
} }
/** /**
@ -263,13 +341,130 @@ class DescriptionEditor {
} }
} }
/**
* An account option in the description editor.
*
*/
class DescriptionEditorAccount extends JournalEntryAccount {
/**
* The account button
* @type {HTMLButtonElement}
*/
#element;
/**
* Whether this is the account specified or confirmed by the user
* @type {boolean}
*/
isConfirmedAccount = false;
/**
* Constructs an account option in the description editor.
*
* @param editor {DescriptionEditor} the description editor
* @param code {string} the account code
* @param text {string} the account text
* @param isNeedOffset {boolean} true if the line items in the account needs offset, or false otherwise
* @param button {HTMLButtonElement} the account button
*/
constructor(editor, code, text, isNeedOffset, button) {
super(code, text, isNeedOffset);
this.#element = button;
this.#element.onclick = () => editor.selectAccount(this);
}
/**
* Sets whether the option is shown.
*
* @param isShown {boolean} true to show, or false otherwise
*/
setShown(isShown) {
if (isShown) {
this.#element.classList.remove("d-none");
} else {
this.#element.classList.add("d-none");
}
}
/**
* Sets whether the option is active.
*
* @param isActive {boolean} true if active, or false otherwise
*/
setActive(isActive) {
if (isActive) {
this.#element.classList.add("btn-primary");
this.#element.classList.remove("btn-outline-primary");
} else {
this.#element.classList.remove("btn-primary");
this.#element.classList.add("btn-outline-primary");
}
}
/**
* Sets the content of the account button.
*
*/
resetContent() {
this.#element.innerText = this.text;
}
}
/**
* A suggested account.
*
*/
class DescriptionEditorSuggestedAccount extends DescriptionEditorAccount {
/**
* Constructs a suggested account.
*
* @param editor {DescriptionEditor} the description editor
* @param button {HTMLButtonElement} the account button
*/
constructor(editor, button) {
super(editor, button.dataset.code, button.dataset.text, button.classList.contains("accounting-account-is-need-offset"), button);
}
}
/**
* The account option that is specified or confirmed by the user.
*
*/
class DescriptionEditorConfirmedAccount extends DescriptionEditorAccount {
/**
* Constructs the account option that is specified or confirmed by the user.
*
* @param editor {DescriptionEditor} the description editor
* @param button {HTMLButtonElement} the account button
*/
constructor(editor, button) {
super(editor, "", "", false, button);
this.isConfirmedAccount = true;
}
/**
* Initializes the confirmed account from the line item editor.
*
* @param account {JournalEntryAccount} the confirmed account from the line item editor
*/
initializeFrom(account) {
this.code = account.code;
this.text = account.text;
this.isNeedOffset = account.isNeedOffset;
this.resetContent();
}
}
/** /**
* A tab plane. * A tab plane.
* *
* @abstract * @abstract
* @private * @private
*/ */
class TabPlane { class DescriptionEditorTabPlane {
/** /**
* The parent description editor * The parent description editor
@ -302,9 +497,9 @@ class TabPlane {
*/ */
constructor(editor) { constructor(editor) {
this.editor = editor; this.editor = editor;
this.prefix = this.editor.prefix + "-" + this.tabId(); this.prefix = `${this.editor.prefix}-${this.tabId()}`;
this.#tab = document.getElementById(this.prefix + "-tab"); this.#tab = document.getElementById(`${this.prefix}-tab`);
this.#page = document.getElementById(this.prefix + "-page"); this.#page = document.getElementById(`${this.prefix}-page`);
this.#tab.onclick = () => this.switchToMe(); this.#tab.onclick = () => this.switchToMe();
} }
@ -364,7 +559,7 @@ class TabPlane {
* @abstract * @abstract
* @private * @private
*/ */
class TagTabPlane extends TabPlane { class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
/** /**
* The tag input * The tag input
@ -392,10 +587,10 @@ class TagTabPlane extends TabPlane {
*/ */
constructor(editor) { constructor(editor) {
super(editor); super(editor);
this.tag = document.getElementById(this.prefix + "-tag"); this.tag = document.getElementById(`${this.prefix}-tag`);
this.tagError = document.getElementById(this.prefix + "-tag-error"); this.tagError = document.getElementById(`${this.prefix}-tag-error`);
// noinspection JSValidateTypes // noinspection JSValidateTypes
this.tagButtons = Array.from(document.getElementsByClassName(this.prefix + "-btn-tag")); this.tagButtons = Array.from(document.getElementsByClassName(`${this.prefix}-btn-tag`));
this.initializeTagButtons(); this.initializeTagButtons();
this.tag.onchange = () => { this.tag.onchange = () => {
this.onTagChange(); this.onTagChange();
@ -414,7 +609,7 @@ class TagTabPlane extends TabPlane {
if (tagButton.dataset.value === this.tag.value) { if (tagButton.dataset.value === this.tag.value) {
tagButton.classList.remove("btn-outline-primary"); tagButton.classList.remove("btn-outline-primary");
tagButton.classList.add("btn-primary"); tagButton.classList.add("btn-primary");
this.editor.filterSuggestedAccounts(tagButton); this.editor.updateCurrentSuggestedAccounts(tagButton);
isMatched = true; isMatched = true;
} else { } else {
tagButton.classList.remove("btn-primary"); tagButton.classList.remove("btn-primary");
@ -442,7 +637,7 @@ class TagTabPlane extends TabPlane {
super.switchToMe(); super.switchToMe();
for (const tagButton of this.tagButtons) { for (const tagButton of this.tagButtons) {
if (tagButton.classList.contains("btn-primary")) { if (tagButton.classList.contains("btn-primary")) {
this.editor.filterSuggestedAccounts(tagButton); this.editor.updateCurrentSuggestedAccounts(tagButton);
return; return;
} }
} }
@ -463,7 +658,7 @@ class TagTabPlane extends TabPlane {
tagButton.classList.remove("btn-outline-primary"); tagButton.classList.remove("btn-outline-primary");
tagButton.classList.add("btn-primary"); tagButton.classList.add("btn-primary");
this.tag.value = tagButton.dataset.value; this.tag.value = tagButton.dataset.value;
this.editor.filterSuggestedAccounts(tagButton); this.editor.updateCurrentSuggestedAccounts(tagButton);
this.updateDescription(); this.updateDescription();
}; };
} }
@ -522,7 +717,7 @@ class TagTabPlane extends TabPlane {
* *
* @private * @private
*/ */
class GeneralTagTab extends TagTabPlane { class DescriptionEditorGeneralTagTab extends DescriptionEditorTagTabPlane {
/** /**
* The tab ID * The tab ID
@ -540,12 +735,12 @@ class GeneralTagTab extends TagTabPlane {
* @override * @override
*/ */
updateDescription() { updateDescription() {
const pos = this.editor.description.value.indexOf("—"); const pos = this.editor.description.indexOf("—");
const prefix = this.tag.value === ""? "": this.tag.value + "—"; const prefix = this.tag.value === ""? "": `${this.tag.value}`;
if (pos === -1) { if (pos === -1) {
this.editor.description.value = prefix + this.editor.description.value; this.editor.description = `${prefix}${this.editor.description}`;
} else { } else {
this.editor.description.value = prefix + this.editor.description.value.substring(pos + 1); this.editor.description = `${prefix}${this.editor.description.substring(pos + 1)}`;
} }
} }
@ -556,7 +751,7 @@ class GeneralTagTab extends TagTabPlane {
* @override * @override
*/ */
populate() { populate() {
const found = this.editor.description.value.match(/^([^—]+)—/); const found = this.editor.description.match(/^([^—]+)—/);
if (found === null) { if (found === null) {
return false; return false;
} }
@ -583,7 +778,7 @@ class GeneralTagTab extends TagTabPlane {
* *
* @private * @private
*/ */
class GeneralTripTab extends TagTabPlane { class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {
/** /**
* The origin * The origin
@ -623,12 +818,12 @@ class GeneralTripTab extends TagTabPlane {
*/ */
constructor(editor) { constructor(editor) {
super(editor); super(editor);
this.#from = document.getElementById(this.prefix + "-from"); this.#from = document.getElementById(`${this.prefix}-from`);
this.#fromError = document.getElementById(this.prefix + "-from-error"); this.#fromError = document.getElementById(`${this.prefix}-from-error`);
this.#to = document.getElementById(this.prefix + "-to"); this.#to = document.getElementById(`${this.prefix}-to`);
this.#toError = document.getElementById(this.prefix + "-to-error") this.#toError = document.getElementById(`${this.prefix}-to-error`)
// noinspection JSValidateTypes // noinspection JSValidateTypes
this.#directionButtons = Array.from(document.getElementsByClassName(this.prefix + "-direction")); this.#directionButtons = Array.from(document.getElementsByClassName(`${this.prefix}-direction`));
this.#from.onchange = () => { this.#from.onchange = () => {
this.#from.value = this.#from.value.trim(); this.#from.value = this.#from.value.trim();
this.updateDescription(); this.updateDescription();
@ -675,7 +870,7 @@ class GeneralTripTab extends TagTabPlane {
break; break;
} }
} }
this.editor.description.value = this.tag.value + "—" + this.#from.value + direction + this.#to.value; this.editor.description = `${this.tag.value}${this.#from.value}${direction}${this.#to.value}`;
} }
/** /**
@ -709,7 +904,7 @@ class GeneralTripTab extends TagTabPlane {
* @override * @override
*/ */
populate() { populate() {
const found = this.editor.description.value.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/); const found = this.editor.description.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
if (found === null) { if (found === null) {
return false; return false;
} }
@ -782,7 +977,7 @@ class GeneralTripTab extends TagTabPlane {
* *
* @private * @private
*/ */
class BusTripTab extends TagTabPlane { class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {
/** /**
* The route * The route
@ -828,12 +1023,12 @@ class BusTripTab extends TagTabPlane {
*/ */
constructor(editor) { constructor(editor) {
super(editor); super(editor);
this.#route = document.getElementById(this.prefix + "-route"); this.#route = document.getElementById(`${this.prefix}-route`);
this.#routeError = document.getElementById(this.prefix + "-route-error"); this.#routeError = document.getElementById(`${this.prefix}-route-error`);
this.#from = document.getElementById(this.prefix + "-from"); this.#from = document.getElementById(`${this.prefix}-from`);
this.#fromError = document.getElementById(this.prefix + "-from-error"); this.#fromError = document.getElementById(`${this.prefix}-from-error`);
this.#to = document.getElementById(this.prefix + "-to"); this.#to = document.getElementById(`${this.prefix}-to`);
this.#toError = document.getElementById(this.prefix + "-to-error") this.#toError = document.getElementById(`${this.prefix}-to-error`)
this.#route.onchange = () => { this.#route.onchange = () => {
this.#route.value = this.#route.value.trim(); this.#route.value = this.#route.value.trim();
this.updateDescription(); this.updateDescription();
@ -867,7 +1062,7 @@ class BusTripTab extends TagTabPlane {
* @override * @override
*/ */
updateDescription() { updateDescription() {
this.editor.description.value = this.tag.value + "—" + this.#route.value + "—" + this.#from.value + "→" + this.#to.value; this.editor.description = `${this.tag.value}${this.#route.value}${this.#from.value}${this.#to.value}`;
} }
/** /**
@ -895,7 +1090,7 @@ class BusTripTab extends TagTabPlane {
* @override * @override
*/ */
populate() { populate() {
const found = this.editor.description.value.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/); const found = this.editor.description.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
if (found === null) { if (found === null) {
return false; return false;
} }
@ -970,7 +1165,7 @@ class BusTripTab extends TagTabPlane {
* *
* @private * @private
*/ */
class RecurringTransactionTab extends TabPlane { class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
/** /**
* The month names * The month names
@ -999,14 +1194,14 @@ class RecurringTransactionTab extends TabPlane {
A_("September"), A_("October"), A_("November"), A_("December"), A_("September"), A_("October"), A_("November"), A_("December"),
]; ];
// noinspection JSValidateTypes // noinspection JSValidateTypes
this.#itemButtons = Array.from(document.getElementsByClassName(this.prefix + "-item")); this.#itemButtons = Array.from(document.getElementsByClassName(`${this.prefix}-item`));
for (const itemButton of this.#itemButtons) { for (const itemButton of this.#itemButtons) {
itemButton.onclick = () => { itemButton.onclick = () => {
this.reset(); this.reset();
itemButton.classList.add("btn-primary"); itemButton.classList.add("btn-primary");
itemButton.classList.remove("btn-outline-primary"); itemButton.classList.remove("btn-outline-primary");
this.editor.description.value = this.#getDescription(itemButton); this.editor.description = this.#getDescription(itemButton);
this.editor.filterSuggestedAccounts(itemButton); this.editor.updateCurrentSuggestedAccounts(itemButton);
}; };
} }
} }
@ -1028,8 +1223,8 @@ class RecurringTransactionTab extends TabPlane {
.replaceAll("{this_month_name}", this.#monthNames[thisMonth]) .replaceAll("{this_month_name}", this.#monthNames[thisMonth])
.replaceAll("{last_month_number}", String(lastMonth)) .replaceAll("{last_month_number}", String(lastMonth))
.replaceAll("{last_month_name}", this.#monthNames[lastMonth]) .replaceAll("{last_month_name}", this.#monthNames[lastMonth])
.replaceAll("{last_bimonthly_number}", String(lastBimonthlyFrom) + "" + String(lastBimonthlyTo)) .replaceAll("{last_bimonthly_number}", `${String(lastBimonthlyFrom)}${String(lastBimonthlyTo)}`)
.replaceAll("{last_bimonthly_name}", this.#monthNames[lastBimonthlyFrom] + "" + this.#monthNames[lastBimonthlyTo]); .replaceAll("{last_bimonthly_name}", `${this.#monthNames[lastBimonthlyFrom]}${this.#monthNames[lastBimonthlyTo]}`);
} }
/** /**
@ -1062,7 +1257,7 @@ class RecurringTransactionTab extends TabPlane {
*/ */
populate() { populate() {
for (const itemButton of this.#itemButtons) { for (const itemButton of this.#itemButtons) {
if (this.#getDescription(itemButton) === this.editor.description.value) { if (this.#getDescription(itemButton) === this.editor.description) {
itemButton.classList.add("btn-primary"); itemButton.classList.add("btn-primary");
itemButton.classList.remove("btn-outline-primary"); itemButton.classList.remove("btn-outline-primary");
this.switchToMe(); this.switchToMe();
@ -1080,7 +1275,7 @@ class RecurringTransactionTab extends TabPlane {
super.switchToMe(); super.switchToMe();
for (const itemButton of this.#itemButtons) { for (const itemButton of this.#itemButtons) {
if (itemButton.classList.contains("btn-primary")) { if (itemButton.classList.contains("btn-primary")) {
this.editor.filterSuggestedAccounts(itemButton); this.editor.updateCurrentSuggestedAccounts(itemButton);
return; return;
} }
} }
@ -1103,7 +1298,7 @@ class RecurringTransactionTab extends TabPlane {
* *
* @private * @private
*/ */
class AnnotationTab extends TabPlane { class DescriptionEditorAnnotationTab extends DescriptionEditorTabPlane {
/** /**
* Constructs a tab plane. * Constructs a tab plane.
@ -1136,15 +1331,15 @@ class AnnotationTab extends TabPlane {
* @override * @override
*/ */
updateDescription() { updateDescription() {
const found = this.editor.description.value.match(/^(.*?)(?:[*×]\d+)?(?:\([^()]+\))?$/); const found = this.editor.description.match(/^(.*?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
if (found !== null) { if (found !== null) {
this.editor.description.value = found[1]; this.editor.description = found[1];
} }
if (parseInt(this.editor.number.value) > 1) { if (parseInt(this.editor.number.value) > 1) {
this.editor.description.value = this.editor.description.value + "×" + this.editor.number.value; this.editor.description = `${this.editor.description}×${this.editor.number.value}`;
} }
if (this.editor.note.value !== "") { if (this.editor.note.value !== "") {
this.editor.description.value = this.editor.description.value + "(" + this.editor.note.value + ")"; this.editor.description = `${this.editor.description}(${this.editor.note.value})`;
} }
} }
@ -1165,19 +1360,19 @@ class AnnotationTab extends TabPlane {
* @override * @override
*/ */
populate() { populate() {
const found = this.editor.description.value.match(/^(.*?)(?:[*×](\d+))?(?:\(([^()]+)\))?$/); const found = this.editor.description.match(/^(.*?)(?:[*×](\d+))?(?:\(([^()]+)\))?$/);
this.editor.description.value = found[1]; this.editor.description = found[1];
if (found[2] === undefined || parseInt(found[2]) === 1) { if (found[2] === undefined || parseInt(found[2]) === 1) {
this.editor.number.value = ""; this.editor.number.value = "";
} else { } else {
this.editor.number.value = found[2]; this.editor.number.value = found[2];
this.editor.description.value = this.editor.description.value + "×" + this.editor.number.value; this.editor.description = `${this.editor.description}×${this.editor.number.value}`;
} }
if (found[3] === undefined) { if (found[3] === undefined) {
this.editor.note.value = ""; this.editor.note.value = "";
} else { } else {
this.editor.note.value = found[3]; this.editor.note.value = found[3];
this.editor.description.value = this.editor.description.value + "(" + this.editor.note.value + ")"; this.editor.description = `${this.editor.description}(${this.editor.note.value})`;
} }
return true; return true;
} }

View File

@ -1,4 +1,4 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Project
* drag-and-drop-reorder.js: The JavaScript for the reorder a list with drag-and-drop * drag-and-drop-reorder.js: The JavaScript for the reorder a list with drag-and-drop
*/ */

View File

@ -1,4 +1,4 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Project
* journal-entry-account-selector.js: The JavaScript for the account selector of the journal entry form * journal-entry-account-selector.js: The JavaScript for the account selector of the journal entry form
*/ */
@ -91,13 +91,13 @@ class JournalEntryAccountSelector {
constructor(lineItemEditor, debitCredit) { constructor(lineItemEditor, debitCredit) {
this.lineItemEditor = lineItemEditor this.lineItemEditor = lineItemEditor
this.#debitCredit = debitCredit; this.#debitCredit = debitCredit;
const prefix = "accounting-account-selector-" + debitCredit; const prefix = `accounting-account-selector-${debitCredit}`;
this.#query = document.getElementById(prefix + "-query"); this.#query = document.getElementById(`${prefix}-query`);
this.#queryNoResult = document.getElementById(prefix + "-option-no-result"); this.#queryNoResult = document.getElementById(`${prefix}-option-no-result`);
this.#optionList = document.getElementById(prefix + "-option-list"); this.#optionList = document.getElementById(`${prefix}-option-list`);
this.#options = Array.from(document.getElementsByClassName(prefix + "-option")).map((element) => new JournalEntryAccountOption(this, element)); this.#options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new JournalEntryAccountOption(this, element));
this.#more = document.getElementById(prefix + "-more"); this.#more = document.getElementById(`${prefix}-more`);
this.#clearButton = document.getElementById(prefix + "-btn-clear"); this.#clearButton = document.getElementById(`${prefix}-btn-clear`);
this.#more.onclick = () => { this.#more.onclick = () => {
this.#isShowMore = true; this.#isShowMore = true;
@ -123,7 +123,7 @@ class JournalEntryAccountSelector {
option.setShown(false); option.setShown(false);
} }
} }
if (!isAnyMatched) { if (!isAnyMatched && this.#isShowMore) {
this.#optionList.classList.add("d-none"); this.#optionList.classList.add("d-none");
this.#queryNoResult.classList.remove("d-none"); this.#queryNoResult.classList.remove("d-none");
} else { } else {
@ -139,8 +139,8 @@ class JournalEntryAccountSelector {
*/ */
#getCodesUsedInForm() { #getCodesUsedInForm() {
const inUse = this.lineItemEditor.form.getAccountCodesUsed(this.#debitCredit); const inUse = this.lineItemEditor.form.getAccountCodesUsed(this.#debitCredit);
if (this.lineItemEditor.accountCode !== null) { if (this.lineItemEditor.account !== null) {
inUse.push(this.lineItemEditor.accountCode); inUse.push(this.lineItemEditor.account.code);
} }
return inUse return inUse
} }
@ -155,9 +155,9 @@ class JournalEntryAccountSelector {
this.#more.classList.remove("d-none"); this.#more.classList.remove("d-none");
this.#filterOptions(); this.#filterOptions();
for (const option of this.#options) { for (const option of this.#options) {
option.setActive(option.code === this.lineItemEditor.accountCode); option.setActive(this.lineItemEditor.account !== null && option.code === this.lineItemEditor.account.code);
} }
if (this.lineItemEditor.accountCode === null) { if (this.lineItemEditor.account === null) {
this.#clearButton.classList.add("btn-secondary"); this.#clearButton.classList.add("btn-secondary");
this.#clearButton.classList.remove("btn-danger"); this.#clearButton.classList.remove("btn-danger");
this.#clearButton.disabled = true; this.#clearButton.disabled = true;
@ -190,12 +190,6 @@ class JournalEntryAccountSelector {
*/ */
class JournalEntryAccountOption { class JournalEntryAccountOption {
/**
* The account selector
* @type {JournalEntryAccountSelector}
*/
#selector;
/** /**
* The element * The element
* @type {HTMLLIElement} * @type {HTMLLIElement}
@ -239,7 +233,6 @@ class JournalEntryAccountOption {
* @param element {HTMLLIElement} the element * @param element {HTMLLIElement} the element
*/ */
constructor(selector, element) { constructor(selector, element) {
this.#selector = selector;
this.#element = element; this.#element = element;
this.code = element.dataset.code; this.code = element.dataset.code;
this.text = element.dataset.text; this.text = element.dataset.text;
@ -247,7 +240,7 @@ class JournalEntryAccountOption {
this.isNeedOffset = element.classList.contains("accounting-account-is-need-offset"); this.isNeedOffset = element.classList.contains("accounting-account-is-need-offset");
this.#queryValues = JSON.parse(element.dataset.queryValues); this.#queryValues = JSON.parse(element.dataset.queryValues);
this.#element.onclick = () => this.#selector.lineItemEditor.saveAccount(this); this.#element.onclick = () => selector.lineItemEditor.saveAccount(this);
} }
/** /**

View File

@ -1,4 +1,4 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Project
* journal-entry-form.js: The JavaScript for the journal entry form * journal-entry-form.js: The JavaScript for the journal entry form
*/ */
@ -128,7 +128,7 @@ class JournalEntryForm {
const html = this.#element.dataset.currencyTemplate const html = this.#element.dataset.currencyTemplate
.replaceAll("CURRENCY_INDEX", escapeHtml(String(newIndex))); .replaceAll("CURRENCY_INDEX", escapeHtml(String(newIndex)));
this.#currencyList.insertAdjacentHTML("beforeend", html); this.#currencyList.insertAdjacentHTML("beforeend", html);
const element = document.getElementById("accounting-currency-" + String(newIndex)); const element = document.getElementById(`accounting-currency-${String(newIndex)}`);
this.#currencies.push(new CurrencySubForm(this, element)); this.#currencies.push(new CurrencySubForm(this, element));
this.#resetDeleteCurrencyButtons(); this.#resetDeleteCurrencyButtons();
this.#initializeDragAndDropReordering(); this.#initializeDragAndDropReordering();
@ -207,8 +207,8 @@ class JournalEntryForm {
* @return {string[]} the account codes used in the form * @return {string[]} the account codes used in the form
*/ */
getAccountCodesUsed(debitCredit) { getAccountCodesUsed(debitCredit) {
return this.getLineItems(debitCredit).map((lineItem) => lineItem.accountCode) return this.getLineItems(debitCredit).filter((lineItem) => lineItem.account !== null)
.filter((code) => code !== null); .map((lineItem) => lineItem.account.code);
} }
/** /**
@ -415,16 +415,16 @@ class CurrencySubForm {
this.#element = element; this.#element = element;
this.form = form; this.form = form;
this.index = parseInt(this.#element.dataset.index); this.index = parseInt(this.#element.dataset.index);
const prefix = "accounting-currency-" + String(this.index); const prefix = `accounting-currency-${String(this.index)}`;
this.#control = document.getElementById(prefix + "-control"); this.#control = document.getElementById(`${prefix}-control`);
this.#error = document.getElementById(prefix + "-error"); this.#error = document.getElementById(`${prefix}-error`);
this.#no = document.getElementById(prefix + "-no"); this.#no = document.getElementById(`${prefix}-no`);
this.#code = document.getElementById(prefix + "-code"); this.#code = document.getElementById(`${prefix}-code`);
this.#codeSelect = document.getElementById(prefix + "-code-select"); this.#codeSelect = document.getElementById(`${prefix}-code-select`);
this.#deleteButton = document.getElementById(prefix + "-delete"); this.#deleteButton = document.getElementById(`${prefix}-delete`);
const debitElement = document.getElementById(prefix + "-debit"); const debitElement = document.getElementById(`${prefix}-debit`);
this.#debit = debitElement === null? null: new DebitCreditSubForm(this, debitElement, "debit"); this.#debit = debitElement === null? null: new DebitCreditSubForm(this, debitElement, "debit");
const creditElement = document.getElementById(prefix + "-credit"); const creditElement = document.getElementById(`${prefix}-credit`);
this.#credit = creditElement == null? null: new DebitCreditSubForm(this, creditElement, "credit"); this.#credit = creditElement == null? null: new DebitCreditSubForm(this, creditElement, "credit");
this.#codeSelect.onchange = () => this.#code.value = this.#codeSelect.value; this.#codeSelect.onchange = () => this.#code.value = this.#codeSelect.value;
this.#deleteButton.onclick = () => { this.#deleteButton.onclick = () => {
@ -457,11 +457,7 @@ class CurrencySubForm {
* @param isShown {boolean} true to show, or false otherwise * @param isShown {boolean} true to show, or false otherwise
*/ */
setDeleteButtonShown(isShown) { setDeleteButtonShown(isShown) {
if (isShown) { setElementShown(this.#deleteButton, isShown);
this.#deleteButton.classList.remove("d-none");
} else {
this.#deleteButton.classList.add("d-none");
}
} }
/** /**
@ -617,13 +613,13 @@ class DebitCreditSubForm {
this.#element = element; this.#element = element;
this.#currencyIndex = currency.index; this.#currencyIndex = currency.index;
this.debitCredit = debitCredit; this.debitCredit = debitCredit;
this.#prefix = "accounting-currency-" + String(this.#currencyIndex) + "-" + debitCredit; this.#prefix = `accounting-currency-${String(this.#currencyIndex)}-${debitCredit}`;
this.#content = document.getElementById(this.#prefix + "-content"); this.#content = document.getElementById(`${this.#prefix}-content`);
this.#error = document.getElementById(this.#prefix + "-error"); this.#error = document.getElementById(`${this.#prefix}-error`);
this.#lineItemList = document.getElementById(this.#prefix + "-list"); this.#lineItemList = document.getElementById(`${this.#prefix}-list`);
this.lineItems = Array.from(document.getElementsByClassName(this.#prefix)).map((element) => new LineItemSubForm(this, element)); this.lineItems = Array.from(document.getElementsByClassName(this.#prefix)).map((element) => new LineItemSubForm(this, element));
this.#total = document.getElementById(this.#prefix + "-total"); this.#total = document.getElementById(`${this.#prefix}-total`);
this.#addLineItemButton = document.getElementById(this.#prefix + "-add-line-item"); this.#addLineItemButton = document.getElementById(`${this.#prefix}-add-line-item`);
this.#resetContent(); this.#resetContent();
this.#addLineItemButton.onclick = () => this.currency.form.lineItemEditor.onAddNew(this); this.#addLineItemButton.onclick = () => this.currency.form.lineItemEditor.onAddNew(this);
@ -653,7 +649,7 @@ class DebitCreditSubForm {
.replaceAll("DEBIT_CREDIT", escapeHtml(this.debitCredit)) .replaceAll("DEBIT_CREDIT", escapeHtml(this.debitCredit))
.replaceAll("LINE_ITEM_INDEX", escapeHtml(String(newIndex))); .replaceAll("LINE_ITEM_INDEX", escapeHtml(String(newIndex)));
this.#lineItemList.insertAdjacentHTML("beforeend", html); this.#lineItemList.insertAdjacentHTML("beforeend", html);
const lineItem = new LineItemSubForm(this, document.getElementById(this.#prefix + "-" + String(newIndex))); const lineItem = new LineItemSubForm(this, document.getElementById(`${this.#prefix}-${String(newIndex)}`));
this.lineItems.push(lineItem); this.lineItems.push(lineItem);
this.#resetContent(); this.#resetContent();
this.#resetDeleteLineItemButtons(); this.#resetDeleteLineItemButtons();
@ -700,20 +696,19 @@ class DebitCreditSubForm {
this.#element.classList.remove("accounting-not-empty"); this.#element.classList.remove("accounting-not-empty");
this.#element.classList.add("accounting-clickable"); this.#element.classList.add("accounting-clickable");
this.#element.dataset.bsToggle = "modal" this.#element.dataset.bsToggle = "modal"
this.#element.dataset.bsTarget = "#" + this.currency.form.lineItemEditor.modal.id; this.#element.dataset.bsTarget = `#${this.currency.form.lineItemEditor.modal.id}`;
this.#element.onclick = () => { this.#element.onclick = () => {
this.#element.classList.add("accounting-not-empty"); this.#element.classList.add("accounting-not-empty");
this.currency.form.lineItemEditor.onAddNew(this); this.currency.form.lineItemEditor.onAddNew(this);
}; };
this.#content.classList.add("d-none");
} else { } else {
this.#element.classList.add("accounting-not-empty"); this.#element.classList.add("accounting-not-empty");
this.#element.classList.remove("accounting-clickable"); this.#element.classList.remove("accounting-clickable");
delete this.#element.dataset.bsToggle; delete this.#element.dataset.bsToggle;
delete this.#element.dataset.bsTarget; delete this.#element.dataset.bsTarget;
this.#element.onclick = null; this.#element.onclick = null;
this.#content.classList.remove("d-none");
} }
setElementShown(this.#content, this.lineItems.length !== 0);
} }
/** /**
@ -784,6 +779,53 @@ class DebitCreditSubForm {
} }
} }
/**
* A journal entry account.
*
*/
class JournalEntryAccount {
/**
* The account code
* @type {string}
*/
code;
/**
* The account text
* @type {string}
*/
text;
/**
* Whether the line items in the account needs offset
* @type {boolean}
*/
isNeedOffset;
/**
* Constructs a journal entry account.
*
* @param code {string} the account code
* @param text {string} the account text
* @param isNeedOffset {boolean} true if the line items in the account needs offset, or false otherwise
*/
constructor(code, text, isNeedOffset) {
this.code = code;
this.text = text;
this.isNeedOffset = isNeedOffset;
}
/**
* Returns a copy of the account.
*
* @return {JournalEntryAccount} the copy of the account
*/
copy() {
return new JournalEntryAccount(this.code, this.text, this.isNeedOffset);
}
}
/** /**
* The line item sub-form. * The line item sub-form.
* *
@ -910,20 +952,20 @@ class LineItemSubForm {
this.debitCredit = element.dataset.debitCredit; this.debitCredit = element.dataset.debitCredit;
this.index = parseInt(element.dataset.lineItemIndex); this.index = parseInt(element.dataset.lineItemIndex);
this.isMatched = element.classList.contains("accounting-matched-line-item"); this.isMatched = element.classList.contains("accounting-matched-line-item");
const prefix = "accounting-currency-" + element.dataset.currencyIndex + "-" + this.debitCredit + "-" + String(this.index); const prefix = `accounting-currency-${element.dataset.currencyIndex}-${this.debitCredit}-${String(this.index)}`;
this.#control = document.getElementById(prefix + "-control"); this.#control = document.getElementById(`${prefix}-control`);
this.#error = document.getElementById(prefix + "-error"); this.#error = document.getElementById(`${prefix}-error`);
this.#no = document.getElementById(prefix + "-no"); this.#no = document.getElementById(`${prefix}-no`);
this.#accountCode = document.getElementById(prefix + "-account-code"); this.#accountCode = document.getElementById(`${prefix}-account-code`);
this.#accountText = document.getElementById(prefix + "-account-text"); this.#accountText = document.getElementById(`${prefix}-account-text`);
this.#description = document.getElementById(prefix + "-description"); this.#description = document.getElementById(`${prefix}-description`);
this.#descriptionText = document.getElementById(prefix + "-description-text"); this.#descriptionText = document.getElementById(`${prefix}-description-text`);
this.#originalLineItemId = document.getElementById(prefix + "-original-line-item-id"); this.#originalLineItemId = document.getElementById(`${prefix}-original-line-item-id`);
this.#originalLineItemText = document.getElementById(prefix + "-original-line-item-text"); this.#originalLineItemText = document.getElementById(`${prefix}-original-line-item-text`);
this.#offsets = document.getElementById(prefix + "-offsets"); this.#offsets = document.getElementById(`${prefix}-offsets`);
this.#amount = document.getElementById(prefix + "-amount"); this.#amount = document.getElementById(`${prefix}-amount`);
this.#amountText = document.getElementById(prefix + "-amount-text"); this.#amountText = document.getElementById(`${prefix}-amount-text`);
this.#deleteButton = document.getElementById(prefix + "-delete"); this.#deleteButton = document.getElementById(`${prefix}-delete`);
this.#control.onclick = () => this.debitCreditSubForm.currency.form.lineItemEditor.onEdit(this); this.#control.onclick = () => this.debitCreditSubForm.currency.form.lineItemEditor.onEdit(this);
this.#deleteButton.onclick = () => { this.#deleteButton.onclick = () => {
this.#element.parentElement.removeChild(this.#element); this.#element.parentElement.removeChild(this.#element);
@ -940,15 +982,6 @@ class LineItemSubForm {
this.#no.value = String(siblings.indexOf(this.#element) + 1); this.#no.value = String(siblings.indexOf(this.#element) + 1);
} }
/**
* Returns whether the line item needs offset.
*
* @return {boolean} true if the line item needs offset, or false otherwise
*/
get isNeedOffset() {
return "isNeedOffset" in this.#element.dataset;
}
/** /**
* Returns the ID of the original line item. * Returns the ID of the original line item.
* *
@ -986,21 +1019,12 @@ class LineItemSubForm {
} }
/** /**
* Returns the account code. * Returns the account.
* *
* @return {string|null} the account code * @return {JournalEntryAccount|null} the account
*/ */
get accountCode() { get account() {
return this.#accountCode.value === ""? null: this.#accountCode.value; return this.#accountCode.value === null? null: new JournalEntryAccount(this.#accountCode.value, this.#accountCode.dataset.text, this.#accountCode.classList.contains("accounting-account-is-need-offset"));
}
/**
* Returns the account text.
*
* @return {string|null} the account text
*/
get accountText() {
return this.#accountCode.dataset.text === ""? null: this.#accountCode.dataset.text;
} }
/** /**
@ -1027,11 +1051,7 @@ class LineItemSubForm {
* @param isShown {boolean} true to show, or false otherwise * @param isShown {boolean} true to show, or false otherwise
*/ */
setDeleteButtonShown(isShown) { setDeleteButtonShown(isShown) {
if (isShown) { setElementShown(this.#deleteButton, isShown);
this.#deleteButton.classList.remove("d-none");
} else {
this.#deleteButton.classList.add("d-none");
}
} }
/** /**
@ -1061,24 +1081,24 @@ class LineItemSubForm {
* @param editor {JournalEntryLineItemEditor} the line item editor * @param editor {JournalEntryLineItemEditor} the line item editor
*/ */
save(editor) { save(editor) {
if (editor.isNeedOffset) { setElementShown(this.#offsets, editor.account.isNeedOffset);
this.#offsets.classList.remove("d-none");
} else {
this.#offsets.classList.add("d-none");
}
this.#originalLineItemId.value = editor.originalLineItemId === null? "": editor.originalLineItemId; this.#originalLineItemId.value = editor.originalLineItemId === null? "": editor.originalLineItemId;
this.#originalLineItemId.dataset.date = editor.originalLineItemDate === null? "": editor.originalLineItemDate; this.#originalLineItemId.dataset.date = editor.originalLineItemDate === null? "": editor.originalLineItemDate;
this.#originalLineItemId.dataset.text = editor.originalLineItemText === null? "": editor.originalLineItemText; this.#originalLineItemId.dataset.text = editor.originalLineItemText === null? "": editor.originalLineItemText;
setElementShown(this.#originalLineItemText, editor.originalLineItemText !== null);
if (editor.originalLineItemText === null) { if (editor.originalLineItemText === null) {
this.#originalLineItemText.classList.add("d-none");
this.#originalLineItemText.innerText = ""; this.#originalLineItemText.innerText = "";
} else { } else {
this.#originalLineItemText.classList.remove("d-none");
this.#originalLineItemText.innerText = A_("Offset %(item)s", {item: editor.originalLineItemText}); this.#originalLineItemText.innerText = A_("Offset %(item)s", {item: editor.originalLineItemText});
} }
this.#accountCode.value = editor.accountCode === null? "": editor.accountCode; this.#accountCode.value = editor.account.code;
this.#accountCode.dataset.text = editor.accountText === null? "": editor.accountText; this.#accountCode.dataset.text = editor.account.text;
this.#accountText.innerText = editor.accountText === null? "": editor.accountText; if (editor.account.isNeedOffset) {
this.#accountCode.classList.add("accounting-account-is-need-offset");
} else {
this.#accountCode.classList.remove("accounting-account-is-need-offset");
}
this.#accountText.innerText = editor.account.text;
this.#description.value = editor.description === null? "": editor.description; this.#description.value = editor.description === null? "": editor.description;
this.#descriptionText.innerText = editor.description === null? "": editor.description; this.#descriptionText.innerText = editor.description === null? "": editor.description;
this.#amount.value = editor.amount; this.#amount.value = editor.amount;
@ -1119,3 +1139,18 @@ function formatDecimal(number) {
const whole = Number(number.minus(frac)).toLocaleString(); const whole = Number(number.minus(frac)).toLocaleString();
return whole + String(frac).substring(1); return whole + String(frac).substring(1);
} }
/**
* Sets whether an element is shown.
*
* @param element {HTMLElement} the element
* @param isShown {boolean} true to show, or false otherwise
* @private
*/
function setElementShown(element, isShown) {
if (isShown) {
element.classList.remove("d-none");
} else {
element.classList.add("d-none");
}
}

View File

@ -1,4 +1,4 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Project
* journal-entry-line-item-editor.js: The JavaScript for the journal entry line item editor * journal-entry-line-item-editor.js: The JavaScript for the journal entry line item editor
*/ */
@ -148,12 +148,6 @@ class JournalEntryLineItemEditor {
*/ */
#debitCreditSubForm; #debitCreditSubForm;
/**
* Whether the journal entry line item needs offset
* @type {boolean}
*/
isNeedOffset = false;
/** /**
* The ID of the original line item * The ID of the original line item
* @type {string|null} * @type {string|null}
@ -173,16 +167,16 @@ class JournalEntryLineItemEditor {
originalLineItemText = null; originalLineItemText = null;
/** /**
* The account code * The account
* @type {string|null} * @type {JournalEntryAccount|null}
*/ */
accountCode = null; account = null;
/** /**
* The account text * Whether the user has confirmed the account
* @type {string|null} * @type {boolean}
*/ */
accountText = null; isAccountConfirmed = false;
/** /**
* The description * The description
@ -216,20 +210,20 @@ class JournalEntryLineItemEditor {
constructor(form) { constructor(form) {
this.form = form; this.form = form;
this.#element = document.getElementById(this.#prefix); this.#element = document.getElementById(this.#prefix);
this.modal = document.getElementById(this.#prefix + "-modal"); this.modal = document.getElementById(`${this.#prefix}-modal`);
this.#originalLineItemContainer = document.getElementById(this.#prefix + "-original-line-item-container"); this.#originalLineItemContainer = document.getElementById(`${this.#prefix}-original-line-item-container`);
this.#originalLineItemControl = document.getElementById(this.#prefix + "-original-line-item-control"); this.#originalLineItemControl = document.getElementById(`${this.#prefix}-original-line-item-control`);
this.#originalLineItemText = document.getElementById(this.#prefix + "-original-line-item"); this.#originalLineItemText = document.getElementById(`${this.#prefix}-original-line-item`);
this.#originalLineItemError = document.getElementById(this.#prefix + "-original-line-item-error"); this.#originalLineItemError = document.getElementById(`${this.#prefix}-original-line-item-error`);
this.#originalLineItemDelete = document.getElementById(this.#prefix + "-original-line-item-delete"); this.#originalLineItemDelete = document.getElementById(`${this.#prefix}-original-line-item-delete`);
this.#descriptionControl = document.getElementById(this.#prefix + "-description-control"); this.#descriptionControl = document.getElementById(`${this.#prefix}-description-control`);
this.#descriptionText = document.getElementById(this.#prefix + "-description"); this.#descriptionText = document.getElementById(`${this.#prefix}-description`);
this.#descriptionError = document.getElementById(this.#prefix + "-description-error"); this.#descriptionError = document.getElementById(`${this.#prefix}-description-error`);
this.#accountControl = document.getElementById(this.#prefix + "-account-control"); this.#accountControl = document.getElementById(`${this.#prefix}-account-control`);
this.#accountText = document.getElementById(this.#prefix + "-account"); this.#accountText = document.getElementById(`${this.#prefix}-account`);
this.#accountError = document.getElementById(this.#prefix + "-account-error") this.#accountError = document.getElementById(`${this.#prefix}-account-error`)
this.#amountInput = document.getElementById(this.#prefix + "-amount"); this.#amountInput = document.getElementById(`${this.#prefix}-amount`);
this.#amountError = document.getElementById(this.#prefix + "-amount-error"); this.#amountError = document.getElementById(`${this.#prefix}-amount-error`);
this.#descriptionEditors = DescriptionEditor.getInstances(this); this.#descriptionEditors = DescriptionEditor.getInstances(this);
this.#accountSelectors = JournalEntryAccountSelector.getInstances(this); this.#accountSelectors = JournalEntryAccountSelector.getInstances(this);
this.originalLineItemSelector = new OriginalLineItemSelector(this); this.originalLineItemSelector = new OriginalLineItemSelector(this);
@ -276,7 +270,6 @@ class JournalEntryLineItemEditor {
* @param originalLineItem {OriginalLineItem} the original line item * @param originalLineItem {OriginalLineItem} the original line item
*/ */
saveOriginalLineItem(originalLineItem) { saveOriginalLineItem(originalLineItem) {
this.isNeedOffset = false;
this.#originalLineItemContainer.classList.remove("d-none"); this.#originalLineItemContainer.classList.remove("d-none");
this.#originalLineItemControl.classList.add("accounting-not-empty"); this.#originalLineItemControl.classList.add("accounting-not-empty");
this.originalLineItemId = originalLineItem.id; this.originalLineItemId = originalLineItem.id;
@ -292,9 +285,9 @@ class JournalEntryLineItemEditor {
this.description = originalLineItem.description === ""? null: originalLineItem.description; this.description = originalLineItem.description === ""? null: originalLineItem.description;
this.#descriptionText.innerText = originalLineItem.description; this.#descriptionText.innerText = originalLineItem.description;
this.#accountControl.classList.add("accounting-not-empty"); this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = originalLineItem.accountCode; this.account = originalLineItem.account.copy();
this.accountText = originalLineItem.accountText; this.isAccountConfirmed = false;
this.#accountText.innerText = originalLineItem.accountText; this.#accountText.innerText = this.account.text;
this.#amountInput.value = String(originalLineItem.netBalance); this.#amountInput.value = String(originalLineItem.netBalance);
this.#amountInput.max = String(originalLineItem.netBalance); this.#amountInput.max = String(originalLineItem.netBalance);
this.#amountInput.min = "0"; this.#amountInput.min = "0";
@ -306,7 +299,6 @@ class JournalEntryLineItemEditor {
* *
*/ */
clearOriginalLineItem() { clearOriginalLineItem() {
this.isNeedOffset = false;
this.#originalLineItemContainer.classList.add("d-none"); this.#originalLineItemContainer.classList.add("d-none");
this.#originalLineItemControl.classList.remove("accounting-not-empty"); this.#originalLineItemControl.classList.remove("accounting-not-empty");
this.originalLineItemId = null; this.originalLineItemId = null;
@ -315,8 +307,8 @@ class JournalEntryLineItemEditor {
this.#originalLineItemText.innerText = ""; this.#originalLineItemText.innerText = "";
this.#setEnableDescriptionAccount(true); this.#setEnableDescriptionAccount(true);
this.#accountControl.classList.remove("accounting-not-empty"); this.#accountControl.classList.remove("accounting-not-empty");
this.accountCode = null; this.account = null;
this.accountText = null; this.isAccountConfirmed = false;
this.#accountText.innerText = ""; this.#accountText.innerText = "";
this.#amountInput.max = ""; this.#amountInput.max = "";
} }
@ -324,47 +316,35 @@ class JournalEntryLineItemEditor {
/** /**
* Saves the description from the description editor. * Saves the description from the description editor.
* *
* @param description {string} the description * @param editor {DescriptionEditor} the description editor
*/ */
saveDescription(description) { saveDescription(editor) {
if (description === "") { if (editor.selectedAccount !== null) {
this.#accountControl.classList.add("accounting-not-empty");
this.account = editor.selectedAccount.copy();
this.#accountText.innerText = editor.selectedAccount.text;
this.isAccountConfirmed = editor.isAccountConfirmed;
this.#validateAccount();
}
if (editor.description === "") {
this.#descriptionControl.classList.remove("accounting-not-empty"); this.#descriptionControl.classList.remove("accounting-not-empty");
} else { } else {
this.#descriptionControl.classList.add("accounting-not-empty"); this.#descriptionControl.classList.add("accounting-not-empty");
} }
this.description = description === ""? null: description; this.description = editor.description === ""? null: editor.description;
this.#descriptionText.innerText = description; this.#descriptionText.innerText = editor.description;
this.#validateDescription(); this.#validateDescription();
bootstrap.Modal.getOrCreateInstance(this.modal).show(); bootstrap.Modal.getOrCreateInstance(this.modal).show();
} }
/**
* Saves the description with the suggested account from the description editor.
*
* @param description {string} the description
* @param accountCode {string} the account code
* @param accountText {string} the account text
* @param isAccountNeedOffset {boolean} true if the line items in the account need offset, or false otherwise
*/
saveDescriptionWithAccount(description, accountCode, accountText, isAccountNeedOffset) {
this.isNeedOffset = isAccountNeedOffset;
this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = accountCode;
this.accountText = accountText;
this.#accountText.innerText = accountText;
this.#validateAccount();
this.saveDescription(description)
}
/** /**
* Clears the account. * Clears the account.
* *
*/ */
clearAccount() { clearAccount() {
this.isNeedOffset = false;
this.#accountControl.classList.remove("accounting-not-empty"); this.#accountControl.classList.remove("accounting-not-empty");
this.accountCode = null; this.account = null;
this.accountText = null; this.isAccountConfirmed = false;
this.#accountText.innerText = ""; this.#accountText.innerText = "";
this.#validateAccount(); this.#validateAccount();
} }
@ -375,10 +355,9 @@ class JournalEntryLineItemEditor {
* @param account {JournalEntryAccountOption} the selected account * @param account {JournalEntryAccountOption} the selected account
*/ */
saveAccount(account) { saveAccount(account) {
this.isNeedOffset = account.isNeedOffset;
this.#accountControl.classList.add("accounting-not-empty"); this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = account.code; this.account = new JournalEntryAccount(account.code, account.text, account.isNeedOffset);
this.accountText = account.text; this.isAccountConfirmed = true;
this.#accountText.innerText = account.text; this.#accountText.innerText = account.text;
this.#validateAccount(); this.#validateAccount();
} }
@ -427,7 +406,7 @@ class JournalEntryLineItemEditor {
* @return {boolean} true if valid, or false otherwise * @return {boolean} true if valid, or false otherwise
*/ */
#validateAccount() { #validateAccount() {
if (this.accountCode === null) { if (this.account === null) {
this.#accountControl.classList.add("is-invalid"); this.#accountControl.classList.add("is-invalid");
this.#accountError.innerText = A_("Please select the account."); this.#accountError.innerText = A_("Please select the account.");
return false; return false;
@ -486,7 +465,6 @@ class JournalEntryLineItemEditor {
this.lineItem = null; this.lineItem = null;
this.#debitCreditSubForm = debitCredit; this.#debitCreditSubForm = debitCredit;
this.debitCredit = this.#debitCreditSubForm.debitCredit; this.debitCredit = this.#debitCreditSubForm.debitCredit;
this.isNeedOffset = false;
this.#originalLineItemContainer.classList.add("d-none"); this.#originalLineItemContainer.classList.add("d-none");
this.#originalLineItemControl.classList.remove("accounting-not-empty"); this.#originalLineItemControl.classList.remove("accounting-not-empty");
this.#originalLineItemControl.classList.remove("is-invalid"); this.#originalLineItemControl.classList.remove("is-invalid");
@ -502,8 +480,8 @@ class JournalEntryLineItemEditor {
this.#descriptionError.innerText = "" this.#descriptionError.innerText = ""
this.#accountControl.classList.remove("accounting-not-empty"); this.#accountControl.classList.remove("accounting-not-empty");
this.#accountControl.classList.remove("is-invalid"); this.#accountControl.classList.remove("is-invalid");
this.accountCode = null; this.account = null;
this.accountText = null; this.isAccountConfirmed = false;
this.#accountText.innerText = ""; this.#accountText.innerText = "";
this.#accountError.innerText = ""; this.#accountError.innerText = "";
this.#amountInput.value = ""; this.#amountInput.value = "";
@ -522,7 +500,6 @@ class JournalEntryLineItemEditor {
this.lineItem = lineItem; this.lineItem = lineItem;
this.#debitCreditSubForm = lineItem.debitCreditSubForm; this.#debitCreditSubForm = lineItem.debitCreditSubForm;
this.debitCredit = this.#debitCreditSubForm.debitCredit; this.debitCredit = this.#debitCreditSubForm.debitCredit;
this.isNeedOffset = lineItem.isNeedOffset;
this.originalLineItemId = lineItem.originalLineItemId; this.originalLineItemId = lineItem.originalLineItemId;
this.originalLineItemDate = lineItem.originalLineItemDate; this.originalLineItemDate = lineItem.originalLineItemDate;
this.originalLineItemText = lineItem.originalLineItemText; this.originalLineItemText = lineItem.originalLineItemText;
@ -542,14 +519,14 @@ class JournalEntryLineItemEditor {
this.#descriptionControl.classList.add("accounting-not-empty"); this.#descriptionControl.classList.add("accounting-not-empty");
} }
this.#descriptionText.innerText = this.description === null? "": this.description; this.#descriptionText.innerText = this.description === null? "": this.description;
if (lineItem.accountCode === null) { this.account = lineItem.account;
this.isAccountConfirmed = true;
if (this.account === null) {
this.#accountControl.classList.remove("accounting-not-empty"); this.#accountControl.classList.remove("accounting-not-empty");
} else { } else {
this.#accountControl.classList.add("accounting-not-empty"); this.#accountControl.classList.add("accounting-not-empty");
} }
this.accountCode = lineItem.accountCode; this.#accountText.innerText = this.account.text;
this.accountText = lineItem.accountText;
this.#accountText.innerText = this.accountText;
this.#amountInput.value = lineItem.amount === null? "": String(lineItem.amount); this.#amountInput.value = lineItem.amount === null? "": String(lineItem.amount);
const maxAmount = this.#getMaxAmount(); const maxAmount = this.#getMaxAmount();
this.#amountInput.max = maxAmount === null? "": maxAmount; this.#amountInput.max = maxAmount === null? "": maxAmount;
@ -577,11 +554,11 @@ class JournalEntryLineItemEditor {
#setEnableDescriptionAccount(isEnabled) { #setEnableDescriptionAccount(isEnabled) {
if (isEnabled) { if (isEnabled) {
this.#descriptionControl.dataset.bsToggle = "modal"; this.#descriptionControl.dataset.bsToggle = "modal";
this.#descriptionControl.dataset.bsTarget = "#accounting-description-editor-" + this.#debitCreditSubForm.debitCredit + "-modal"; this.#descriptionControl.dataset.bsTarget = `#accounting-description-editor-${this.#debitCreditSubForm.debitCredit}-modal`;
this.#descriptionControl.classList.remove("accounting-disabled"); this.#descriptionControl.classList.remove("accounting-disabled");
this.#descriptionControl.classList.add("accounting-clickable"); this.#descriptionControl.classList.add("accounting-clickable");
this.#accountControl.dataset.bsToggle = "modal"; this.#accountControl.dataset.bsToggle = "modal";
this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + this.#debitCreditSubForm.debitCredit + "-modal"; this.#accountControl.dataset.bsTarget = `#accounting-account-selector-${this.#debitCreditSubForm.debitCredit}-modal`;
this.#accountControl.classList.remove("accounting-disabled"); this.#accountControl.classList.remove("accounting-disabled");
this.#accountControl.classList.add("accounting-clickable"); this.#accountControl.classList.add("accounting-clickable");
} else { } else {
@ -596,3 +573,4 @@ class JournalEntryLineItemEditor {
} }
} }
} }

View File

@ -1,4 +1,4 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Project
* journal-entry-order.js: The JavaScript for the journal entry order * journal-entry-order.js: The JavaScript for the journal entry order
*/ */
@ -29,7 +29,7 @@ document.addEventListener("DOMContentLoaded", () => {
const onReorder = () => { const onReorder = () => {
const accounts = Array.from(list.children); const accounts = Array.from(list.children);
for (let i = 0; i < accounts.length; i++) { for (let i = 0; i < accounts.length; i++) {
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no"); const no = document.getElementById(`accounting-order-${accounts[i].dataset.id}-no`);
no.value = String(i + 1); no.value = String(i + 1);
} }
}; };

View File

@ -1,4 +1,4 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Project
* material-fab-speed-dial.js: The JavaScript for the speed dial for the material floating buttons * material-fab-speed-dial.js: The JavaScript for the speed dial for the material floating buttons
*/ */

View File

@ -1,4 +1,4 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Project
* account-form.js: The JavaScript for the account form * account-form.js: The JavaScript for the account form
*/ */
@ -242,12 +242,12 @@ class RecurringExpenseIncomeSubForm {
this.#form = form; this.#form = form;
this.expenseIncome = expenseIncome; this.expenseIncome = expenseIncome;
this.editor = new RecurringItemEditor(this); this.editor = new RecurringItemEditor(this);
this.#prefix = "accounting-recurring-" + expenseIncome; this.#prefix = `accounting-recurring-${expenseIncome}`;
this.#element = document.getElementById(this.#prefix); this.#element = document.getElementById(this.#prefix);
this.#content = document.getElementById(this.#prefix + "-content"); this.#content = document.getElementById(`${this.#prefix}-content`);
this.#itemList = document.getElementById(this.#prefix + "-list"); this.#itemList = document.getElementById(`${this.#prefix}-list`);
this.#items = Array.from(document.getElementsByClassName(this.#prefix + "-item")).map((element) => new RecurringItemSubForm(this, element)); this.#items = Array.from(document.getElementsByClassName(`${this.#prefix}-item`)).map((element) => new RecurringItemSubForm(this, element));
this.#addButton = document.getElementById(this.#prefix + "-add"); this.#addButton = document.getElementById(`${this.#prefix}-add`);
this.#resetContent(); this.#resetContent();
this.#addButton.onclick = () => this.editor.onAddNew(); this.#addButton.onclick = () => this.editor.onAddNew();
@ -265,7 +265,7 @@ class RecurringExpenseIncomeSubForm {
.replaceAll("EXPENSE_INCOME", escapeHtml(this.expenseIncome)) .replaceAll("EXPENSE_INCOME", escapeHtml(this.expenseIncome))
.replaceAll("ITEM_INDEX", escapeHtml(String(newIndex))); .replaceAll("ITEM_INDEX", escapeHtml(String(newIndex)));
this.#itemList.insertAdjacentHTML("beforeend", html); this.#itemList.insertAdjacentHTML("beforeend", html);
const element = document.getElementById(this.#prefix + "-" + String(newIndex)) const element = document.getElementById(`${this.#prefix}-${String(newIndex)}`)
const item = new RecurringItemSubForm(this, element); const item = new RecurringItemSubForm(this, element);
this.#items.push(item); this.#items.push(item);
this.#resetContent(); this.#resetContent();
@ -294,7 +294,7 @@ class RecurringExpenseIncomeSubForm {
this.#element.classList.remove("accounting-not-empty"); this.#element.classList.remove("accounting-not-empty");
this.#element.classList.add("accounting-clickable"); this.#element.classList.add("accounting-clickable");
this.#element.dataset.bsToggle = "modal" this.#element.dataset.bsToggle = "modal"
this.#element.dataset.bsTarget = "#" + this.editor.modal.id; this.#element.dataset.bsTarget = `#${this.editor.modal.id}`;
this.#element.onclick = () => this.editor.onAddNew(); this.#element.onclick = () => this.editor.onAddNew();
this.#content.classList.add("d-none"); this.#content.classList.add("d-none");
} else { } else {
@ -441,17 +441,17 @@ class RecurringItemSubForm {
this.#expenseIncomeSubForm = expenseIncomeSubForm this.#expenseIncomeSubForm = expenseIncomeSubForm
this.#element = element; this.#element = element;
this.itemIndex = parseInt(element.dataset.itemIndex); this.itemIndex = parseInt(element.dataset.itemIndex);
const prefix = "accounting-recurring-" + expenseIncomeSubForm.expenseIncome + "-" + element.dataset.itemIndex; const prefix = `accounting-recurring-${expenseIncomeSubForm.expenseIncome}-${element.dataset.itemIndex}`;
this.#control = document.getElementById(prefix + "-control"); this.#control = document.getElementById(`${prefix}-control`);
this.#error = document.getElementById(prefix + "-error"); this.#error = document.getElementById(`${prefix}-error`);
this.#no = document.getElementById(prefix + "-no"); this.#no = document.getElementById(`${prefix}-no`);
this.#name = document.getElementById(prefix + "-name"); this.#name = document.getElementById(`${prefix}-name`);
this.#nameText = document.getElementById(prefix + "-name-text"); this.#nameText = document.getElementById(`${prefix}-name-text`);
this.#accountCode = document.getElementById(prefix + "-account-code"); this.#accountCode = document.getElementById(`${prefix}-account-code`);
this.#accountText = document.getElementById(prefix + "-account-text"); this.#accountText = document.getElementById(`${prefix}-account-text`);
this.#descriptionTemplate = document.getElementById(prefix + "-description-template"); this.#descriptionTemplate = document.getElementById(`${prefix}-description-template`);
this.#descriptionTemplateText = document.getElementById(prefix + "-description-template-text"); this.#descriptionTemplateText = document.getElementById(`${prefix}-description-template-text`);
this.deleteButton = document.getElementById(prefix + "-delete"); this.deleteButton = document.getElementById(`${prefix}-delete`);
this.#control.onclick = () => this.#expenseIncomeSubForm.editor.onEdit(this); this.#control.onclick = () => this.#expenseIncomeSubForm.editor.onEdit(this);
this.deleteButton.onclick = () => { this.deleteButton.onclick = () => {
@ -652,16 +652,16 @@ class RecurringItemEditor {
constructor(subForm) { constructor(subForm) {
this.#subForm = subForm; this.#subForm = subForm;
this.expenseIncome = subForm.expenseIncome; this.expenseIncome = subForm.expenseIncome;
const prefix = "accounting-recurring-item-editor-" + subForm.expenseIncome; const prefix = `accounting-recurring-item-editor-${subForm.expenseIncome}`;
this.#form = document.getElementById(prefix); this.#form = document.getElementById(prefix);
this.modal = document.getElementById(prefix + "-modal"); this.modal = document.getElementById(`${prefix}-modal`);
this.#name = document.getElementById(prefix + "-name"); this.#name = document.getElementById(`${prefix}-name`);
this.#nameError = document.getElementById(prefix + "-name-error"); this.#nameError = document.getElementById(`${prefix}-name-error`);
this.#accountControl = document.getElementById(prefix + "-account-control"); this.#accountControl = document.getElementById(`${prefix}-account-control`);
this.#accountContainer = document.getElementById(prefix + "-account"); this.#accountContainer = document.getElementById(`${prefix}-account`);
this.#accountError = document.getElementById(prefix + "-account-error"); this.#accountError = document.getElementById(`${prefix}-account-error`);
this.#descriptionTemplate = document.getElementById(prefix + "-description-template"); this.#descriptionTemplate = document.getElementById(`${prefix}-description-template`);
this.#descriptionTemplateError = document.getElementById(prefix + "-description-template-error"); this.#descriptionTemplateError = document.getElementById(`${prefix}-description-template-error`);
this.#accountSelector = new RecurringAccountSelector(this); this.#accountSelector = new RecurringAccountSelector(this);
this.#name.onchange = () => this.#validateName(); this.#name.onchange = () => this.#validateName();
@ -882,12 +882,12 @@ class RecurringAccountSelector {
constructor(editor) { constructor(editor) {
this.editor = editor; this.editor = editor;
this.#expenseIncome = editor.expenseIncome; this.#expenseIncome = editor.expenseIncome;
const prefix = "accounting-recurring-accounting-selector-" + editor.expenseIncome; const prefix = `accounting-recurring-accounting-selector-${editor.expenseIncome}`;
this.#query = document.getElementById(prefix + "-query"); this.#query = document.getElementById(`${prefix}-query`);
this.#queryNoResult = document.getElementById(prefix + "-option-no-result"); this.#queryNoResult = document.getElementById(`${prefix}-option-no-result`);
this.#optionList = document.getElementById(prefix + "-option-list"); this.#optionList = document.getElementById(`${prefix}-option-list`);
this.#options = Array.from(document.getElementsByClassName(prefix + "-option")).map((element) => new RecurringAccount(this, element)); this.#options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new RecurringAccount(this, element));
this.#clearButton = document.getElementById(prefix + "-clear"); this.#clearButton = document.getElementById(`${prefix}-clear`);
this.#query.oninput = () => this.#filterOptions(); this.#query.oninput = () => this.#filterOptions();
this.#clearButton.onclick = () => this.editor.clearAccount(); this.#clearButton.onclick = () => this.editor.clearAccount();
@ -944,12 +944,6 @@ class RecurringAccountSelector {
*/ */
class RecurringAccount { class RecurringAccount {
/**
* The account selector for the recurring item editor
* @type {RecurringAccountSelector}
*/
#selector;
/** /**
* The element * The element
* @type {HTMLLIElement} * @type {HTMLLIElement}
@ -981,13 +975,12 @@ class RecurringAccount {
* @param element {HTMLLIElement} the element * @param element {HTMLLIElement} the element
*/ */
constructor(selector, element) { constructor(selector, element) {
this.#selector = selector;
this.#element = element; this.#element = element;
this.code = element.dataset.code; this.code = element.dataset.code;
this.text = element.dataset.text; this.text = element.dataset.text;
this.#queryValues = JSON.parse(element.dataset.queryValues); this.#queryValues = JSON.parse(element.dataset.queryValues);
this.#element.onclick = () => this.#selector.editor.saveAccount(this); this.#element.onclick = () => selector.editor.saveAccount(this);
} }
/** /**

View File

@ -1,4 +1,4 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Project
* original-line-item-selector.js: The JavaScript for the original line item selector * original-line-item-selector.js: The JavaScript for the original line item selector
*/ */
@ -88,10 +88,10 @@ class OriginalLineItemSelector {
*/ */
constructor(lineItemEditor) { constructor(lineItemEditor) {
this.lineItemEditor = lineItemEditor; this.lineItemEditor = lineItemEditor;
this.#query = document.getElementById(this.#prefix + "-query"); this.#query = document.getElementById(`${this.#prefix}-query`);
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result"); this.#queryNoResult = document.getElementById(`${this.#prefix}-option-no-result`);
this.#optionList = document.getElementById(this.#prefix + "-option-list"); this.#optionList = document.getElementById(`${this.#prefix}-option-list`);
this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option")).map((element) => new OriginalLineItem(this, element)); this.#options = Array.from(document.getElementsByClassName(`${this.#prefix}-option`)).map((element) => new OriginalLineItem(this, element));
this.#optionById = {}; this.#optionById = {};
for (const option of this.#options) { for (const option of this.#options) {
this.#optionById[option.id] = option; this.#optionById[option.id] = option;
@ -194,10 +194,10 @@ class OriginalLineItemSelector {
class OriginalLineItem { class OriginalLineItem {
/** /**
* The original line item selector * The journal entry form
* @type {OriginalLineItemSelector} * @type {JournalEntryForm}
*/ */
#selector; #form;
/** /**
* The element * The element
@ -230,16 +230,10 @@ class OriginalLineItem {
#currencyCode; #currencyCode;
/** /**
* The account code * The account
* @type {string} * @type {JournalEntryAccount}
*/ */
accountCode; account;
/**
* The account text
* @type {string}
*/
accountText;
/** /**
* The description * The description
@ -284,21 +278,20 @@ class OriginalLineItem {
* @param element {HTMLLIElement} the element * @param element {HTMLLIElement} the element
*/ */
constructor(selector, element) { constructor(selector, element) {
this.#selector = selector; this.#form = selector.lineItemEditor.form;
this.#element = element; this.#element = element;
this.id = element.dataset.id; this.id = element.dataset.id;
this.date = element.dataset.date; this.date = element.dataset.date;
this.#debitCredit = element.dataset.debitCredit; this.#debitCredit = element.dataset.debitCredit;
this.#currencyCode = element.dataset.currencyCode; this.#currencyCode = element.dataset.currencyCode;
this.accountCode = element.dataset.accountCode; this.account = new JournalEntryAccount(element.dataset.accountCode, element.dataset.accountText, false);
this.accountText = element.dataset.accountText;
this.description = element.dataset.description; this.description = element.dataset.description;
this.bareNetBalance = new Decimal(element.dataset.netBalance); this.bareNetBalance = new Decimal(element.dataset.netBalance);
this.netBalance = this.bareNetBalance; this.netBalance = this.bareNetBalance;
this.netBalanceText = document.getElementById("accounting-original-line-item-selector-option-" + this.id + "-net-balance"); this.netBalanceText = document.getElementById(`accounting-original-line-item-selector-option-${this.id}-net-balance`);
this.text = element.dataset.text; this.text = element.dataset.text;
this.#queryValues = JSON.parse(element.dataset.queryValues); this.#queryValues = JSON.parse(element.dataset.queryValues);
this.#element.onclick = () => this.#selector.lineItemEditor.saveOriginalLineItem(this); this.#element.onclick = () => selector.lineItemEditor.saveOriginalLineItem(this);
} }
/** /**
@ -339,7 +332,7 @@ class OriginalLineItem {
*/ */
isMatched(debitCredit, currencyCode, query = null) { isMatched(debitCredit, currencyCode, query = null) {
return this.netBalance.greaterThan(0) return this.netBalance.greaterThan(0)
&& this.date <= this.#selector.lineItemEditor.form.date && this.date <= this.#form.date
&& this.#isDebitCreditMatched(debitCredit) && this.#isDebitCreditMatched(debitCredit)
&& this.#currencyCode === currencyCode && this.#currencyCode === currencyCode
&& this.#isQueryMatched(query); && this.#isQueryMatched(query);

Some files were not shown because too many files have changed in this diff Show More