Compare commits

..

103 Commits

Author SHA1 Message Date
imacat 84bd01087c Fix Escape key navigation and dynamic parent targeting in stacked modals 2026-04-19 10:06:55 +08:00
imacat c10bf81d24 Add ARIA live regions and alerts for dynamic content announcements 2026-04-19 10:06:55 +08:00
imacat acf2a0fa87 Add BaseCombobox base class with listbox/option ARIA pattern 2026-04-19 10:05:35 +08:00
imacat 090acbd66b Convert clickable divs to buttons or add role="button" for keyboard accessibility 2026-04-19 09:53:18 +08:00
imacat 220dbaa683 Convert navbar dropdown span to button for accessibility 2026-04-19 09:49:56 +08:00
imacat 800832d15e Add BaseTablist base class with keyboard navigation 2026-04-19 09:47:50 +08:00
imacat 454ff8bb5f Add ARIA tablist markup to description editor and period chooser 2026-04-19 09:34:41 +08:00
imacat 9b002cd9a9 Add ARIA table roles to report tables for screen reader accessibility 2026-04-19 09:24:30 +08:00
imacat c1d5b46145 Add ARIA markup to icons, icon-only buttons, and pagination dropdown
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 09:24:30 +08:00
imacat 46c6767a90 Add missing semicolon in journal-entry-account-selector.js constructor 2026-04-17 06:56:53 +08:00
imacat 381298adb1 Mark internal classes as private in JavaScript modules 2026-04-17 06:11:17 +08:00
imacat bfa42000d8 Fix indentation in unapplied and unmatched account report templates 2026-04-16 20:41:08 +08:00
imacat 0d02f41417 Fix the typo in the variable name from conform to confirm in period-chooser.js 2026-04-16 20:41:06 +08:00
imacat a26f1942f8 Fix incorrect comment reference in balance-sheet.html template 2026-04-16 20:40:49 +08:00
imacat 0a7bcdd9ec Remove period-chooser script from unapplied and unmatched report templates 2026-04-16 20:40:37 +08:00
imacat b6ea944eb5 Fix the typo in the block name from as_trasfer to as_transfer in the journal entry detail templates 2026-04-15 13:35:21 +08:00
imacat be9f4f3d83 Fix the receipt and transfer journal entry details to show credit_total instead of debit_total for the credit section total 2026-04-15 13:35:21 +08:00
imacat dc42a05959 Migrate from Flask-SQLAlchemy to Flask-SQLAlchemy-Lite 2026-04-15 13:35:21 +08:00
imacat 9c6cc1f3eb Replace db.Model with DeclarativeBase from SQLAlchemy for Flask-SQLAlchemy-Lite migration 2026-04-15 13:35:21 +08:00
imacat e6d25882fc Replace Flask-SQLAlchemy helpers (db.relationship, db.ForeignKey, etc.) with plain SQLAlchemy equivalents 2026-04-15 13:35:21 +08:00
imacat 970c2e9946 Migrate from SQLAlchemy 1.x legacy Query API to 2.x style select/delete statements 2026-04-15 13:35:10 +08:00
imacat 356950e2c7 Replace typing.Type with built-in type[] for Python 3.12. 2026-04-05 23:49:16 +08:00
imacat cca3f89bf1 Replace absolute imports with relative imports 2026-04-05 23:49:16 +08:00
imacat 674b0de3b2 Fix various type hints 2026-04-05 23:49:12 +08:00
imacat 29dfc6c5a4 Fix pycodestyle styling issues 2026-04-05 07:05:20 +08:00
imacat aa3bc1ed69 Add Claude Code and Codex files to .gitignore 2026-04-04 23:40:55 +08:00
imacat 1289d7cba6 Fix type hints in the console command test case. 2026-01-11 12:02:29 +08:00
imacat d62e295dc6 Add init options to skip data initialization and remove manual cleanup in test cases. 2026-01-11 12:02:25 +08:00
imacat 693c5890ca Add db.engine.dispose() in test tearDown to fix ResourceWarning from Python 3.13. 2026-01-11 11:56:20 +08:00
imacat 3adcaa61d3 Fix httpx dependency version in pyproject.toml. 2026-01-08 21:52:11 +08:00
imacat aea9dcae79 Advanced to version 1.6.1. 2024-12-03 08:18:40 +08:00
imacat 40278eaf06 Fix test cases for compatibility with httpx 0.28.0. 2024-12-03 08:18:30 +08:00
imacat e00c14f277 Fixed the SQLite database URL for the in-memory database. 2024-07-10 05:49:29 +08:00
imacat f20c462685 Advanced to version 1.6.0. 2024-06-04 08:29:26 +08:00
imacat 80ae4bd91c Revised the calculation of "today" to use the client's timezone instead of the server's timezone. 2024-06-04 08:28:59 +08:00
imacat 6ee3ee76ea Updated optional dependencies in pyproject.toml. 2024-06-04 08:28:58 +08:00
imacat 2bfcc8b889 Updated the dependencies in pyproject.toml. 2024-06-04 08:28:15 +08:00
imacat 99564c02d0 Updated the Bootstrap, FontAwesome, and Tempus-Dominus versions in the test site. 2024-04-21 22:41:46 +02:00
imacat 25d9904180 Applied the new type parameter syntax to the generic classes for Python 3.12. 2024-03-03 07:39:37 +08:00
imacat 1cf83adf87 Applied the "type" statement to type aliases for Python 3.12. 2024-03-03 07:39:20 +08:00
imacat 8e3d1f11b5 Updated Python version to 3.12. 2024-03-03 07:38:59 +08:00
imacat 0ab14aa34d Updated the copyright year in README.rst. 2024-03-03 07:38:32 +08:00
imacat e0ed81ad1f Advanced to version 1.5.11. 2023-12-16 21:52:15 +08:00
imacat ece7481e9e Refined to enable the selection of the 3351-001 Accumulated Profit or Loss account. 2023-12-16 21:51:14 +08:00
imacat 50d4526e0b Advanced to version 1.5.10. 2023-11-28 08:27:31 +08:00
imacat 3f0a0b4227 Fixed the form validator to enable the selection of Accumulated Profit or Loss accounts other than 3351-001. 2023-11-28 08:26:37 +08:00
imacat dcc9626b23 Fixed the release date of version 1.5.9 in the change log. 2023-11-28 08:17:25 +08:00
imacat 79eb077129 Advanced to version 1.5.9. 2023-11-28 08:10:00 +08:00
imacat d5719ad223 Refined to enable the selection of Accumulated Profit or Loss accounts other than 3351-001, facilitating the consolidation of existing balances. 2023-11-28 08:09:35 +08:00
imacat eb3fa8f414 Added docs/requirements.txt and the "sphinx_rtd_theme" theme to the readthedocs configuration, as Read the Docs does not install sphinx_rtd_theme by default
after August 7, 2023.
2023-11-28 08:04:11 +08:00
imacat 937908717b Advanced to version 1.5.8. 2023-10-24 05:00:53 +05:30
imacat 0104fa4c21 Fixed an icon in the detail of the cash receipt journal entry. 2023-10-24 04:43:11 +05:30
imacat 14365ca255 Advanced to version 1.5.7. 2023-07-29 13:24:52 +08:00
imacat cd86651606 Added the "accounting-titleize" console command to capitalize the existing account titles that were already initialized. 2023-07-29 13:11:47 +08:00
imacat 9147744ff7 Renamed the test_init test to test_init_db in the ConsoleCommandTestCase test case. 2023-07-29 13:07:08 +08:00
imacat 1a212a5330 Updated the documentation of the test_init test of the ConsoleCommandTestCase test case. 2023-07-29 13:07:08 +08:00
imacat 0614457b7b Moved dropping tables from the setUp method to the test_init test in the ConsoleCommandTestCase test case. The other tests may not need to drop the tables first. 2023-07-29 13:07:08 +08:00
imacat 545f49043b Updated the Sphinx documentation. 2023-07-29 13:07:08 +08:00
imacat cac0d66ca1 Updated the translation. 2023-07-29 13:07:08 +08:00
imacat 5ffd37c859 Revised to capitalize the account titles when initializing the base accounts instead of when displaying the account titles, so that the titles of the user-added accounts are not capitalized incorrectly. 2023-07-29 13:06:32 +08:00
imacat 9ae8c1bce9 Updated the translation. 2023-07-29 10:11:45 +08:00
imacat ec0ff3e2e6 Updated the log in message at the home page, and removed the next URI from the log in link. The next URI is not clear text but encrypted now. There is no need to attach the next URI, as it defaults redirects to the accounting application without the next URI. 2023-07-29 10:11:45 +08:00
imacat 40a8080751 Removed unused imports from the test site. 2023-07-29 10:11:45 +08:00
imacat 736a4086ee Removed an unused import from testlib_journal_entry.py. 2023-07-29 10:11:45 +08:00
imacat 6723077b72 Revised the code to read from the CSV data files in the __test_base_account_data method of the ConsoleCommandTestCase test case, to prevent PyCharm from complaining. 2023-07-29 10:11:45 +08:00
imacat 0ae00bce79 Changed the properties of the test cases from public to private. 2023-07-29 10:11:45 +08:00
imacat 356d2010cc Removed the CSRF token from the get_client function in testlib.py, so that type hints and documentation can be added to the client and the CSRF token properties separately. 2023-07-29 10:11:45 +08:00
imacat 501c4b1d22 Added missing documentation to the global variables, class properties, and object properties. 2023-07-29 10:11:44 +08:00
imacat 64b9c8c11f Removed an excess property declaration in the populate_obj method of the JournalEntryForm form. 2023-07-29 10:03:46 +08:00
imacat 9072de82d4 Added the "decode_next" utility in the "accounting.utils.next_uri" module, and applied the "encode_next" and "decode_next" utilities to the NextUriTestCase test case, so that the test case do not need to get involved into the detail of the next URI encryption. 2023-07-29 10:03:45 +08:00
imacat 30fd9c2164 Fixed the documentation of the "is_default" property of the Period utility. 2023-06-05 22:43:35 +08:00
imacat 7cb01b4cee Revised the documentation of the columns of the data models. 2023-06-05 16:55:25 +08:00
imacat 9a4e04c41f Renamed the HTML ID "collapsible-navbar" to "accounting-collapsible-navbar" in the test site. 2023-06-03 11:12:28 +08:00
imacat a9c4fa9de0 Advanced to version 1.5.6. 2023-05-23 09:32:48 +08:00
imacat 3a676e0b5a Fixed the back URL of the creation forms, applying the accounting_or_next filter for the decoded next URI instead of getting the next URI directly. 2023-05-23 09:32:48 +08:00
imacat 9cc7b64bb3 Moved the "__as_next" utility from the test site to the "accounting.utils.next_uri" module, and applied it to the template of the unmatched offset list. 2023-05-23 09:32:48 +08:00
imacat 352867797d Advanced to version 1.5.5. 2023-05-23 09:30:33 +08:00
imacat 09a344d749 Removed excess spaces from the test_change_date test of the JournalEntryReorderTestCase test case. 2023-05-23 09:30:33 +08:00
imacat 818c357613 Revised the next URI utilities to apply URLSafeSerializer for encoding and decoding the next URI, in order to prevent tampering with the next URI. 2023-05-23 09:30:19 +08:00
imacat 822c8fc49b Renamed the "__get_next_uri" function to "__get_next" in the "accounting.utils.next_uri" module. 2023-05-23 07:10:30 +08:00
imacat 3b8a2e3bb1 Replaced the "accounting-dummy-form" name with the dummy CSRF token to work with OWASP ZAP CSRF token scans. 2023-05-22 18:32:24 +08:00
imacat 9e4927ee0b Replaced the get_errors_view with the get_messages_view in the create_test_app function in testlib.py. 2023-05-22 00:03:13 +08:00
imacat 3b030c577c Added the integrity value of the CDN stylesheet links in the base template of the test site. 2023-05-19 18:17:29 +08:00
imacat 60b33f2a3b Revised the link to the stylesheet of tempus dominus in the base template of the test site. 2023-05-19 18:17:20 +08:00
imacat 08fdf59844 Revised the indent of the flashed success messages in the base template of the test site. 2023-05-19 18:17:11 +08:00
imacat b397515457 Removed the size restriction in the next URI utilities. Buffer overflow may happen with any parameter, not only the "next" parameter. It should be solved in uWSGI, but not the application. 2023-05-18 23:30:36 +08:00
imacat abe90d3483 Advanced to version 1.5.4. 2023-05-18 00:06:16 +08:00
imacat 65e7dcdf6d Replaced the "/next" next URI with the NEXT_URI constant in the test_reorder test of the JournalEntryReorderTestCase test case. 2023-05-18 00:06:05 +08:00
imacat 74e414badf Removed unnecessary f-strings from the test_reorder test of the JournalEntryReorderTestCase test case. 2023-05-17 23:54:52 +08:00
imacat 69175979ff Added the form name to the dummy forms so that they can be excluded by OWASP ZAP scanner for Anti-CSRF tokens. 2023-05-17 22:56:47 +08:00
imacat 2f69e0f215 Added the form name to the search forms so that they can be excluded by OWASP ZAP scanner for Anti-CSRF tokens. 2023-05-17 21:43:21 +08:00
imacat 961385c389 Added SESSION_COOKIE_SAMESITE and SESSION_COOKIE_SECURE to create_app of the test site, to set the SameSite and Secure flags for the session cookie. 2023-05-17 19:57:38 +08:00
imacat a691cfd2da Applied the or_next utility to the set local route of the test site. 2023-05-17 19:57:23 +08:00
imacat 482a0faa23 Added safeguard to the next URI utilities from invalid or insecure next URI. 2023-05-17 16:26:35 +08:00
imacat 0ecf7b6617 Revised the documentation of the "accounting.utils.cast" module. 2023-05-17 15:33:42 +08:00
imacat 4408bbfc82 Updated the JavaScript library versions, and added decimal.js-light to the documentation. 2023-05-06 23:59:06 +08:00
imacat 433110f486 Revised the way to query accounts with Flask-SQLAlchemy style queries in the accounts method of the CurrentAccount data model. 2023-05-04 09:35:20 +08:00
imacat 0b1dd4f4fc Advanced to version 1.5.3. 2023-04-30 15:07:46 +08:00
imacat 46bd27e126 Revised the saveOriginalLineItem method of the JavaScript JournalEntryLineItemEditor class not to override the existing amount when the existing amount is less than the net balance. This make it easier when updating the existing journal entries. 2023-04-30 15:03:59 +08:00
imacat b718d19450 Resolved an issue where, in cases where there was no existing localized title and the default title was submitted, the submitted account title or currency name would be erroneously saved as the localized title. 2023-04-30 15:03:58 +08:00
imacat 2969e83afe Advanced to version 1.5.2. 2023-04-30 06:43:18 +08:00
imacat a732656746 Revised the coding style in the "__get_line_items" method of the OffsetMatcher class. 2023-04-30 06:38:43 +08:00
imacat 1daed940b6 Corrected the definition of the "is_offset" property in the "__get_line_items" method of the OffsetMatcher class. 2023-04-30 06:38:01 +08:00
174 changed files with 4656 additions and 3306 deletions
+2
View File
@@ -25,6 +25,8 @@ venv
.DS_Store .DS_Store
.idea .idea
.claude
.codex
instance instance
flask_session flask_session
+1
View File
@@ -38,3 +38,4 @@ python:
install: install:
- method: pip - method: pip
path: . path: .
- requirements: docs/requirements.txt
+1 -1
View File
@@ -59,7 +59,7 @@ Refer to the `change log`_.
Copyright Copyright
========= =========
Copyright (c) 2023 imacat. Copyright (c) 2023-2024 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.
+1
View File
@@ -0,0 +1 @@
sphinx_rtd_theme
+8
View File
@@ -100,6 +100,14 @@ accounting.utils.strip\_text module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.utils.title\_case module
-----------------------------------
.. automodule:: accounting.utils.title_case
:members:
:undoc-members:
:show-inheritance:
accounting.utils.user module accounting.utils.user module
---------------------------- ----------------------------
+143
View File
@@ -2,6 +2,149 @@ Change Log
========== ==========
Version 1.6.1
--------------
Released 2024/12/3
Fix test cases for compatibility with httpx 0.28.0.
Version 1.6.0
--------------
Released 2024/6/4
* Updated Python version to 3.12.
* Revised the calculation of "today" to use the client's timezone instead of
the server's timezone.
* Updated the Bootstrap, FontAwesome, and Tempus-Dominus versions in the test
site.
Version 1.5.11
--------------
Released 2023/12/26
Bug fix.
* Refined to enable the selection of the 3351-001 Accumulated Profit or Loss
account.
Version 1.5.10
--------------
Released 2023/11/28
Bug fix.
* Fixed the form validator to enable the selection of Accumulated Profit or
Loss accounts other than 3351-001.
Version 1.5.9
-------------
Released 2023/11/28
Bug fix.
* Refined to enable the selection of Accumulated Profit or Loss accounts other
than 3351-001, facilitating the consolidation of existing balances.
Version 1.5.8
-------------
Released 2023/10/24
Bug fix.
* Fixed an icon in the detail of the cash receipt journal entry.
Released at Jaipur, India on vacation.
Version 1.5.7
-------------
Released 2023/7/29
Revised account title capitalization to capitalize account titles
upon initialization of base accounts, rather than when displaying
the accounts. This prevents the system from incorrectly
capitalizing titles of user-added accounts.
For existing installation, run the ``accounting-titleize`` console
command to capitalize the existing account titles that were already
initialized.
Other fixes:
* Added missing documentation to the global variables, class
properties, and object properties.
* Various minor fixes.
Version 1.5.6
-------------
Released 2023/5/23
Bug fixes.
* Fixed the return URI of the creation forms to decode the next URI.
* Fixed the unmatched offset list to use the encoded next URI.
Version 1.5.5
-------------
Released 2023/5/23
Security fixes.
* Revised the next URI utilities to encode and decode the next URI
preventing tampering with the next URI.
* Added the integrity value of the CDN stylesheet links.
* Various fixes.
Version 1.5.4
-------------
Released 2023/5/18
Security fixes.
* Added safeguard to the next URI utilities, to prevent Cross-Site
Scripting (XSS) attacks.
* Applied the safe next URI utilities to the test site.
* Added the ``SameSite`` and ``Secure`` flags to the session cookie
of the test site.
Version 1.5.3
-------------
Released 2023/4/30
* Fixed the error of the net balance in the unmatched offset list.
* Revised the original line item editor not to override the existing
amount when the existing amount is less or equal to the net
balance.
Version 1.5.2
-------------
Released 2023/4/30
* Fixed the error of the net balance in the unmatched offset list.
Version 1.5.1 Version 1.5.1
------------- -------------
+8 -3
View File
@@ -13,7 +13,7 @@ The following is an example configuration for *Mia! Accounting*.
from flask import Response, redirect from flask import Response, redirect
from .auth import current_user() from .auth import current_user()
from .modules import User from .modules import Base, User
def create_app(test_config=None) -> Flask: def create_app(test_config=None) -> Flask:
app: Flask = Flask(__name__) app: Flask = Flask(__name__)
@@ -37,7 +37,11 @@ The following is an example configuration for *Mia! Accounting*.
return redirect("/login") return redirect("/login")
@property @property
def cls(self) -> t.Type[User]: def base(self) -> type[DeclarativeBase]:
return Base
@property
def cls(self) -> type[User]:
return User return User
@property @property
@@ -49,7 +53,8 @@ The following is an example configuration for *Mia! Accounting*.
return current_user() return current_user()
def get_by_username(self, username: str) -> User | None: def get_by_username(self, username: str) -> User | None:
return User.query.filter(User.username == username).first() return db.session.scalar(
sa.select(User).where(User.username == username))
def get_pk(self, user: User) -> int: def get_pk(self, user: User) -> int:
return user.id return user.id
+5 -4
View File
@@ -50,9 +50,9 @@ The following front-end JavaScript libraries must be loaded. You may
download it locally or use CDN_. download it locally or use CDN_.
* Bootstrap_ 5.2.3 or above * Bootstrap_ 5.2.3 or above
* FontAwesome_ 6.2.1 or above * FontAwesome_ 6.4.0 or above
* `Decimal.js`_ 6.4.3 or above * `decimal.js`_ 10.4.3 or above, or `decimal.js-light`_ 2.5.1 or above.
* `Tempus-Dominus`_ 6.4.3 or above * `Tempus-Dominus`_ 6.7.7 or above
Configuration Configuration
@@ -114,6 +114,7 @@ Check your Flask application and see how it works.
.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network .. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network
.. _Bootstrap: https://getbootstrap.com .. _Bootstrap: https://getbootstrap.com
.. _FontAwesome: https://fontawesome.com .. _FontAwesome: https://fontawesome.com
.. _Decimal.js: https://mikemcl.github.io/decimal.js .. _decimal.js: https://mikemcl.github.io/decimal.js
.. _decimal.js-light: https://mikemcl.github.io/decimal.js-light
.. _Tempus-Dominus: https://getdatepicker.com .. _Tempus-Dominus: https://getdatepicker.com
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/ .. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
+6 -7
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -20,7 +20,7 @@ name = "mia-accounting"
dynamic = ["version"] dynamic = ["version"]
description = "A Flask accounting module." description = "A Flask accounting module."
readme = "README.rst" readme = "README.rst"
requires-python = ">=3.11" requires-python = ">=3.12"
authors = [ authors = [
{name = "imacat", email = "imacat@mail.imacat.idv.tw"}, {name = "imacat", email = "imacat@mail.imacat.idv.tw"},
] ]
@@ -33,18 +33,17 @@ classifiers = [
"Topic :: Office/Business :: Financial :: Accounting", "Topic :: Office/Business :: Financial :: Accounting",
] ]
dependencies = [ dependencies = [
"flask", "Flask",
"SQLAlchemy >= 2", "SQLAlchemy >= 2",
"Flask-SQLAlchemy", "Flask-SQLAlchemy-Lite",
"Flask-WTF", "Flask-WTF",
"Flask-Babel >= 3", "Flask-Babel >= 3",
"Flask-Babel-JS", "Flask-Babel-JS",
] ]
[project.optional-dependencies] [project.optional-dependencies]
test = [ devel = [
"unittest", "httpx >= 0.28.0",
"httpx",
"OpenCC", "OpenCC",
] ]
+6 -5
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -20,11 +20,11 @@
from pathlib import Path from pathlib import Path
from flask import Flask, Blueprint from flask import Flask, Blueprint
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy_lite import SQLAlchemy
from accounting.utils.user import UserUtilityInterface from .utils.user import UserUtilityInterface
VERSION: str = "1.5.1" VERSION: str = "1.6.1"
"""The package version.""" """The package version."""
db: SQLAlchemy = SQLAlchemy() db: SQLAlchemy = SQLAlchemy()
"""The database instance.""" """The database instance."""
@@ -63,8 +63,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 from .commands import init_db_command, titleize_command
app.cli.add_command(init_db_command) app.cli.add_command(init_db_command)
app.cli.add_command(titleize_command)
from . import locale from . import locale
locale.init_app(app, bp) locale.init_app(app, bp)
+10 -9
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -23,11 +23,11 @@ from typing import Any
import click import click
import sqlalchemy as sa import sqlalchemy as sa
from accounting import db from .. import db
from accounting.models import BaseAccount, Account, AccountL10n from ..models import BaseAccount, Account, AccountL10n
from accounting.utils.user import get_user_pk from ..utils.user import get_user_pk
AccountData = tuple[int, str, int, str, str, str, bool] type 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."""
@@ -36,13 +36,14 @@ 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)
bases: list[BaseAccount] = BaseAccount.query\ bases: list[BaseAccount] = db.session.scalars(
.filter(db.func.length(BaseAccount.code) == 4)\ sa.select(BaseAccount).where(sa.func.length(BaseAccount.code) == 4)
.order_by(BaseAccount.code).all() .order_by(BaseAccount.code)).unique().all()
if len(bases) == 0: if len(bases) == 0:
raise click.Abort raise click.Abort
existing: list[Account] = Account.query.all() existing: list[Account] = \
db.session.scalars(sa.select(Account)).unique().all()
existing_base_code: set[str] = {x.base_code for x in existing} existing_base_code: set[str] = {x.base_code for x in existing}
bases_to_add: list[BaseAccount] = [x for x in bases bases_to_add: list[BaseAccount] = [x for x in bases
+2 -2
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -20,7 +20,7 @@
from flask import abort from flask import abort
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from accounting.models import Account from ..models import Account
class AccountConverter(BaseConverter): class AccountConverter(BaseConverter):
+20 -16
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -23,12 +23,12 @@ from flask_wtf import FlaskForm
from wtforms import StringField, BooleanField from wtforms import StringField, BooleanField
from wtforms.validators import DataRequired, ValidationError from wtforms.validators import DataRequired, ValidationError
from accounting import db from .. import db
from accounting.locale import lazy_gettext from ..locale import lazy_gettext
from accounting.models import BaseAccount, Account from ..models import BaseAccount, Account
from accounting.utils.random_id import new_id from ..utils.random_id import new_id
from accounting.utils.strip_text import strip_text from ..utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk from ..utils.user import get_current_user_pk
class BaseAccountExists: class BaseAccountExists:
@@ -97,8 +97,9 @@ class AccountForm(FlaskForm):
if obj.base_code is not None: if obj.base_code is not None:
sort_accounts_in(obj.base_code, obj.id) sort_accounts_in(obj.base_code, obj.id)
sort_accounts_in(self.base_code.data, obj.id) sort_accounts_in(self.base_code.data, obj.id)
count: int = Account.query\ count: int = db.session.scalar(
.filter(Account.base_code == self.base_code.data).count() sa.select(sa.func.count(Account.id))
.where(Account.base_code == self.base_code.data))
obj.base_code = self.base_code.data obj.base_code = self.base_code.data
obj.no = count + 1 obj.no = count + 1
obj.title = self.title.data obj.title = self.title.data
@@ -137,9 +138,10 @@ class AccountForm(FlaskForm):
:return: The selectable base accounts. :return: The selectable base accounts.
""" """
return BaseAccount.query\ return db.session.scalars(
.filter(sa.func.char_length(BaseAccount.code) == 4)\ sa.select(BaseAccount)
.order_by(BaseAccount.code).all() .where(sa.func.char_length(BaseAccount.code) == 4)
.order_by(BaseAccount.code)).unique()
def sort_accounts_in(base_code: str, exclude: int) -> None: def sort_accounts_in(base_code: str, exclude: int) -> None:
@@ -150,10 +152,10 @@ def sort_accounts_in(base_code: str, exclude: int) -> None:
:param exclude: The account ID to exclude. :param exclude: The account ID to exclude.
:return: None. :return: None.
""" """
accounts: list[Account] = Account.query\ accounts: list[Account] = db.session.scalars(
.filter(Account.base_code == base_code, sa.select(Account)
Account.id != exclude)\ .where(Account.base_code == base_code, Account.id != exclude)
.order_by(Account.no).all() .order_by(Account.no)).unique().all()
for i in range(len(accounts)): for i in range(len(accounts)):
if accounts[i].no != i + 1: if accounts[i].no != i + 1:
accounts[i].no = i + 1 accounts[i].no = i + 1
@@ -168,7 +170,9 @@ class AccountReorderForm:
:param base: The base account. :param base: The base account.
""" """
self.base: BaseAccount = base self.base: BaseAccount = base
"""The base account."""
self.is_modified: bool = False self.is_modified: bool = False
"""Whether the order is modified."""
def save_order(self) -> None: def save_order(self) -> None:
"""Saves the order of the account. """Saves the order of the account.
+18 -13
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -20,9 +20,10 @@
import sqlalchemy as sa import sqlalchemy as sa
from flask import request from flask import request
from accounting.locale import gettext from .. import db
from accounting.models import Account, AccountL10n from ..locale import gettext
from accounting.utils.query import parse_query_keywords from ..models import Account, AccountL10n
from ..utils.query import parse_query_keywords
def get_account_query() -> list[Account]: def get_account_query() -> list[Account]:
@@ -32,17 +33,20 @@ def get_account_query() -> list[Account]:
""" """
keywords: list[str] = parse_query_keywords(request.args.get("q")) keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0: if len(keywords) == 0:
return Account.query.order_by(Account.base_code, Account.no).all() return db.session.scalars(
code: sa.BinaryExpression = Account.base_code + "-" \ sa.select(Account)
.order_by(Account.base_code, Account.no)).unique().all()
code: sa.ColumnElement[str] = Account.base_code + "-" \
+ sa.func.substr("000" + sa.cast(Account.no, sa.String), + sa.func.substr("000" + sa.cast(Account.no, sa.String),
sa.func.char_length(sa.cast(Account.no, sa.func.char_length(sa.cast(Account.no,
sa.String)) + 1) sa.String)) + 1)
conditions: list[sa.BinaryExpression] = [] conditions: list[sa.ColumnElement[bool]] = []
for k in keywords: for k in keywords:
l10n: list[AccountL10n] = AccountL10n.query\ l10n: list[AccountL10n] = db.session.scalars(
.filter(AccountL10n.title.icontains(k)).all() sa.select(AccountL10n)
l10n_matches: set[str] = {x.account_id for x in l10n} .where(AccountL10n.title.icontains(k))).all()
sub_conditions: list[sa.BinaryExpression] \ l10n_matches: set[int] = {x.account_id for x in l10n}
sub_conditions: list[sa.ColumnElement[bool]] \
= [Account.base_code.contains(k), = [Account.base_code.contains(k),
Account.title_l10n.icontains(k), Account.title_l10n.icontains(k),
code.contains(k), code.contains(k),
@@ -51,5 +55,6 @@ def get_account_query() -> list[Account]:
sub_conditions.append(Account.is_need_offset) sub_conditions.append(Account.is_need_offset)
conditions.append(sa.or_(*sub_conditions)) conditions.append(sa.or_(*sub_conditions))
return Account.query.filter(*conditions)\ return db.session.scalars(
.order_by(Account.base_code, Account.no).all() sa.select(Account).where(*conditions)
.order_by(Account.base_code, Account.no)).unique().all()
+17 -17
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -21,20 +21,20 @@ from urllib.parse import parse_qsl, urlencode
import sqlalchemy as sa import sqlalchemy as sa
from flask import Blueprint, render_template, session, redirect, flash, \ from flask import Blueprint, render_template, session, redirect, flash, \
url_for, request url_for, request, Response
from werkzeug.datastructures import ImmutableMultiDict from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Account, BaseAccount
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.pagination import Pagination
from accounting.utils.permission import can_view, has_permission, can_edit
from accounting.utils.user import get_current_user_pk
from .forms import AccountForm, sort_accounts_in, AccountReorderForm from .forms import AccountForm, sort_accounts_in, AccountReorderForm
from .queries import get_account_query from .queries import get_account_query
from .. import db
from ..locale import lazy_gettext
from ..models import Account, BaseAccount
from ..utils.cast import s
from ..utils.flash_errors import flash_form_errors
from ..utils.next_uri import inherit_next, or_next
from ..utils.pagination import Pagination
from ..utils.permission import can_view, has_permission, can_edit
from ..utils.user import get_current_user_pk
bp: Blueprint = Blueprint("account", __name__) bp: Blueprint = Blueprint("account", __name__)
"""The view blueprint for the account management.""" """The view blueprint for the account management."""
@@ -47,8 +47,8 @@ def list_accounts() -> str:
:return: The account list. :return: The account list.
""" """
accounts: list[BaseAccount] = get_account_query() accounts: list[Account] = get_account_query()
pagination: Pagination = Pagination[BaseAccount](accounts) pagination: Pagination = Pagination[Account](accounts)
return render_template("accounting/account/list.html", return render_template("accounting/account/list.html",
list=pagination.list, pagination=pagination) list=pagination.list, pagination=pagination)
@@ -72,7 +72,7 @@ def show_add_account_form() -> str:
@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() -> Response:
"""Adds an account. """Adds an account.
:return: The redirection to the account detail on success, or the account :return: The redirection to the account detail on success, or the account
@@ -123,7 +123,7 @@ def show_account_edit_form(account: Account) -> str:
@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) -> Response:
"""Updates an account. """Updates an account.
:param account: The account. :param account: The account.
@@ -150,7 +150,7 @@ def update_account(account: Account) -> redirect:
@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) -> Response:
"""Deletes an account. """Deletes an account.
:param account: The account. :param account: The account.
@@ -180,7 +180,7 @@ def show_account_order(base: BaseAccount) -> str:
@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) -> Response:
"""Reorders the accounts under a base account. """Reorders the accounts under a base account.
:param base: The base account. :param base: The base account.
+7 -7
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -21,20 +21,20 @@ import csv
import sqlalchemy as sa import sqlalchemy as sa
from accounting import data_dir from .. import db, data_dir
from accounting import db from ..models import BaseAccount, BaseAccountL10n
from accounting.models import BaseAccount, BaseAccountL10n from ..utils.title_case import title_case
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 db.session.scalar(sa.select(BaseAccount)) is not None:
return return
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)]
account_data: list[dict[str, str]] = [{"code": x["code"], account_data: list[dict[str, str]] = \
"title_l10n": x["title"]} [{"code": x["code"], "title_l10n": title_case(x["title"])}
for x in data] for x in data]
locales: list[str] = [x[5:] for x in data[0] if x.startswith("l10n-")] locales: list[str] = [x[5:] for x in data[0] if x.startswith("l10n-")]
l10n_data: list[dict[str, str]] = [{"account_code": x["code"], l10n_data: list[dict[str, str]] = [{"account_code": x["code"],
+3 -3
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -20,8 +20,8 @@
from flask import abort from flask import abort
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from accounting import db from .. import db
from accounting.models import BaseAccount from ..models import BaseAccount
class BaseAccountConverter(BaseConverter): class BaseAccountConverter(BaseConverter):
+13 -9
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -20,8 +20,9 @@
import sqlalchemy as sa import sqlalchemy as sa
from flask import request from flask import request
from accounting.models import BaseAccount, BaseAccountL10n from .. import db
from accounting.utils.query import parse_query_keywords from ..models import BaseAccount, BaseAccountL10n
from ..utils.query import parse_query_keywords
def get_base_account_query() -> list[BaseAccount]: def get_base_account_query() -> list[BaseAccount]:
@@ -31,14 +32,17 @@ def get_base_account_query() -> list[BaseAccount]:
""" """
keywords: list[str] = parse_query_keywords(request.args.get("q")) keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0: if len(keywords) == 0:
return BaseAccount.query.order_by(BaseAccount.code).all() return db.session.scalars(
conditions: list[sa.BinaryExpression] = [] sa.select(BaseAccount).order_by(BaseAccount.code)).unique().all()
conditions: list[sa.ColumnElement[bool]] = []
for k in keywords: for k in keywords:
l10n: list[BaseAccountL10n] = BaseAccountL10n.query\ l10n: list[BaseAccountL10n] = db.session.scalars(
.filter(BaseAccountL10n.title.icontains(k)).all() sa.select(BaseAccountL10n)
.where((BaseAccountL10n.title.icontains(k)))).all()
l10n_matches: set[str] = {x.account_code for x in l10n} l10n_matches: set[str] = {x.account_code for x in l10n}
conditions.append(sa.or_(BaseAccount.code.contains(k), conditions.append(sa.or_(BaseAccount.code.contains(k),
BaseAccount.title_l10n.icontains(k), BaseAccount.title_l10n.icontains(k),
BaseAccount.code.in_(l10n_matches))) BaseAccount.code.in_(l10n_matches)))
return BaseAccount.query.filter(*conditions)\ return db.session.scalars(
.order_by(BaseAccount.code).all() sa.select(BaseAccount).where(*conditions)
.order_by(BaseAccount.code)).unique().all()
+4 -5
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -19,10 +19,10 @@
""" """
from flask import Blueprint, render_template from flask import Blueprint, render_template
from accounting.models import BaseAccount
from accounting.utils.pagination import Pagination
from accounting.utils.permission import has_permission, can_view
from .queries import get_base_account_query from .queries import get_base_account_query
from ..models import BaseAccount
from ..utils.pagination import Pagination
from ..utils.permission import has_permission, can_view
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."""
@@ -50,4 +50,3 @@ def show_account_detail(account: BaseAccount) -> str:
:return: The detail. :return: The detail.
""" """
return render_template("accounting/base-account/detail.html", obj=account) return render_template("accounting/base-account/detail.html", obj=account)
+49 -8
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/10 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/10
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 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.
@@ -20,13 +20,16 @@
import os import os
import click import click
import sqlalchemy as sa
from flask.cli import with_appcontext from flask.cli import with_appcontext
from accounting import db from . import db
from accounting.account import init_accounts_command from .account import init_accounts_command
from accounting.base_account import init_base_accounts_command from .base_account import init_base_accounts_command
from accounting.currency import init_currencies_command from .currency import init_currencies_command
from accounting.utils.user import has_user from .models import BaseAccount, Account
from .utils.title_case import title_case
from .utils.user import base_cls, has_user, get_user_pk
def __validate_username(ctx: click.core.Context, param: click.core.Option, def __validate_username(ctx: click.core.Context, param: click.core.Option,
@@ -51,12 +54,50 @@ def __validate_username(ctx: click.core.Context, param: click.core.Option,
@click.option("-u", "--username", metavar="USERNAME", prompt=True, @click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username, help="The username.", callback=__validate_username,
default=lambda: os.getlogin()) default=lambda: os.getlogin())
@click.option("--skip-accounts", is_flag=True, default=False,
help="Skip initializing accounts.")
@click.option("--skip-currencies", is_flag=True, default=False,
help="Skip initializing currencies.")
@with_appcontext @with_appcontext
def init_db_command(username: str) -> None: def init_db_command(username: str, skip_accounts: bool,
skip_currencies: bool) -> None:
"""Initializes the accounting database.""" """Initializes the accounting database."""
db.create_all() base_cls.metadata.create_all(db.engine)
init_base_accounts_command() init_base_accounts_command()
if not skip_accounts:
init_accounts_command(username) init_accounts_command(username)
print("OK 1")
if not skip_currencies:
init_currencies_command(username) init_currencies_command(username)
print("OK 2")
db.session.commit() db.session.commit()
click.echo("Accounting database initialized.") click.echo("Accounting database initialized.")
@click.command("accounting-titleize")
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@with_appcontext
def titleize_command(username: str) -> None:
"""Capitalize the account titles."""
updater_pk: int = get_user_pk(username)
updated: int = 0
for base in db.session.scalars(sa.select(BaseAccount)).unique():
new_title: str = title_case(base.title_l10n)
if base.title_l10n != new_title:
base.title_l10n = new_title
updated = updated + 1
for account in db.session.scalars(sa.select(Account)).unique():
if account.title_l10n.lower() == account.base.title_l10n.lower():
new_title: str = title_case(account.title_l10n)
if account.title_l10n != new_title:
account.title_l10n = new_title
account.updated_at = sa.func.now()
account.updated_by_id = updater_pk
updated = updated + 1
if updated == 0:
click.echo("All account titles were already capitalized.")
return
db.session.commit()
click.echo(f"{updated} account titles capitalized.")
+6 -5
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -22,14 +22,15 @@ from typing import Any
import sqlalchemy as sa import sqlalchemy as sa
from accounting import db, data_dir from .. import db, data_dir
from accounting.models import Currency, CurrencyL10n from ..models import Currency, CurrencyL10n
from accounting.utils.user import get_user_pk from ..utils.user import get_user_pk
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 db.session.scalars(sa.select(Currency)).unique()}
with open(data_dir / "currencies.csv") as fp: with open(data_dir / "currencies.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)]
+3 -3
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -20,8 +20,8 @@
from flask import abort from flask import abort
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from accounting import db from .. import db
from accounting.models import Currency from ..models import Currency
class CurrencyConverter(BaseConverter): class CurrencyConverter(BaseConverter):
+6 -6
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -21,11 +21,11 @@ from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError from wtforms import StringField, ValidationError
from wtforms.validators import DataRequired, Regexp, NoneOf from wtforms.validators import DataRequired, Regexp, NoneOf
from accounting import db from .. import db
from accounting.locale import lazy_gettext from ..locale import lazy_gettext
from accounting.models import Currency from ..models import Currency
from accounting.utils.strip_text import strip_text from ..utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk from ..utils.user import get_current_user_pk
class CodeUnique: class CodeUnique:
+13 -9
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -20,8 +20,9 @@
import sqlalchemy as sa import sqlalchemy as sa
from flask import request from flask import request
from accounting.models import Currency, CurrencyL10n from .. import db
from accounting.utils.query import parse_query_keywords from ..models import Currency, CurrencyL10n
from ..utils.query import parse_query_keywords
def get_currency_query() -> list[Currency]: def get_currency_query() -> list[Currency]:
@@ -31,14 +32,17 @@ def get_currency_query() -> list[Currency]:
""" """
keywords: list[str] = parse_query_keywords(request.args.get("q")) keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0: if len(keywords) == 0:
return Currency.query.order_by(Currency.code).all() return db.session.scalars(
conditions: list[sa.BinaryExpression] = [] sa.select(Currency).order_by(Currency.code)).unique().all()
conditions: list[sa.ColumnElement[bool]] = []
for k in keywords: for k in keywords:
l10n: list[CurrencyL10n] = CurrencyL10n.query\ l10n: list[CurrencyL10n] = db.session.scalars(
.filter(CurrencyL10n.name.icontains(k)).all() sa.select(CurrencyL10n)
.where(CurrencyL10n.name.icontains(k))).all()
l10n_matches: set[str] = {x.account_code for x in l10n} l10n_matches: set[str] = {x.account_code for x in l10n}
conditions.append(sa.or_(Currency.code.icontains(k), conditions.append(sa.or_(Currency.code.icontains(k),
Currency.name_l10n.icontains(k), Currency.name_l10n.icontains(k),
Currency.code.in_(l10n_matches))) Currency.code.in_(l10n_matches)))
return Currency.query.filter(*conditions)\ return db.session.scalars(
.order_by(Currency.code).all() sa.select(Currency).where(*conditions)
.order_by(Currency.code)).unique().all()
+14 -14
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -21,20 +21,20 @@ from urllib.parse import urlencode, parse_qsl
import sqlalchemy as sa import sqlalchemy as sa
from flask import Blueprint, render_template, redirect, session, request, \ from flask import Blueprint, render_template, redirect, session, request, \
flash, url_for flash, url_for, Response
from werkzeug.datastructures import ImmutableMultiDict from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.pagination import Pagination
from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.user import get_current_user_pk
from .forms import CurrencyForm from .forms import CurrencyForm
from .queries import get_currency_query from .queries import get_currency_query
from .. import db
from ..locale import lazy_gettext
from ..models import Currency
from ..utils.cast import s
from ..utils.flash_errors import flash_form_errors
from ..utils.next_uri import inherit_next, or_next
from ..utils.pagination import Pagination
from ..utils.permission import has_permission, can_view, can_edit
from ..utils.user import get_current_user_pk
bp: Blueprint = Blueprint("currency", __name__) bp: Blueprint = Blueprint("currency", __name__)
"""The view blueprint for the currency management.""" """The view blueprint for the currency management."""
@@ -74,7 +74,7 @@ def show_add_currency_form() -> str:
@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() -> Response:
"""Adds a currency. """Adds a currency.
:return: The redirection to the currency detail on success, or the currency :return: The redirection to the currency detail on success, or the currency
@@ -125,7 +125,7 @@ def show_currency_edit_form(currency: Currency) -> str:
@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) -> Response:
"""Updates a currency. """Updates a currency.
:param currency: The currency. :param currency: The currency.
@@ -153,7 +153,7 @@ def update_currency(currency: Currency) -> redirect:
@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) -> Response:
"""Deletes a currency. """Deletes a currency.
:param currency: The currency. :param currency: The currency.
+6 -7
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -24,10 +24,9 @@ from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError from wtforms import StringField, ValidationError
from wtforms.validators import DataRequired from wtforms.validators import DataRequired
from accounting import db from . import db
from accounting.locale import lazy_gettext from .locale import lazy_gettext
from accounting.models import Currency, Account from .models import Currency, Account
ACCOUNT_REQUIRED: DataRequired = DataRequired( ACCOUNT_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the account.")) lazy_gettext("Please select the account."))
@@ -65,12 +64,12 @@ class IsDebitAccount:
:param message: The error message. :param message: The error message.
""" """
self.__message: str | LazyString = message self.__message: str | LazyString = message
"""The error message."""
def __call__(self, form: FlaskForm, field: StringField) -> None: def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None: if field.data is None:
return return
if re.match(r"^(?:[1235689]|7[5678])", field.data) \ if re.match(r"^(?:[1235689]|7[5678])", field.data) \
and not field.data.startswith("3351-") \
and not field.data.startswith("3353-"): and not field.data.startswith("3353-"):
return return
raise ValidationError(self.__message) raise ValidationError(self.__message)
@@ -85,12 +84,12 @@ class IsCreditAccount:
:param message: The error message. :param message: The error message.
""" """
self.__message: str | LazyString = message self.__message: str | LazyString = message
"""The error message."""
def __call__(self, form: FlaskForm, field: StringField) -> None: def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None: if field.data is None:
return return
if re.match(r"^(?:[123489]|7[1234])", field.data) \ if re.match(r"^(?:[123489]|7[1234])", field.data) \
and not field.data.startswith("3351-") \
and not field.data.startswith("3353-"): and not field.data.startswith("3353-"):
return return
raise ValidationError(self.__message) raise ValidationError(self.__message)
+4 -4
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -22,9 +22,9 @@ import datetime as dt
from flask import abort from flask import abort
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from accounting import db from .. import db
from accounting.models import JournalEntry from ..models import JournalEntry
from accounting.utils.journal_entry_types import JournalEntryType from ..utils.journal_entry_types import JournalEntryType
class JournalEntryConverter(BaseConverter): class JournalEntryConverter(BaseConverter):
+16 -16
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -26,13 +26,13 @@ from wtforms import StringField, ValidationError, FieldList, IntegerField, \
BooleanField, FormField BooleanField, FormField
from wtforms.validators import DataRequired from wtforms.validators import DataRequired
from accounting import db
from accounting.forms import CurrencyExists
from accounting.locale import lazy_gettext
from accounting.models import JournalEntryLineItem
from accounting.utils.offset_alias import offset_alias
from accounting.utils.strip_text import strip_text
from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
from ... import db
from ...forms import CurrencyExists
from ...locale import lazy_gettext
from ...models import JournalEntryLineItem
from ...utils.offset_alias import offset_alias
from ...utils.strip_text import strip_text
CURRENCY_REQUIRED: DataRequired = DataRequired( CURRENCY_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the currency.")) lazy_gettext("Please select the currency."))
@@ -55,7 +55,7 @@ class SameCurrencyAsOriginalLineItems:
return return
original_line_item_currency_codes: set[str] = set(db.session.scalars( original_line_item_currency_codes: set[str] = set(db.session.scalars(
sa.select(JournalEntryLineItem.currency_code) sa.select(JournalEntryLineItem.currency_code)
.filter(JournalEntryLineItem.id.in_(original_line_item_id))).all()) .where(JournalEntryLineItem.id.in_(original_line_item_id))).all())
for currency_code in original_line_item_currency_codes: for currency_code in original_line_item_currency_codes:
if field.data != currency_code: if field.data != currency_code:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
@@ -72,17 +72,17 @@ class KeepCurrencyWhenHavingOffset:
if field.data is None: if field.data is None:
return return
offset: sa.Alias = offset_alias() offset: sa.Alias = offset_alias()
original_line_items: list[JournalEntryLineItem]\ original_line_items: list[JournalEntryLineItem] = db.session.scalars(
= JournalEntryLineItem.query\ sa.select(JournalEntryLineItem)
.join(offset, .join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id, JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\ isouter=True)
.filter(JournalEntryLineItem.id .where(JournalEntryLineItem.id
.in_({x.id.data for x in form.line_items .in_({x.id.data for x in form.line_items
if x.id.data is not None}))\ if x.id.data is not None}))
.group_by(JournalEntryLineItem.id, .group_by(JournalEntryLineItem.id,
JournalEntryLineItem.currency_code)\ JournalEntryLineItem.currency_code)
.having(sa.func.count(offset.c.id) > 0).all() .having(sa.func.count(offset.c.id) > 0)).unique().all()
for original_line_item in original_line_items: for original_line_item in original_line_items:
if original_line_item.currency_code != field.data: if original_line_item.currency_code != field.data:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
@@ -152,7 +152,7 @@ class CurrencyForm(FlaskForm):
line_item_id: set[int] = {x.id.data for x in line_item_forms line_item_id: set[int] = {x.id.data for x in line_item_forms
if x.id.data is not None} if x.id.data is not None}
select: sa.Select = sa.select(sa.func.count(JournalEntryLineItem.id))\ select: sa.Select = sa.select(sa.func.count(JournalEntryLineItem.id))\
.filter(JournalEntryLineItem.original_line_item_id .where(JournalEntryLineItem.original_line_item_id
.in_(line_item_id)) .in_(line_item_id))
return db.session.scalar(select) > 0 return db.session.scalar(select) > 0
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -19,7 +19,6 @@
""" """
import datetime as dt import datetime as dt
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import TypeVar, Generic, Type
import sqlalchemy as sa import sqlalchemy as sa
from flask_babel import LazyString from flask_babel import LazyString
@@ -28,21 +27,20 @@ from wtforms import DateField, FieldList, FormField, TextAreaField, \
BooleanField BooleanField
from wtforms.validators import DataRequired, ValidationError from wtforms.validators import DataRequired, ValidationError
from accounting import db
from accounting.journal_entry.utils.account_option import AccountOption
from accounting.journal_entry.utils.description_editor import DescriptionEditor
from accounting.journal_entry.utils.original_line_items import \
get_selectable_original_line_items
from accounting.locale import lazy_gettext
from accounting.models import JournalEntry, Account, JournalEntryLineItem, \
JournalEntryCurrency
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_multiline_text
from accounting.utils.user import get_current_user_pk
from .currency import CurrencyForm, CashReceiptCurrencyForm, \ from .currency import CurrencyForm, CashReceiptCurrencyForm, \
CashDisbursementCurrencyForm, TransferCurrencyForm CashDisbursementCurrencyForm, TransferCurrencyForm
from .line_item import LineItemForm, DebitLineItemForm, CreditLineItemForm from .line_item import LineItemForm, DebitLineItemForm, CreditLineItemForm
from .reorder import sort_journal_entries_in from .reorder import sort_journal_entries_in
from ..utils.account_option import AccountOption
from ..utils.description_editor import DescriptionEditor
from ..utils.original_line_items import get_selectable_original_line_items
from ... import db
from ...locale import lazy_gettext
from ...models import JournalEntry, Account, JournalEntryLineItem, \
JournalEntryCurrency
from ...utils.random_id import new_id
from ...utils.strip_text import strip_multiline_text
from ...utils.user import get_current_user_pk
DATE_REQUIRED: DataRequired = DataRequired( DATE_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please fill in the date.")) lazy_gettext("Please fill in the date."))
@@ -123,7 +121,7 @@ class JournalEntryForm(FlaskForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.is_modified: bool = False self.is_modified: bool = False
"""Whether the journal entry is modified during populate_obj().""" """Whether the journal entry is modified during populate_obj()."""
self.collector: Type[LineItemCollector] = LineItemCollector self.collector: type[LineItemCollector] = LineItemCollector
"""The line item collector. The default is the base abstract """The line item collector. The default is the base abstract
collector only to provide the correct type. The subclass forms should collector only to provide the correct type. The subclass forms should
provide their own collectors.""" provide their own collectors."""
@@ -151,19 +149,19 @@ class JournalEntryForm(FlaskForm):
is_new: bool = obj.id is None is_new: bool = obj.id is None
if is_new: if is_new:
obj.id = new_id(JournalEntry) obj.id = new_id(JournalEntry)
self.date: DateField
self.__set_date(obj, self.date.data) self.__set_date(obj, self.date.data)
obj.note = self.note.data obj.note = self.note.data
collector_cls: Type[LineItemCollector] = self.collector collector_cls: type[LineItemCollector] = self.collector
collector: collector_cls = collector_cls(self, obj) collector: collector_cls = collector_cls(self, obj)
collector.collect() collector.collect()
to_delete: set[int] = {x.id for x in obj.line_items to_delete: set[int] = {x.id for x in obj.line_items
if x.id not in collector.to_keep} if x.id not in collector.to_keep}
if len(to_delete) > 0: if len(to_delete) > 0:
JournalEntryLineItem.query\ db.session.execute(
.filter(JournalEntryLineItem.id.in_(to_delete)).delete() sa.delete(JournalEntryLineItem)
.where(JournalEntryLineItem.id.in_(to_delete)))
self.is_modified = True self.is_modified = True
if is_new or db.session.is_modified(obj): if is_new or db.session.is_modified(obj):
@@ -198,7 +196,7 @@ class JournalEntryForm(FlaskForm):
if self.max_date is not None and new_date == self.max_date: if self.max_date is not None and new_date == self.max_date:
db_min_no: int | None = db.session.scalar( db_min_no: int | None = db.session.scalar(
sa.select(sa.func.min(JournalEntry.no)) sa.select(sa.func.min(JournalEntry.no))
.filter(JournalEntry.date == new_date)) .where(JournalEntry.date == new_date))
if db_min_no is None: if db_min_no is None:
obj.date = new_date obj.date = new_date
obj.no = 1 obj.no = 1
@@ -208,8 +206,9 @@ class JournalEntryForm(FlaskForm):
sort_journal_entries_in(new_date) sort_journal_entries_in(new_date)
else: else:
sort_journal_entries_in(new_date, obj.id) sort_journal_entries_in(new_date, obj.id)
count: int = JournalEntry.query\ count: int = db.session.scalar(
.filter(JournalEntry.date == new_date).count() sa.select(sa.func.count(JournalEntry.id))
.where(JournalEntry.date == new_date))
obj.date = new_date obj.date = new_date
obj.no = count + 1 obj.no = count + 1
@@ -224,7 +223,7 @@ class JournalEntryForm(FlaskForm):
if not (x.code[0] == "2" and x.is_need_offset)] if not (x.code[0] == "2" and x.is_need_offset)]
in_use: set[int] = set(db.session.scalars( in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntryLineItem.account_id) sa.select(JournalEntryLineItem.account_id)
.filter(JournalEntryLineItem.is_debit) .where(JournalEntryLineItem.is_debit)
.group_by(JournalEntryLineItem.account_id)).all()) .group_by(JournalEntryLineItem.account_id)).all())
for account in accounts: for account in accounts:
account.is_in_use = account.id in in_use account.is_in_use = account.id in in_use
@@ -241,7 +240,7 @@ class JournalEntryForm(FlaskForm):
if not (x.code[0] == "1" and x.is_need_offset)] if not (x.code[0] == "1" and x.is_need_offset)]
in_use: set[int] = set(db.session.scalars( in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntryLineItem.account_id) sa.select(JournalEntryLineItem.account_id)
.filter(sa.not_(JournalEntryLineItem.is_debit)) .where(sa.not_(JournalEntryLineItem.is_debit))
.group_by(JournalEntryLineItem.account_id)).all()) .group_by(JournalEntryLineItem.account_id)).all())
for account in accounts: for account in accounts:
account.is_in_use = account.id in in_use account.is_in_use = account.id in in_use
@@ -291,7 +290,7 @@ class JournalEntryForm(FlaskForm):
return None return None
select: sa.Select = sa.select(sa.func.max(JournalEntry.date))\ select: sa.Select = sa.select(sa.func.max(JournalEntry.date))\
.join(JournalEntryLineItem)\ .join(JournalEntryLineItem)\
.filter(JournalEntryLineItem.id.in_(original_line_item_id)) .where(JournalEntryLineItem.id.in_(original_line_item_id))
return db.session.scalar(select) return db.session.scalar(select)
@property @property
@@ -304,16 +303,12 @@ class JournalEntryForm(FlaskForm):
if x.id.data is not None} if x.id.data is not None}
select: sa.Select = sa.select(sa.func.min(JournalEntry.date))\ select: sa.Select = sa.select(sa.func.min(JournalEntry.date))\
.join(JournalEntryLineItem)\ .join(JournalEntryLineItem)\
.filter(JournalEntryLineItem.original_line_item_id .where(JournalEntryLineItem.original_line_item_id
.in_(line_item_id)) .in_(line_item_id))
return db.session.scalar(select) return db.session.scalar(select)
T = TypeVar("T", bound=JournalEntryForm) class LineItemCollector[T: JournalEntryForm](ABC):
"""A journal entry form variant."""
class LineItemCollector(Generic[T], ABC):
"""The line item collector.""" """The line item collector."""
def __init__(self, form: T, obj: JournalEntry): def __init__(self, form: T, obj: JournalEntry):
+17 -16
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -27,15 +27,15 @@ from sqlalchemy.orm import selectinload
from wtforms import StringField, ValidationError, DecimalField, IntegerField from wtforms import StringField, ValidationError, DecimalField, IntegerField
from wtforms.validators import Optional from wtforms.validators import Optional
from accounting import db from ... import db
from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \ from ...forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
IsCreditAccount IsCreditAccount
from accounting.locale import lazy_gettext from ...locale import lazy_gettext
from accounting.models import Account, JournalEntry, JournalEntryLineItem from ...models import Account, JournalEntry, JournalEntryLineItem
from accounting.template_filters import format_amount from ...template_filters import format_amount
from accounting.utils.random_id import new_id from ...utils.random_id import new_id
from accounting.utils.strip_text import strip_text from ...utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk from ...utils.user import get_current_user_pk
class OriginalLineItemExists: class OriginalLineItemExists:
@@ -202,7 +202,7 @@ class NotExceedingOriginalLineItemNetBalance:
else_=-JournalEntryLineItem.amount)) else_=-JournalEntryLineItem.amount))
offset_total_but_form: Decimal | None = db.session.scalar( offset_total_but_form: Decimal | None = db.session.scalar(
sa.select(offset_total_func) sa.select(offset_total_func)
.filter(JournalEntryLineItem.original_line_item_id .where(JournalEntryLineItem.original_line_item_id
== original_line_item.id, == original_line_item.id,
JournalEntryLineItem.id.not_in(existing_line_item_id))) JournalEntryLineItem.id.not_in(existing_line_item_id)))
if offset_total_but_form is None: if offset_total_but_form is None:
@@ -231,7 +231,7 @@ class NotLessThanOffsetTotal:
(JournalEntryLineItem.is_debit != is_debit, (JournalEntryLineItem.is_debit != is_debit,
JournalEntryLineItem.amount), JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)))\ else_=-JournalEntryLineItem.amount)))\
.filter(JournalEntryLineItem.original_line_item_id == form.id.data) .where(JournalEntryLineItem.original_line_item_id == form.id.data)
offset_total: Decimal | None = db.session.scalar(select_offset_total) offset_total: Decimal | None = db.session.scalar(select_offset_total)
if offset_total is not None and field.data < offset_total: if offset_total is not None and field.data < offset_total:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
@@ -353,13 +353,14 @@ 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.join(JournalEntry)\ return db.session.scalars(
.filter(JournalEntryLineItem.original_line_item_id sa.select(JournalEntryLineItem).join(JournalEntry)
== self.id.data)\ .where(JournalEntryLineItem.original_line_item_id
== self.id.data)
.order_by(JournalEntry.date, JournalEntry.no, .order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.no)\ JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.journal_entry), .options(selectinload(JournalEntryLineItem.journal_entry),
selectinload(JournalEntryLineItem.account)).all() selectinload(JournalEntryLineItem.account))).all()
setattr(self, "__offsets", get_offsets()) setattr(self, "__offsets", get_offsets())
return getattr(self, "__offsets") return getattr(self, "__offsets")
+12 -9
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -22,8 +22,8 @@ import datetime as dt
import sqlalchemy as sa import sqlalchemy as sa
from flask import request from flask import request
from accounting import db from ... import db
from accounting.models import JournalEntry from ...models import JournalEntry
def sort_journal_entries_in(date: dt.date, exclude: int | None = None) -> None: def sort_journal_entries_in(date: dt.date, exclude: int | None = None) -> None:
@@ -34,12 +34,12 @@ def sort_journal_entries_in(date: dt.date, exclude: int | None = None) -> None:
:param exclude: The journal entry ID to exclude. :param exclude: The journal entry ID to exclude.
:return: None. :return: None.
""" """
conditions: list[sa.BinaryExpression] = [JournalEntry.date == date] conditions: list[sa.ColumnElement[bool]] = [JournalEntry.date == date]
if exclude is not None: if exclude is not None:
conditions.append(JournalEntry.id != exclude) conditions.append(JournalEntry.id != exclude)
journal_entries: list[JournalEntry] = JournalEntry.query\ journal_entries: list[JournalEntry] = db.session.scalars(
.filter(*conditions)\ sa.select(JournalEntry).where(*conditions)
.order_by(JournalEntry.no).all() .order_by(JournalEntry.no)).all()
for i in range(len(journal_entries)): for i in range(len(journal_entries)):
if journal_entries[i].no != i + 1: if journal_entries[i].no != i + 1:
journal_entries[i].no = i + 1 journal_entries[i].no = i + 1
@@ -54,15 +54,18 @@ class JournalEntryReorderForm:
:param date: The date. :param date: The date.
""" """
self.date: dt.date = date self.date: dt.date = date
"""The date."""
self.is_modified: bool = False self.is_modified: bool = False
"""Whether the order is modified."""
def save_order(self) -> None: def save_order(self) -> None:
"""Saves the order of the account. """Saves the order of the account.
:return: :return:
""" """
journal_entries: list[JournalEntry] = JournalEntry.query\ journal_entries: list[JournalEntry] = db.session.scalars(
.filter(JournalEntry.date == self.date).all() sa.select(JournalEntry)
.where(JournalEntry.date == self.date)).all()
# Collects the specified order. # Collects the specified order.
orders: dict[JournalEntry, int] = {} orders: dict[JournalEntry, int] = {}
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -17,7 +17,7 @@
"""The account option for the journal entry management. """The account option for the journal entry management.
""" """
from accounting.models import Account from ...models import Account
class AccountOption: class AccountOption:
@@ -28,7 +28,7 @@ class AccountOption:
:param account: The account. :param account: The account.
""" """
self.id: str = 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."""
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -22,9 +22,9 @@ from typing import Literal
import sqlalchemy as sa import sqlalchemy as sa
from accounting import db from ... import db
from accounting.models import Account, JournalEntryLineItem from ...models import Account, JournalEntryLineItem
from accounting.utils.options import options, Recurring from ...utils.options import options, Recurring
class DescriptionAccount: class DescriptionAccount:
@@ -166,8 +166,11 @@ class DescriptionRecurring:
:param account: The account. :param account: The account.
""" """
self.name: str = name self.name: str = name
"""The name."""
self.account: DescriptionAccount = DescriptionAccount(account, 0) self.account: DescriptionAccount = DescriptionAccount(account, 0)
"""The account."""
self.description_template: str = description_template self.description_template: str = description_template
"""The description template."""
@property @property
def account_codes(self) -> list[str]: def account_codes(self) -> list[str]:
@@ -269,15 +272,17 @@ class DescriptionEditor:
select: sa.Select = sa.Select(debit_credit, tag_type, tag, select: sa.Select = sa.Select(debit_credit, tag_type, tag,
JournalEntryLineItem.account_id, JournalEntryLineItem.account_id,
sa.func.count().label("freq"))\ sa.func.count().label("freq"))\
.filter(JournalEntryLineItem.description.is_not(None), .where(JournalEntryLineItem.description.is_not(None),
JournalEntryLineItem.description.like("_%—_%"), JournalEntryLineItem.description.like("_%—_%"),
JournalEntryLineItem.original_line_item_id.is_(None))\ JournalEntryLineItem.original_line_item_id.is_(None))\
.group_by(debit_credit, tag_type, tag, .group_by(debit_credit, tag_type, tag,
JournalEntryLineItem.account_id) JournalEntryLineItem.account_id)
result: list[sa.Row] = db.session.execute(select).all() result: list[sa.Row] = db.session.execute(select).all()
accounts: dict[int, Account] \ accounts: dict[int, Account] \
= {x.id: x for x in Account.query = {x.id: x for x in db.session.scalars(
.filter(Account.id.in_({x.account_id for x in result})).all()} sa.select(Account)
.where(Account.id.in_({x.account_id for x in result})))
.unique()}
debit_credit_dict: dict[Literal["debit", "credit"], debit_credit_dict: dict[Literal["debit", "credit"],
DescriptionDebitCredit] \ DescriptionDebitCredit] \
= {x.debit_credit: x for x in {self.debit, self.credit}} = {x.debit_credit: x for x in {self.debit, self.credit}}
@@ -312,18 +317,19 @@ class DescriptionEditor:
if len(codes) == 0: if len(codes) == 0:
return {} return {}
def get_condition(code0: str) -> sa.BinaryExpression: def get_condition(code0: str) -> sa.ColumnElement[bool]:
m: re.Match = re.match(r"^(\d{4})-(\d{3})$", code0) m: re.Match[str] | None = re.match(r"^(\d{4})-(\d{3})$", code0)
assert m is not None, \ assert m is not None, \
f"Malformed account code \"{code0}\" for regular transactions." f"Malformed account code \"{code0}\" for regular transactions."
return sa.and_(Account.base_code == m.group(1), return sa.and_(Account.base_code == m.group(1),
Account.no == int(m.group(2))) Account.no == int(m.group(2)))
conditions: list[sa.BinaryExpression] \ conditions: list[sa.ColumnElement[bool]] \
= [get_condition(x) for x in codes] = [get_condition(x) for x in codes]
accounts: dict[str, Account] \ accounts: dict[str, Account] \
= {x.code: x for x in = {x.code: x for x in
Account.query.filter(sa.or_(*conditions)).all()} db.session.scalars(
sa.select(Account).where(sa.or_(*conditions))).unique()}
for code in codes: for code in codes:
assert code in accounts, \ assert code in accounts, \
f"Unknown account \"{code}\" for regular transactions." f"Unknown account \"{code}\" for regular transactions."
+12 -13
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -18,18 +18,16 @@
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Type
from flask import render_template, request, abort from flask import render_template, request, abort
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from accounting.journal_entry.forms import JournalEntryForm, \ from ..forms import JournalEntryForm, CashReceiptJournalEntryForm, \
CashReceiptJournalEntryForm, CashDisbursementJournalEntryForm, \ CashDisbursementJournalEntryForm, TransferJournalEntryForm
TransferJournalEntryForm from ..forms.line_item import LineItemForm
from accounting.journal_entry.forms.line_item import LineItemForm from ...models import JournalEntry
from accounting.models import JournalEntry from ...template_globals import default_currency_code
from accounting.template_globals import default_currency_code from ...utils.journal_entry_types import JournalEntryType
from accounting.utils.journal_entry_types import JournalEntryType
class JournalEntryOperator(ABC): class JournalEntryOperator(ABC):
@@ -39,7 +37,7 @@ class JournalEntryOperator(ABC):
@property @property
@abstractmethod @abstractmethod
def form(self) -> Type[JournalEntryForm]: def form(self) -> type[JournalEntryForm]:
"""Returns the form class. """Returns the form class.
:return: The form class. :return: The form class.
@@ -100,7 +98,7 @@ class CashReceiptJournalEntry(JournalEntryOperator):
"""The order when checking the journal entry operator.""" """The order when checking the journal entry operator."""
@property @property
def form(self) -> Type[JournalEntryForm]: def form(self) -> type[JournalEntryForm]:
"""Returns the form class. """Returns the form class.
:return: The form class. :return: The form class.
@@ -170,7 +168,7 @@ class CashDisbursementJournalEntry(JournalEntryOperator):
"""The order when checking the journal entry operator.""" """The order when checking the journal entry operator."""
@property @property
def form(self) -> Type[JournalEntryForm]: def form(self) -> type[JournalEntryForm]:
"""Returns the form class. """Returns the form class.
:return: The form class. :return: The form class.
@@ -243,7 +241,7 @@ class TransferJournalEntry(JournalEntryOperator):
"""The order when checking the journal entry operator.""" """The order when checking the journal entry operator."""
@property @property
def form(self) -> Type[JournalEntryForm]: def form(self) -> type[JournalEntryForm]:
"""Returns the form class. """Returns the form class.
:return: The form class. :return: The form class.
@@ -334,3 +332,4 @@ def get_journal_entry_op(journal_entry: JournalEntry,
key=lambda x: x.CHECK_ORDER): key=lambda x: x.CHECK_ORDER):
if journal_entry_type.is_my_type(journal_entry): if journal_entry_type.is_my_type(journal_entry):
return journal_entry_type return journal_entry_type
assert False
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -22,9 +22,9 @@ from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from accounting import db from ... import db
from accounting.models import Account, JournalEntry, JournalEntryLineItem from ...models import Account, JournalEntry, JournalEntryLineItem
from accounting.utils.offset_alias import offset_alias from ...utils.offset_alias import offset_alias
def get_selectable_original_line_items( def get_selectable_original_line_items(
@@ -46,8 +46,8 @@ def get_selectable_original_line_items(
(offset.c.id.in_(line_item_id_on_form), 0), (offset.c.id.in_(line_item_id_on_form), 0),
(offset.c.is_debit == JournalEntryLineItem.is_debit, offset.c.amount), (offset.c.is_debit == JournalEntryLineItem.is_debit, offset.c.amount),
else_=-offset.c.amount))).label("net_balance") else_=-offset.c.amount))).label("net_balance")
conditions: list[sa.BinaryExpression] = [Account.is_need_offset] conditions: list[sa.ColumnElement[bool]] = [Account.is_need_offset]
sub_conditions: list[sa.BinaryExpression] = [] sub_conditions: list[sa.ColumnElement[bool]] = []
if is_payable: if is_payable:
sub_conditions.append(sa.and_(Account.base_code.startswith("2"), sub_conditions.append(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit))) sa.not_(JournalEntryLineItem.is_debit)))
@@ -61,20 +61,21 @@ def get_selectable_original_line_items(
.join(offset, .join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id, JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\ isouter=True)\
.filter(*conditions)\ .where(*conditions)\
.group_by(JournalEntryLineItem.id)\ .group_by(JournalEntryLineItem.id)\
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0)) .having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
net_balances: dict[int, Decimal] \ net_balances: dict[int, Decimal] \
= {x.id: x.net_balance = {x.id: x.net_balance
for x in db.session.execute(select_net_balances).all()} for x in db.session.execute(select_net_balances)}
line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query\ line_items: list[JournalEntryLineItem] = db.session.scalars(
.filter(JournalEntryLineItem.id.in_({x for x in net_balances}))\ sa.select(JournalEntryLineItem)
.join(JournalEntry)\ .where(JournalEntryLineItem.id.in_({x for x in net_balances}))
.join(JournalEntry)
.order_by(JournalEntry.date, JournalEntry.no, .order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, 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() 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 \
+20 -19
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -22,23 +22,24 @@ from urllib.parse import parse_qsl, urlencode
import sqlalchemy as sa import sqlalchemy as sa
from flask import Blueprint, render_template, session, redirect, request, \ from flask import Blueprint, render_template, session, redirect, request, \
flash, url_for flash, url_for, Response
from werkzeug.datastructures import ImmutableMultiDict from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import JournalEntry
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.journal_entry_types import JournalEntryType
from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.user import get_current_user_pk
from .forms import sort_journal_entries_in, JournalEntryReorderForm from .forms import sort_journal_entries_in, JournalEntryReorderForm
from .template_filters import with_type, to_transfer, format_amount_input, \ from .template_filters import with_type, to_transfer, format_amount_input, \
text2html text2html
from .utils.operators import JournalEntryOperator, JOURNAL_ENTRY_TYPE_TO_OP, \ from .utils.operators import JournalEntryOperator, JOURNAL_ENTRY_TYPE_TO_OP, \
get_journal_entry_op get_journal_entry_op
from .. import db
from ..locale import lazy_gettext
from ..models import JournalEntry
from ..utils.cast import s
from ..utils.flash_errors import flash_form_errors
from ..utils.journal_entry_types import JournalEntryType
from ..utils.next_uri import inherit_next, or_next
from ..utils.permission import has_permission, can_view, can_edit
from ..utils.timezone import get_tz_today
from ..utils.user import get_current_user_pk
bp: Blueprint = Blueprint("journal-entry", __name__) bp: Blueprint = Blueprint("journal-entry", __name__)
"""The view blueprint for the journal entry management.""" """The view blueprint for the journal entry management."""
@@ -67,13 +68,13 @@ def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
form.validate() form.validate()
else: else:
form = journal_entry_op.form() form = journal_entry_op.form()
form.date.data = dt.date.today() form.date.data = get_tz_today()
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) -> Response:
"""Adds a journal entry. """Adds a journal entry.
:param journal_entry_type: The journal entry type. :param journal_entry_type: The journal entry type.
@@ -135,7 +136,7 @@ def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
@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) -> Response:
"""Updates a journal entry. """Updates a journal entry.
:param journal_entry: The journal entry. :param journal_entry: The journal entry.
@@ -168,7 +169,7 @@ def update_journal_entry(journal_entry: JournalEntry) -> redirect:
@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) -> Response:
"""Deletes a journal entry. """Deletes a journal entry.
:param journal_entry: The journal entry. :param journal_entry: The journal entry.
@@ -194,16 +195,16 @@ def show_journal_entry_order(date: dt.date) -> str:
:param date: The date. :param date: The date.
:return: The order of the journal entries in the date. :return: The order of the journal entries in the date.
""" """
journal_entries: list[JournalEntry] = JournalEntry.query \ journal_entries: list[JournalEntry] = db.session.scalars(
.filter(JournalEntry.date == date) \ sa.select(JournalEntry).where(JournalEntry.date == date)
.order_by(JournalEntry.no).all() .order_by(JournalEntry.no)).all()
return render_template("accounting/journal-entry/order.html", return render_template("accounting/journal-entry/order.html",
date=date, list=journal_entries) date=date, list=journal_entries)
@bp.post("dates/<date:date>", endpoint="sort") @bp.post("dates/<date:date>", endpoint="sort")
@has_permission(can_edit) @has_permission(can_edit)
def sort_journal_entries(date: dt.date) -> redirect: def sort_journal_entries(date: dt.date) -> Response:
"""Reorders the journal entries in a date. """Reorders the journal entries in a date.
:param date: The date. :param date: The date.
+3 -2
View File
@@ -25,8 +25,10 @@ from flask_babel import LazyString, Domain
from flask_babel_js import JAVASCRIPT, c2js from flask_babel_js import JAVASCRIPT, c2js
translation_dir: Path = Path(__file__).parent / "translations" translation_dir: Path = Path(__file__).parent / "translations"
"""The directory of the translation files."""
domain: Domain = Domain(translation_directories=[translation_dir], domain: Domain = Domain(translation_directories=[translation_dir],
domain="accounting") domain="accounting")
"""The message domain."""
def gettext(string, **variables) -> str: def gettext(string, **variables) -> str:
@@ -120,6 +122,5 @@ def init_app(app: Flask, bp: Blueprint) -> None:
:param bp: The blueprint of the accounting application. :param bp: The blueprint of the accounting application.
:return: None. :return: None.
""" """
bp.add_url_rule("/_jstrans.js", "babel_catalog", bp.add_url_rule("/_jstrans.js", "babel_catalog", __babel_js_catalog_view)
__babel_js_catalog_view)
app.jinja_env.globals["A_"] = domain.gettext app.jinja_env.globals["A_"] = domain.gettext
+123 -109
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -22,31 +22,30 @@ from __future__ import annotations
import datetime as dt import datetime as dt
import re import re
from decimal import Decimal from decimal import Decimal
from typing import Type, Self from typing import Self
import sqlalchemy as sa import sqlalchemy as sa
from babel import Locale from babel import Locale
from flask_babel import get_locale, get_babel from flask_babel import get_locale, get_babel
from sqlalchemy import text from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm import Mapped, mapped_column
from accounting import db from . import db
from accounting.locale import gettext from .locale import gettext
from accounting.utils.user import user_cls, user_pk_column from .utils.user import base_cls, user_cls, user_pk_column
class BaseAccount(db.Model): class BaseAccount(base_cls):
"""A base account.""" """A base account."""
__tablename__ = "accounting_base_accounts" __tablename__ = "accounting_base_accounts"
"""The table name.""" """The table name."""
code: Mapped[str] = mapped_column(primary_key=True) code: Mapped[str] = mapped_column(primary_key=True)
"""The code.""" """The account code."""
title_l10n: Mapped[str] = mapped_column("title") title_l10n: Mapped[str] = mapped_column("title")
"""The title.""" """The title."""
l10n: Mapped[list[BaseAccountL10n]] \ l10n: Mapped[list[BaseAccountL10n]] \
= db.relationship(back_populates="account", lazy=False) = relationship(back_populates="account", lazy=False)
"""The localized titles.""" """The localized titles."""
accounts: Mapped[list[Account]] = db.relationship(back_populates="base") accounts: Mapped[list[Account]] = relationship(back_populates="base")
"""The descendant accounts under the base account.""" """The descendant accounts under the base account."""
def __str__(self) -> str: def __str__(self) -> str:
@@ -54,7 +53,7 @@ class BaseAccount(db.Model):
:return: The string representation of the base account. :return: The string representation of the base account.
""" """
return f"{self.code} {self.title.title()}" return f"{self.code} {self.title}"
@property @property
def title(self) -> str: def title(self) -> str:
@@ -79,16 +78,16 @@ class BaseAccount(db.Model):
return [self.code, self.title_l10n] + [x.title for x in self.l10n] return [self.code, self.title_l10n] + [x.title for x in self.l10n]
class BaseAccountL10n(db.Model): class BaseAccountL10n(base_cls):
"""A localized base account title.""" """A localized base account title."""
__tablename__ = "accounting_base_accounts_l10n" __tablename__ = "accounting_base_accounts_l10n"
"""The table name.""" """The table name."""
account_code: Mapped[str] \ account_code: Mapped[str] \
= mapped_column(db.ForeignKey(BaseAccount.code, onupdate="CASCADE", = mapped_column(sa.ForeignKey(BaseAccount.code, onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
primary_key=True) primary_key=True)
"""The code of the account.""" """The account code."""
account: Mapped[BaseAccount] = db.relationship(back_populates="l10n") account: Mapped[BaseAccount] = relationship(back_populates="l10n")
"""The account.""" """The account."""
locale: Mapped[str] = mapped_column(primary_key=True) locale: Mapped[str] = mapped_column(primary_key=True)
"""The locale.""" """The locale."""
@@ -96,47 +95,47 @@ class BaseAccountL10n(db.Model):
"""The localized title.""" """The localized title."""
class Account(db.Model): class Account(base_cls):
"""An account.""" """An account."""
__tablename__ = "accounting_accounts" __tablename__ = "accounting_accounts"
"""The table name.""" """The table name."""
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
"""The account ID.""" """The account ID."""
base_code: Mapped[str] \ base_code: Mapped[str] \
= mapped_column(db.ForeignKey(BaseAccount.code, onupdate="CASCADE", = mapped_column(sa.ForeignKey(BaseAccount.code, onupdate="CASCADE",
ondelete="CASCADE")) ondelete="CASCADE"))
"""The code of the base account.""" """The code of the base account."""
base: Mapped[BaseAccount] = db.relationship(back_populates="accounts") base: Mapped[BaseAccount] = relationship(back_populates="accounts")
"""The base account.""" """The base account."""
no: Mapped[int] = mapped_column(default=text("1")) no: Mapped[int] = mapped_column(default=sa.text("1"))
"""The account number under the base account.""" """The account number under the base account."""
title_l10n: Mapped[str] = mapped_column("title") title_l10n: Mapped[str] = mapped_column("title")
"""The title.""" """The title."""
is_need_offset: Mapped[bool] = mapped_column(default=False) is_need_offset: Mapped[bool] = mapped_column(default=False)
"""Whether the journal entry line items of this account need offset.""" """Whether the journal entry line items of this account need offset."""
created_at: Mapped[dt.datetime] \ created_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True), = mapped_column(sa.DateTime(timezone=True),
server_default=db.func.now()) server_default=sa.func.now())
"""The date and time when this record was created.""" """The date and time when this record was created."""
created_by_id: Mapped[int] \ created_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE")) = mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the user who created the record.""" """The ID of the user who created the record."""
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id) created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
"""The user who created the record.""" """The user who created the record."""
updated_at: Mapped[dt.datetime] \ updated_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True), = mapped_column(sa.DateTime(timezone=True),
server_default=db.func.now()) server_default=sa.func.now())
"""The date and time when this record was last updated.""" """The date and time when this record was last updated."""
updated_by_id: Mapped[int] \ updated_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE")) = mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the last user who updated the record.""" """The ID of the last user who updated the record."""
updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id) updated_by: Mapped[user_cls] = relationship(foreign_keys=updated_by_id)
"""The last user who updated the record.""" """The last user who updated the record."""
l10n: Mapped[list[AccountL10n]] \ l10n: Mapped[list[AccountL10n]] \
= db.relationship(back_populates="account", lazy=False) = relationship(back_populates="account", lazy=False)
"""The localized titles.""" """The localized titles."""
line_items: Mapped[list[JournalEntryLineItem]] \ line_items: Mapped[list[JournalEntryLineItem]] \
= db.relationship(back_populates="account") = relationship(back_populates="account")
"""The journal entry line items.""" """The journal entry line items."""
CASH_CODE: str = "1111-001" CASH_CODE: str = "1111-001"
@@ -151,7 +150,7 @@ class Account(db.Model):
:return: The string representation of this account. :return: The string representation of this account.
""" """
return f"{self.base_code}-{self.no:03d} {self.title.title()}" return f"{self.base_code}-{self.no:03d} {self.title}"
@property @property
def code(self) -> str: def code(self) -> str:
@@ -182,6 +181,8 @@ class Account(db.Model):
:param value: The new title. :param value: The new title.
:return: None. :return: None.
""" """
if self.title == value:
return
if self.title_l10n is None: if self.title_l10n is None:
self.title_l10n = value self.title_l10n = value
return return
@@ -266,9 +267,10 @@ class Account(db.Model):
:return: None. :return: None.
""" """
AccountL10n.query.filter(AccountL10n.account == self).delete() db.session.execute(sa.delete(AccountL10n)
cls: Type[Self] = self.__class__ .where(AccountL10n.account == self))
cls.query.filter(cls.id == self.id).delete() cls: type[Self] = self.__class__
db.session.execute(sa.delete(cls).where(cls.id == self.id))
@classmethod @classmethod
def find_by_code(cls, code: str) -> Self | None: def find_by_code(cls, code: str) -> Self | None:
@@ -277,11 +279,12 @@ class Account(db.Model):
:param code: The code. :param code: The code.
:return: The account, or None if this account does not exist. :return: The account, or None if this account does not exist.
""" """
m = re.match(r"^([1-9]{4})-(\d{3})$", code) m: re.Match[str] | None = re.match(r"^([1-9]{4})-(\d{3})$", code)
if m is None: if m is None:
return None return None
return cls.query.filter(cls.base_code == m.group(1), return db.session.scalar(
cls.no == int(m.group(2))).first() sa.select(cls).where(cls.base_code == m.group(1),
cls.no == int(m.group(2))))
@classmethod @classmethod
def selectable_debit(cls) -> list[Self]: def selectable_debit(cls) -> list[Self]:
@@ -290,7 +293,9 @@ class Account(db.Model):
:return: The selectable debit accounts. :return: The selectable debit accounts.
""" """
return cls.query.filter(sa.or_(cls.base_code.startswith("1"), return db.session.scalars(
sa.select(cls)
.where(sa.or_(cls.base_code.startswith("1"),
sa.and_(cls.base_code.startswith("2"), sa.and_(cls.base_code.startswith("2"),
sa.not_(cls.is_need_offset)), sa.not_(cls.is_need_offset)),
cls.base_code.startswith("3"), cls.base_code.startswith("3"),
@@ -302,9 +307,8 @@ class Account(db.Model):
cls.base_code.startswith("78"), cls.base_code.startswith("78"),
cls.base_code.startswith("8"), cls.base_code.startswith("8"),
cls.base_code.startswith("9")), cls.base_code.startswith("9")),
cls.base_code != "3351", cls.base_code != "3353")
cls.base_code != "3353")\ .order_by(cls.base_code, cls.no)).unique().all()
.order_by(cls.base_code, cls.no).all()
@classmethod @classmethod
def selectable_credit(cls) -> list[Self]: def selectable_credit(cls) -> list[Self]:
@@ -313,7 +317,9 @@ class Account(db.Model):
:return: The selectable debit accounts. :return: The selectable debit accounts.
""" """
return cls.query.filter(sa.or_(sa.and_(cls.base_code.startswith("1"), return db.session.scalars(
sa.select(cls)
.where(sa.or_(sa.and_(cls.base_code.startswith("1"),
sa.not_(cls.is_need_offset)), sa.not_(cls.is_need_offset)),
cls.base_code.startswith("2"), cls.base_code.startswith("2"),
cls.base_code.startswith("3"), cls.base_code.startswith("3"),
@@ -324,9 +330,8 @@ class Account(db.Model):
cls.base_code.startswith("74"), cls.base_code.startswith("74"),
cls.base_code.startswith("8"), cls.base_code.startswith("8"),
cls.base_code.startswith("9")), cls.base_code.startswith("9")),
cls.base_code != "3351", cls.base_code != "3353")
cls.base_code != "3353")\ .order_by(cls.base_code, cls.no)).unique().all()
.order_by(cls.base_code, cls.no).all()
@classmethod @classmethod
def cash(cls) -> Self: def cash(cls) -> Self:
@@ -334,7 +339,9 @@ class Account(db.Model):
:return: The cash account :return: The cash account
""" """
return cls.find_by_code(cls.CASH_CODE) account: Self | None = cls.find_by_code(cls.CASH_CODE)
assert account is not None
return account
@classmethod @classmethod
def accumulated_change(cls) -> Self: def accumulated_change(cls) -> Self:
@@ -342,19 +349,21 @@ class Account(db.Model):
:return: The accumulated-change account :return: The accumulated-change account
""" """
return cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE) account: Self | None = cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE)
assert account is not None
return account
class AccountL10n(db.Model): class AccountL10n(base_cls):
"""A localized account title.""" """A localized account title."""
__tablename__ = "accounting_accounts_l10n" __tablename__ = "accounting_accounts_l10n"
"""The table name.""" """The table name."""
account_id: Mapped[int] \ account_id: Mapped[int] \
= mapped_column(db.ForeignKey(Account.id, onupdate="CASCADE", = mapped_column(sa.ForeignKey(Account.id, onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
primary_key=True) primary_key=True)
"""The account ID.""" """The account ID."""
account: Mapped[Account] = db.relationship(back_populates="l10n") account: Mapped[Account] = relationship(back_populates="l10n")
"""The account.""" """The account."""
locale: Mapped[str] = mapped_column(primary_key=True) locale: Mapped[str] = mapped_column(primary_key=True)
"""The locale.""" """The locale."""
@@ -362,38 +371,38 @@ class AccountL10n(db.Model):
"""The localized title.""" """The localized title."""
class Currency(db.Model): class Currency(base_cls):
"""A currency.""" """A currency."""
__tablename__ = "accounting_currencies" __tablename__ = "accounting_currencies"
"""The table name.""" """The table name."""
code: Mapped[str] = mapped_column(primary_key=True) code: Mapped[str] = mapped_column(primary_key=True)
"""The code.""" """The currency code."""
name_l10n: Mapped[str] = mapped_column("name") name_l10n: Mapped[str] = mapped_column("name")
"""The name.""" """The currency name."""
created_at: Mapped[dt.datetime] \ created_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True), = mapped_column(sa.DateTime(timezone=True),
server_default=db.func.now()) server_default=sa.func.now())
"""The date and time when this record was created.""" """The date and time when this record was created."""
created_by_id: Mapped[int] \ created_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE")) = mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the user who created the record.""" """The ID of the user who created the record."""
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id) created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
"""The user who created the record.""" """The user who created the record."""
updated_at: Mapped[dt.datetime] \ updated_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True), = mapped_column(sa.DateTime(timezone=True),
server_default=db.func.now()) server_default=sa.func.now())
"""The date and time when this record was last updated.""" """The date and time when this record was last updated."""
updated_by_id: Mapped[int] \ updated_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE")) = mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the last user who updated the record.""" """The ID of the last user who updated the record."""
updated_by: Mapped[user_cls] \ updated_by: Mapped[user_cls] \
= db.relationship(foreign_keys=updated_by_id) = relationship(foreign_keys=updated_by_id)
"""The last user who updated the record.""" """The last user who updated the record."""
l10n: Mapped[list[CurrencyL10n]] \ l10n: Mapped[list[CurrencyL10n]] \
= db.relationship(back_populates="currency", lazy=False) = relationship(back_populates="currency", lazy=False)
"""The localized names.""" """The localized names."""
line_items: Mapped[list[JournalEntryLineItem]] \ line_items: Mapped[list[JournalEntryLineItem]] \
= db.relationship(back_populates="currency") = relationship(back_populates="currency")
"""The journal entry line items.""" """The journal entry line items."""
def __str__(self) -> str: def __str__(self) -> str:
@@ -424,6 +433,8 @@ class Currency(db.Model):
:param value: The new name. :param value: The new name.
:return: None. :return: None.
""" """
if self.name == value:
return
if self.name_l10n is None: if self.name_l10n is None:
self.name_l10n = value self.name_l10n = value
return return
@@ -456,7 +467,7 @@ class Currency(db.Model):
:return: True if the currency can be deleted, or False otherwise. :return: True if the currency can be deleted, or False otherwise.
""" """
from accounting.template_globals import default_currency_code from .template_globals import default_currency_code
if self.code == default_currency_code(): if self.code == default_currency_code():
return False return False
return len(self.line_items) == 0 return len(self.line_items) == 0
@@ -466,21 +477,22 @@ class Currency(db.Model):
:return: None. :return: None.
""" """
CurrencyL10n.query.filter(CurrencyL10n.currency == self).delete() db.session.execute(
cls: Type[Self] = self.__class__ sa.delete(CurrencyL10n)
cls.query.filter(cls.code == self.code).delete() .where(CurrencyL10n.currency_code == self.code))
db.session.delete(self)
class CurrencyL10n(db.Model): class CurrencyL10n(base_cls):
"""A localized currency name.""" """A localized currency name."""
__tablename__ = "accounting_currencies_l10n" __tablename__ = "accounting_currencies_l10n"
"""The table name.""" """The table name."""
currency_code: Mapped[str] \ currency_code: Mapped[str] \
= mapped_column(db.ForeignKey(Currency.code, onupdate="CASCADE", = mapped_column(sa.ForeignKey(Currency.code, onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
primary_key=True) primary_key=True)
"""The currency code.""" """The currency code."""
currency: Mapped[Currency] = db.relationship(back_populates="l10n") currency: Mapped[Currency] = relationship(back_populates="l10n")
"""The currency.""" """The currency."""
locale: Mapped[str] = mapped_column(primary_key=True) locale: Mapped[str] = mapped_column(primary_key=True)
"""The locale.""" """The locale."""
@@ -531,7 +543,7 @@ class JournalEntryCurrency:
return sum([x.amount for x in self.credit]) return sum([x.amount for x in self.credit])
class JournalEntry(db.Model): class JournalEntry(base_cls):
"""A journal entry.""" """A journal entry."""
__tablename__ = "accounting_journal_entries" __tablename__ = "accounting_journal_entries"
"""The table name.""" """The table name."""
@@ -539,30 +551,30 @@ class JournalEntry(db.Model):
"""The journal entry ID.""" """The journal entry ID."""
date: Mapped[dt.date] date: Mapped[dt.date]
"""The date.""" """The date."""
no: Mapped[int] = mapped_column(default=text("1")) no: Mapped[int] = mapped_column(default=sa.text("1"))
"""The account number under the date.""" """The journal entry number under the date."""
note: Mapped[str | None] note: Mapped[str | None]
"""The note.""" """The note."""
created_at: Mapped[dt.datetime] \ created_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True), = mapped_column(sa.DateTime(timezone=True),
server_default=db.func.now()) server_default=sa.func.now())
"""The date and time when this record was created.""" """The date and time when this record was created."""
created_by_id: Mapped[int] \ created_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE")) = mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the user who created the record.""" """The ID of the user who created the record."""
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id) created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
"""The user who created the record.""" """The user who created the record."""
updated_at: Mapped[dt.datetime] \ updated_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True), = mapped_column(sa.DateTime(timezone=True),
server_default=db.func.now()) server_default=sa.func.now())
"""The date and time when this record was last updated.""" """The date and time when this record was last updated."""
updated_by_id: Mapped[int] \ updated_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE")) = mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the last user who updated the record.""" """The ID of the last user who updated the record."""
updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id) updated_by: Mapped[user_cls] = relationship(foreign_keys=updated_by_id)
"""The last user who updated the record.""" """The last user who updated the record."""
line_items: Mapped[list[JournalEntryLineItem]] \ line_items: Mapped[list[JournalEntryLineItem]] \
= db.relationship(back_populates="journal_entry") = relationship(back_populates="journal_entry")
"""The line items.""" """The line items."""
def __str__(self) -> str: def __str__(self) -> str:
@@ -643,48 +655,49 @@ class JournalEntry(db.Model):
:return: None. :return: None.
""" """
JournalEntryLineItem.query\ db.session.execute(
.filter(JournalEntryLineItem.journal_entry_id == self.id).delete() sa.delete(JournalEntryLineItem)
.where(JournalEntryLineItem.journal_entry_id == self.id))
db.session.delete(self) db.session.delete(self)
class JournalEntryLineItem(db.Model): class JournalEntryLineItem(base_cls):
"""A line item in the journal entry.""" """A line item in the journal entry."""
__tablename__ = "accounting_journal_entry_line_items" __tablename__ = "accounting_journal_entry_line_items"
"""The table name.""" """The table name."""
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
"""The line item ID.""" """The line item ID."""
journal_entry_id: Mapped[int] \ journal_entry_id: Mapped[int] \
= mapped_column(db.ForeignKey(JournalEntry.id, onupdate="CASCADE", = mapped_column(sa.ForeignKey(JournalEntry.id, onupdate="CASCADE",
ondelete="CASCADE")) ondelete="CASCADE"))
"""The journal entry ID.""" """The journal entry ID."""
journal_entry: Mapped[JournalEntry] \ journal_entry: Mapped[JournalEntry] \
= db.relationship(back_populates="line_items") = relationship(back_populates="line_items")
"""The journal entry.""" """The journal entry."""
is_debit: Mapped[bool] is_debit: Mapped[bool]
"""True for a debit line item, or False for a credit line item.""" """True for a debit line item, or False for a credit line item."""
no: Mapped[int] no: Mapped[int]
"""The line item number under the journal entry and debit or credit.""" """The line item number under the journal entry and debit or credit."""
original_line_item_id: Mapped[int | None] \ original_line_item_id: Mapped[int | None] \
= mapped_column(db.ForeignKey(id, onupdate="CASCADE")) = mapped_column(sa.ForeignKey(id, onupdate="CASCADE"))
"""The ID of the original line item.""" """The ID of the original line item."""
original_line_item: Mapped[JournalEntryLineItem | None] \ original_line_item: Mapped[JournalEntryLineItem | None] \
= db.relationship(remote_side=id, passive_deletes=True) = relationship(remote_side=id, passive_deletes=True)
"""The original line item.""" """The original line item."""
currency_code: Mapped[str] \ currency_code: Mapped[str] \
= mapped_column(db.ForeignKey(Currency.code, onupdate="CASCADE")) = mapped_column(sa.ForeignKey(Currency.code, onupdate="CASCADE"))
"""The currency code.""" """The currency code."""
currency: Mapped[Currency] = db.relationship(back_populates="line_items") currency: Mapped[Currency] = relationship(back_populates="line_items")
"""The currency.""" """The currency."""
account_id: Mapped[int] \ account_id: Mapped[int] \
= mapped_column(db.ForeignKey(Account.id, onupdate="CASCADE")) = mapped_column(sa.ForeignKey(Account.id, onupdate="CASCADE"))
"""The account ID.""" """The account ID."""
account: Mapped[Account] \ account: Mapped[Account] \
= db.relationship(back_populates="line_items", lazy=False) = relationship(back_populates="line_items", lazy=False)
"""The account.""" """The account."""
description: Mapped[str | None] description: Mapped[str | None]
"""The description.""" """The description."""
amount: Mapped[Decimal] = mapped_column(db.Numeric(14, 2)) amount: Mapped[Decimal] = mapped_column(sa.Numeric(14, 2))
"""The amount.""" """The amount."""
def __str__(self) -> str: def __str__(self) -> str:
@@ -693,7 +706,7 @@ class JournalEntryLineItem(db.Model):
:return: The string representation of the line item. :return: The string representation of the line item.
""" """
if not hasattr(self, "__str"): if not hasattr(self, "__str"):
from accounting.template_filters import format_date, format_amount from .template_filters import format_date, format_amount
setattr(self, "__str", setattr(self, "__str",
gettext("%(date)s %(description)s %(amount)s", gettext("%(date)s %(description)s %(amount)s",
date=format_date(self.journal_entry.date), date=format_date(self.journal_entry.date),
@@ -809,11 +822,12 @@ class JournalEntryLineItem(db.Model):
:return: The offset items. :return: The offset items.
""" """
if not hasattr(self, "__offsets"): if not hasattr(self, "__offsets"):
cls: Type[Self] = self.__class__ cls: type[Self] = self.__class__
offsets: list[Self] = cls.query.join(JournalEntry)\ offsets: list[Self] = db.session.scalars(
.filter(JournalEntryLineItem.original_line_item_id == self.id)\ sa.select(cls).join(JournalEntry)
.where(cls.original_line_item_id == self.id)
.order_by(JournalEntry.date, JournalEntry.no, .order_by(JournalEntry.date, JournalEntry.no,
cls.is_debit, cls.no).all() cls.is_debit, cls.no)).unique().all()
setattr(self, "__offsets", offsets) setattr(self, "__offsets", offsets)
return getattr(self, "__offsets") return getattr(self, "__offsets")
@@ -874,29 +888,29 @@ class JournalEntryLineItem(db.Model):
format_amount(self.amount)] format_amount(self.amount)]
class Option(db.Model): class Option(base_cls):
"""An option.""" """An option."""
__tablename__ = "accounting_options" __tablename__ = "accounting_options"
"""The table name.""" """The table name."""
name: Mapped[str] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(primary_key=True)
"""The name.""" """The name."""
value: Mapped[str] = mapped_column(db.Text) value: Mapped[str] = mapped_column(sa.Text)
"""The option value.""" """The option value."""
created_at: Mapped[dt.datetime] \ created_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True), = mapped_column(sa.DateTime(timezone=True),
server_default=db.func.now()) server_default=sa.func.now())
"""The date and time when this record was created.""" """The date and time when this record was created."""
created_by_id: Mapped[int] \ created_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE")) = mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the user who created the record.""" """The ID of the user who created the record."""
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id) created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
"""The user who created the record.""" """The user who created the record."""
updated_at: Mapped[dt.datetime] \ updated_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True), = mapped_column(sa.DateTime(timezone=True),
server_default=db.func.now()) server_default=sa.func.now())
"""The date and time when this record was last updated.""" """The date and time when this record was last updated."""
updated_by_id: Mapped[int] \ updated_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE")) = mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the last user who updated the record.""" """The ID of the last user who updated the record."""
updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id) updated_by: Mapped[user_cls] = relationship(foreign_keys=updated_by_id)
"""The last user who updated the record.""" """The last user who updated the record."""
+7 -7
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -23,13 +23,13 @@ from flask_wtf import FlaskForm
from wtforms import StringField, FieldList, FormField, IntegerField from wtforms import StringField, FieldList, FormField, IntegerField
from wtforms.validators import DataRequired, ValidationError from wtforms.validators import DataRequired, ValidationError
from accounting.forms import ACCOUNT_REQUIRED, CurrencyExists, AccountExists, \ from ..forms import ACCOUNT_REQUIRED, CurrencyExists, AccountExists, \
IsDebitAccount, IsCreditAccount IsDebitAccount, IsCreditAccount
from accounting.locale import lazy_gettext from ..locale import lazy_gettext
from accounting.models import Account from ..models import Account
from accounting.utils.current_account import CurrentAccount from ..utils.current_account import CurrentAccount
from accounting.utils.options import Options from ..utils.options import Options
from accounting.utils.strip_text import strip_text from ..utils.strip_text import strip_text
class CurrentAccountExists: class CurrentAccountExists:
+9 -9
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -20,16 +20,16 @@
from urllib.parse import parse_qsl, urlencode from urllib.parse import parse_qsl, urlencode
from flask import Blueprint, render_template, redirect, session, request, \ from flask import Blueprint, render_template, redirect, session, request, \
flash, url_for flash, url_for, Response
from werkzeug.datastructures import ImmutableMultiDict from werkzeug.datastructures import ImmutableMultiDict
from accounting.locale import lazy_gettext
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next
from accounting.utils.options import options
from accounting.utils.permission import has_permission, can_admin
from .forms import OptionForm from .forms import OptionForm
from ..locale import lazy_gettext
from ..utils.cast import s
from ..utils.flash_errors import flash_form_errors
from ..utils.next_uri import inherit_next
from ..utils.options import options
from ..utils.permission import has_permission, can_admin
bp: Blueprint = Blueprint("option", __name__) bp: Blueprint = Blueprint("option", __name__)
"""The view blueprint for the currency management.""" """The view blueprint for the currency management."""
@@ -64,7 +64,7 @@ def show_option_form() -> str:
@bp.post("update", endpoint="update") @bp.post("update", endpoint="update")
@has_permission(can_admin) @has_permission(can_admin)
def update_options() -> redirect: def update_options() -> Response:
"""Updates the options. """Updates the options.
:return: The redirection to the option form. :return: The redirection to the option form.
+3 -3
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -22,9 +22,9 @@ import re
from flask import abort from flask import abort
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from accounting.models import Account
from accounting.utils.current_account import CurrentAccount
from .period import Period, get_period from .period import Period, get_period
from ..models import Account
from ..utils.current_account import CurrentAccount
class PeriodConverter(BaseConverter): class PeriodConverter(BaseConverter):
+9 -5
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -23,10 +23,14 @@ This file is largely taken from the NanoParma ERP project, first written in
import datetime as dt import datetime as dt
from collections.abc import Callable from collections.abc import Callable
from accounting.models import JournalEntry import sqlalchemy as sa
from .period import Period from .period import Period
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \ from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
from ... import db
from ...models import JournalEntry
from ...utils.timezone import get_tz_today
class PeriodChooser: class PeriodChooser:
@@ -61,8 +65,8 @@ class PeriodChooser:
self.url_template: str = get_url(TemplatePeriod()) self.url_template: str = get_url(TemplatePeriod())
"""The URL template.""" """The URL template."""
first: JournalEntry | None \ first: JournalEntry | None = db.session.scalar(
= JournalEntry.query.order_by(JournalEntry.date).first() sa.select(JournalEntry).order_by(JournalEntry.date))
start: dt.date | None = None if first is None else first.date start: dt.date | None = None if first is None else first.date
# Attributes # Attributes
@@ -80,7 +84,7 @@ class PeriodChooser:
"""The available years.""" """The available years."""
if self.has_data: if self.has_data:
today: dt.date = dt.date.today() today: dt.date = get_tz_today()
self.has_last_month = start < dt.date(today.year, today.month, 1) self.has_last_month = start < dt.date(today.year, today.month, 1)
self.has_last_year = start.year < today.year self.has_last_year = start.year < today.year
self.has_yesterday = start < today self.has_yesterday = start < today
+2 -2
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -19,7 +19,7 @@
""" """
import datetime as dt import datetime as dt
from accounting.locale import gettext from ...locale import gettext
def get_desc(start: dt.date | None, end: dt.date | None) -> str: def get_desc(start: dt.date | None, end: dt.date | None) -> str:
+3 -3
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -21,7 +21,6 @@ import calendar
import datetime as dt import datetime as dt
import re import re
from collections.abc import Callable from collections.abc import Callable
from typing import Type
from .period import Period from .period import Period
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \ from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
@@ -40,7 +39,7 @@ def get_period(spec: str | None = None) -> Period:
""" """
if spec is None: if spec is None:
return ThisMonth() return ThisMonth()
named_periods: dict[str, Type[Callable[[], Period]]] = { named_periods: dict[str, type[Callable[[], Period]]] = {
"this-month": lambda: ThisMonth(), "this-month": lambda: ThisMonth(),
"last-month": lambda: LastMonth(), "last-month": lambda: LastMonth(),
"since-last-month": lambda: SinceLastMonth(), "since-last-month": lambda: SinceLastMonth(),
@@ -68,6 +67,7 @@ def __parse_spec(text: str) -> tuple[dt.date | None, dt.date | None]:
""" """
if text == "-": if text == "-":
return None, None return None, None
m: re.Match[str] | None
m = re.match(f"^{DATE_SPEC_RE}$", text) m = re.match(f"^{DATE_SPEC_RE}$", text)
if m is not None: if m is not None:
return __get_start(m[1], m[2], m[3]), \ return __get_start(m[1], m[2], m[3]), \
+1 -1
View File
@@ -42,7 +42,7 @@ class Period:
self.end: dt.date | None = end self.end: dt.date | None = end
"""The end of the period.""" """The end of the period."""
self.is_default: bool = False self.is_default: bool = False
"""Whether the is the default period.""" """Whether this is the default period."""
self.is_this_month: bool = False self.is_this_month: bool = False
"""Whether the period is this month.""" """Whether the period is this month."""
self.is_last_month: bool = False self.is_last_month: bool = False
+10 -9
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -19,15 +19,16 @@
""" """
import datetime as dt import datetime as dt
from accounting.locale import gettext
from .month_end import month_end from .month_end import month_end
from .period import Period from .period import Period
from ...locale import gettext
from ...utils.timezone import get_tz_today
class ThisMonth(Period): class ThisMonth(Period):
"""The period of this month.""" """The period of this month."""
def __init__(self): def __init__(self):
today: dt.date = dt.date.today() today: dt.date = get_tz_today()
this_month_start: dt.date = dt.date(today.year, today.month, 1) this_month_start: dt.date = dt.date(today.year, today.month, 1)
super().__init__(this_month_start, month_end(today)) super().__init__(this_month_start, month_end(today))
self.is_default = True self.is_default = True
@@ -43,7 +44,7 @@ class ThisMonth(Period):
class LastMonth(Period): class LastMonth(Period):
"""The period of this month.""" """The period of this month."""
def __init__(self): def __init__(self):
today: dt.date = dt.date.today() today: dt.date = get_tz_today()
year: int = today.year year: int = today.year
month: int = today.month - 1 month: int = today.month - 1
if month < 1: if month < 1:
@@ -63,7 +64,7 @@ class LastMonth(Period):
class SinceLastMonth(Period): class SinceLastMonth(Period):
"""The period of this month.""" """The period of this month."""
def __init__(self): def __init__(self):
today: dt.date = dt.date.today() today: dt.date = get_tz_today()
year: int = today.year year: int = today.year
month: int = today.month - 1 month: int = today.month - 1
if month < 1: if month < 1:
@@ -82,7 +83,7 @@ class SinceLastMonth(Period):
class ThisYear(Period): class ThisYear(Period):
"""The period of this year.""" """The period of this year."""
def __init__(self): def __init__(self):
year: int = dt.date.today().year year: int = get_tz_today().year
start: dt.date = dt.date(year, 1, 1) start: dt.date = dt.date(year, 1, 1)
end: dt.date = dt.date(year, 12, 31) end: dt.date = dt.date(year, 12, 31)
super().__init__(start, end) super().__init__(start, end)
@@ -97,7 +98,7 @@ class ThisYear(Period):
class LastYear(Period): class LastYear(Period):
"""The period of last year.""" """The period of last year."""
def __init__(self): def __init__(self):
year: int = dt.date.today().year year: int = get_tz_today().year
start: dt.date = dt.date(year - 1, 1, 1) start: dt.date = dt.date(year - 1, 1, 1)
end: dt.date = dt.date(year - 1, 12, 31) end: dt.date = dt.date(year - 1, 12, 31)
super().__init__(start, end) super().__init__(start, end)
@@ -112,7 +113,7 @@ class LastYear(Period):
class Today(Period): class Today(Period):
"""The period of today.""" """The period of today."""
def __init__(self): def __init__(self):
today: dt.date = dt.date.today() today: dt.date = get_tz_today()
super().__init__(today, today) super().__init__(today, today)
self.is_today = True self.is_today = True
@@ -125,7 +126,7 @@ class Today(Period):
class Yesterday(Period): class Yesterday(Period):
"""The period of yesterday.""" """The period of yesterday."""
def __init__(self): def __init__(self):
yesterday: dt.date = dt.date.today() - dt.timedelta(days=1) yesterday: dt.date = get_tz_today() - dt.timedelta(days=1)
super().__init__(yesterday, yesterday) super().__init__(yesterday, yesterday)
self.is_yesterday = True self.is_yesterday = True
+11 -5
View File
@@ -27,12 +27,18 @@ def get_spec(start: dt.date | None, end: dt.date | None) -> str:
:param end: The end of the period. :param end: The end of the period.
:return: The period specification. :return: The period specification.
""" """
if start is None and end is None:
return "-"
if end is None:
return __get_since_spec(start)
if start is None: if start is None:
return __get_until_spec(end) return "-" if end is None else __get_until_spec(end)
return __get_since_spec(start) if end is None else __get_spec(start, end)
def __get_spec(start: dt.date, end: dt.date) -> str:
"""Returns the period specification with both start and end.
:param start: The start of the period.
:param end: The end of the period.
:return: The period specification.
"""
try: try:
return __get_year_spec(start, end) return __get_year_spec(start, end)
except ValueError: except ValueError:
+35 -32
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -22,20 +22,18 @@ from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
from flask import render_template, Response from flask import render_template, Response
from accounting import db from ..period import Period, PeriodChooser
from accounting.locale import gettext from ..utils.base_page_params import BasePageParams
from accounting.models import Currency, BaseAccount, Account, JournalEntry, \ from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from ..utils.option_link import OptionLink
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.urls import ledger_url, balance_sheet_url, income_statement_url
from ... import db
from ...locale import gettext
from ...models import Currency, BaseAccount, Account, JournalEntry, \
JournalEntryLineItem JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser
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, \
period_spec
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.urls import ledger_url, balance_sheet_url, \
income_statement_url
class ReportAccount: class ReportAccount:
@@ -121,9 +119,9 @@ class AccountCollector:
:return: The balances. :return: The balances.
""" """
sub_conditions: list[sa.BinaryExpression] \ sub_conditions: list[sa.ColumnElement[bool]] \
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}] = [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
conditions: list[sa.BinaryExpression] \ conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code, = [JournalEntryLineItem.currency_code == self.__currency.code,
sa.or_(*sub_conditions)] sa.or_(*sub_conditions)]
if self.__period.end is not None: if self.__period.end is not None:
@@ -135,16 +133,18 @@ class AccountCollector:
= sa.select(Account.id, Account.base_code, Account.no, = sa.select(Account.id, Account.base_code, Account.no,
balance_func)\ balance_func)\
.join(JournalEntry).join(Account)\ .join(JournalEntry).join(Account)\
.filter(*conditions)\ .where(*conditions)\
.group_by(Account.id, Account.base_code, Account.no)\ .group_by(Account.id, Account.base_code, Account.no)\
.having(balance_func != 0)\ .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()
self.__all_accounts: list[Account] = Account.query\ self.__all_accounts: list[Account] = db.session.scalars(
.filter(sa.or_(Account.id.in_({x.id for x in account_balances}), sa.select(Account)
.where(sa.or_(Account.id.in_({x.id for x in account_balances}),
Account.base_code == "3351", Account.base_code == "3351",
Account.base_code == "3353")).all() Account.base_code == "3353"))).unique().all()
"""The accounts."""
account_by_id: dict[int, Account] \ account_by_id: dict[int, Account] \
= {x.id: x for x in self.__all_accounts} = {x.id: x for x in self.__all_accounts}
self.accounts: list[ReportAccount] \ self.accounts: list[ReportAccount] \
@@ -154,6 +154,7 @@ class AccountCollector:
account_by_id[x.id], account_by_id[x.id],
self.__period)) self.__period))
for x in account_balances] for x in account_balances]
"""The accounts on the balance sheet."""
self.__add_accumulated() self.__add_accumulated()
self.__add_current_period() self.__add_current_period()
self.accounts.sort(key=lambda x: (x.account.base_code, x.account.no)) self.accounts.sort(key=lambda x: (x.account.base_code, x.account.no))
@@ -178,7 +179,7 @@ class AccountCollector:
""" """
if self.__period.start is None: if self.__period.start is None:
return None return None
conditions: list[sa.BinaryExpression] \ conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code, = [JournalEntryLineItem.currency_code == self.__currency.code,
JournalEntry.date < self.__period.start] JournalEntry.date < self.__period.start]
return self.__query_balance(conditions) return self.__query_balance(conditions)
@@ -197,7 +198,7 @@ class AccountCollector:
:return: The net income or loss for current period. :return: The net income or loss for current period.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code] = [JournalEntryLineItem.currency_code == self.__currency.code]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(JournalEntry.date >= self.__period.start) conditions.append(JournalEntry.date >= self.__period.start)
@@ -206,7 +207,7 @@ class AccountCollector:
return self.__query_balance(conditions) return self.__query_balance(conditions)
@staticmethod @staticmethod
def __query_balance(conditions: list[sa.BinaryExpression])\ def __query_balance(conditions: list[sa.ColumnElement[bool]])\
-> Decimal: -> Decimal:
"""Queries the balance. """Queries the balance.
@@ -219,7 +220,7 @@ class AccountCollector:
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount), (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)) else_=-JournalEntryLineItem.amount))
select_balance: sa.Select = sa.select(balance_func)\ select_balance: sa.Select = sa.select(balance_func)\
.join(JournalEntry).join(Account).filter(*conditions) .join(JournalEntry).join(Account).where(*conditions)
return db.session.scalar(select_balance) return db.session.scalar(select_balance)
def __add_owner_s_equity(self, code: str, amount: Decimal | None, def __add_owner_s_equity(self, code: str, amount: Decimal | None,
@@ -383,11 +384,13 @@ class BalanceSheet(BaseReport):
balances: list[ReportAccount] = AccountCollector( balances: list[ReportAccount] = AccountCollector(
self.__currency, self.__period).accounts self.__currency, self.__period).accounts
titles: list[BaseAccount] = BaseAccount.query\ titles: list[BaseAccount] = db.session.scalars(
.filter(BaseAccount.code.in_({"1", "2", "3"})).all() sa.select(BaseAccount)
subtitles: list[BaseAccount] = BaseAccount.query\ .where(BaseAccount.code.in_({"1", "2", "3"}))).unique().all()
.filter(BaseAccount.code.in_({x.account.base_code[:2] subtitle_codes: set[str] = {x.account.base_code[:2] for x in balances}
for x in balances})).all() subtitles: list[BaseAccount] = db.session.scalars(
sa.select(BaseAccount)
.where(BaseAccount.code.in_(subtitle_codes))).unique().all()
sections: dict[str, Section] = {x.code: Section(x) for x in titles} sections: dict[str, Section] = {x.code: Section(x) for x in titles}
subsections: dict[str, Subsection] = {x.code: Subsection(x) subsections: dict[str, Subsection] = {x.code: Subsection(x)
@@ -452,11 +455,11 @@ class BalanceSheet(BaseReport):
:return: The CSV rows for the section. :return: The CSV rows for the section.
""" """
rows: list[CSVHalfRow] \ rows: list[CSVHalfRow] \
= [CSVHalfRow(section.title.title.title(), None)] = [CSVHalfRow(section.title.title, None)]
for subsection in section.subsections: for subsection in section.subsections:
rows.append(CSVHalfRow(f" {subsection.title.title.title()}", None)) rows.append(CSVHalfRow(f" {subsection.title.title}", None))
for account in subsection.accounts: for account in subsection.accounts:
rows.append(CSVHalfRow(f" {str(account.account).title()}", rows.append(CSVHalfRow(f" {str(account.account)}",
account.amount)) account.amount))
return rows return rows
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -24,21 +24,19 @@ import sqlalchemy as sa
from flask import url_for, render_template, Response from flask import url_for, render_template, Response
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from accounting import db from ..period import Period, PeriodChooser
from accounting.locale import gettext from ..utils.base_page_params import BasePageParams
from accounting.models import Currency, Account, JournalEntry, \ from ..utils.base_report import BaseReport
JournalEntryLineItem from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from accounting.report.period import Period, PeriodChooser from ..utils.option_link import OptionLink
from accounting.report.utils.base_page_params import BasePageParams from ..utils.report_chooser import ReportChooser
from accounting.report.utils.base_report import BaseReport from ..utils.report_type import ReportType
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \ from ..utils.urls import income_expenses_url
period_spec from ... import db
from accounting.report.utils.option_link import OptionLink from ...locale import gettext
from accounting.report.utils.report_chooser import ReportChooser from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
from accounting.report.utils.report_type import ReportType from ...utils.current_account import CurrentAccount
from accounting.report.utils.urls import income_expenses_url from ...utils.pagination import Pagination
from accounting.utils.current_account import CurrentAccount
from accounting.utils.pagination import Pagination
class ReportLineItem: class ReportLineItem:
@@ -119,12 +117,12 @@ class LineItemCollector:
balance_func: sa.Function = sa.func.sum(sa.case( balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount), (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)) else_=-JournalEntryLineItem.amount))
select: sa.Select = sa.Select(balance_func)\ select: sa.Select[tuple[Decimal]] = sa.Select(balance_func)\
.join(JournalEntry).join(Account)\ .join(JournalEntry).join(Account)\
.filter(JournalEntryLineItem.currency_code == self.__currency.code, .where(JournalEntryLineItem.currency_code == self.__currency.code,
self.__account_condition, self.__account_condition,
JournalEntry.date < self.__period.start) JournalEntry.date < self.__period.start)
balance: int | None = db.session.scalar(select) balance: Decimal | None = db.session.scalar(select)
if balance is None: if balance is None:
return None return None
line_item: ReportLineItem = ReportLineItem() line_item: ReportLineItem = ReportLineItem()
@@ -144,7 +142,7 @@ class LineItemCollector:
:return: The line items. :return: The line items.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code, = [JournalEntryLineItem.currency_code == self.__currency.code,
self.__account_condition] self.__account_condition]
if self.__period.start is not None: if self.__period.start is not None:
@@ -152,12 +150,12 @@ class LineItemCollector:
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(JournalEntry.date <= self.__period.end) conditions.append(JournalEntry.date <= self.__period.end)
journal_entry_with_account: sa.Select = sa.Select(JournalEntry.id).\ journal_entry_with_account: sa.Select = sa.Select(JournalEntry.id).\
join(JournalEntryLineItem).join(Account).filter(*conditions) join(JournalEntryLineItem).join(Account).where(*conditions)
return [ReportLineItem(x) return [ReportLineItem(x) for x in db.session.scalars(
for x in JournalEntryLineItem.query sa.select(JournalEntryLineItem)
.join(JournalEntry).join(Account) .join(JournalEntry).join(Account)
.filter(JournalEntryLineItem.journal_entry_id .where(JournalEntryLineItem.journal_entry_id
.in_(journal_entry_with_account), .in_(journal_entry_with_account),
JournalEntryLineItem.currency_code JournalEntryLineItem.currency_code
== self.__currency.code, == self.__currency.code,
@@ -167,10 +165,10 @@ class LineItemCollector:
JournalEntryLineItem.is_debit, JournalEntryLineItem.is_debit,
JournalEntryLineItem.no) JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.account), .options(selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.journal_entry))] selectinload(JournalEntryLineItem.journal_entry)))]
@property @property
def __account_condition(self) -> sa.BinaryExpression: def __account_condition(self) -> sa.ColumnElement[bool]:
if self.__account.code == CurrentAccount.CURRENT_AL_CODE: if self.__account.code == CurrentAccount.CURRENT_AL_CODE:
return CurrentAccount.sql_condition() return CurrentAccount.sql_condition()
return Account.id == self.__account.id return Account.id == self.__account.id
@@ -345,7 +343,7 @@ class PageParams(BasePageParams):
self.account.id == 0)] self.account.id == 0)]
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\ in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
.join(Account)\ .join(Account)\
.filter(JournalEntryLineItem.currency_code == self.currency.code, .where(JournalEntryLineItem.currency_code == self.currency.code,
CurrentAccount.sql_condition())\ CurrentAccount.sql_condition())\
.group_by(JournalEntryLineItem.account_id) .group_by(JournalEntryLineItem.account_id)
options.extend([OptionLink(str(x), options.extend([OptionLink(str(x),
@@ -354,8 +352,10 @@ class PageParams(BasePageParams):
CurrentAccount(x), CurrentAccount(x),
self.period), self.period),
x.id == self.account.id) x.id == self.account.id)
for x in Account.query.filter(Account.id.in_(in_use)) for x in db.session.scalars(
.order_by(Account.base_code, Account.no).all()]) sa.select(Account).where(Account.id.in_(in_use))
.order_by(Account.base_code, Account.no))
.unique()])
return options return options
@@ -407,13 +407,13 @@ class IncomeExpenses(BaseReport):
gettext("Note"))] gettext("Note"))]
if self.__brought_forward is not None: if self.__brought_forward is not None:
rows.append(CSVRow(self.__brought_forward.date, rows.append(CSVRow(self.__brought_forward.date,
str(self.__brought_forward.account).title(), str(self.__brought_forward.account),
self.__brought_forward.description, self.__brought_forward.description,
self.__brought_forward.income, self.__brought_forward.income,
self.__brought_forward.expense, self.__brought_forward.expense,
self.__brought_forward.balance, self.__brought_forward.balance,
None)) None))
rows.extend([CSVRow(x.date, str(x.account).title(), x.description, rows.extend([CSVRow(x.date, str(x.account), x.description,
x.income, x.expense, x.balance, x.note) x.income, x.expense, x.balance, x.note)
for x in self.__line_items]) for x in self.__line_items])
if self.__total is not None: if self.__total is not None:
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -22,19 +22,18 @@ from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
from flask import render_template, Response from flask import render_template, Response
from accounting import db from ..period import Period, PeriodChooser
from accounting.locale import gettext from ..utils.base_page_params import BasePageParams
from accounting.models import Currency, BaseAccount, Account, JournalEntry, \ from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from ..utils.option_link import OptionLink
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.urls import ledger_url, income_statement_url
from ... import db
from ...locale import gettext
from ...models import Currency, BaseAccount, Account, JournalEntry, \
JournalEntryLineItem JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser
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, \
period_spec
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.urls import ledger_url, income_statement_url
class ReportAccount: class ReportAccount:
@@ -106,6 +105,7 @@ class Section:
"""The subsections in the section.""" """The subsections in the section."""
self.accumulated: AccumulatedTotal \ self.accumulated: AccumulatedTotal \
= AccumulatedTotal(accumulated_title) = AccumulatedTotal(accumulated_title)
"""The accumulated total."""
@property @property
def total(self) -> Decimal: def total(self) -> Decimal:
@@ -218,19 +218,22 @@ class IncomeStatement(BaseReport):
""" """
balances: list[ReportAccount] = self.__query_balances() balances: list[ReportAccount] = self.__query_balances()
titles: list[BaseAccount] = BaseAccount.query\ title_codes: set[str] = {"4", "5", "6", "7", "8", "9"}
.filter(BaseAccount.code.in_({"4", "5", "6", "7", "8", "9"})).all() titles: list[BaseAccount] = db.session.scalars(
subtitles: list[BaseAccount] = BaseAccount.query\ sa.select(BaseAccount)
.filter(BaseAccount.code.in_({x.account.base_code[:2] .where(BaseAccount.code.in_(title_codes))).unique().all()
for x in balances})).all() subtitle_codes: set[str] = {x.account.base_code[:2] for x in balances}
subtitles: list[BaseAccount] = db.session.scalars(
sa.select(BaseAccount)
.where(BaseAccount.code.in_(subtitle_codes))).unique().all()
total_titles: dict[str, str] \ total_titles: dict[str, str] \
= {"4": gettext("total operating revenue"), = {"4": gettext("Total Operating Revenue"),
"5": gettext("gross income"), "5": gettext("Gross Income"),
"6": gettext("operating income"), "6": gettext("Operating Income"),
"7": gettext("before tax income"), "7": gettext("Before Tax Income"),
"8": gettext("after tax income"), "8": gettext("After Tax Income"),
"9": gettext("net income or loss for current period")} "9": gettext("Net Income or Loss for Current Period")}
sections: dict[str, Section] \ sections: dict[str, Section] \
= {x.code: Section(x, total_titles[x.code]) for x in titles} = {x.code: Section(x, total_titles[x.code]) for x in titles}
@@ -253,9 +256,9 @@ class IncomeStatement(BaseReport):
:return: The balances. :return: The balances.
""" """
sub_conditions: list[sa.BinaryExpression] \ sub_conditions: list[sa.ColumnElement[bool]] \
= [Account.base_code.startswith(str(x)) for x in range(4, 10)] = [Account.base_code.startswith(str(x)) for x in range(4, 10)]
conditions: list[sa.BinaryExpression] \ conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code, = [JournalEntryLineItem.currency_code == self.__currency.code,
sa.or_(*sub_conditions)] sa.or_(*sub_conditions)]
if self.__period.start is not None: if self.__period.start is not None:
@@ -267,14 +270,15 @@ class IncomeStatement(BaseReport):
else_=JournalEntryLineItem.amount)).label("balance") else_=JournalEntryLineItem.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\ select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(JournalEntry).join(Account)\ .join(JournalEntry).join(Account)\
.filter(*conditions)\ .where(*conditions)\
.group_by(Account.id)\ .group_by(Account.id)\
.having(balance_func != 0)\ .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] \
= {x.id: x for x in Account.query = {x.id: x for x in db.session.scalars(
.filter(Account.id.in_([x.id for x in balances])).all()} sa.select(Account)
.where(Account.id.in_([x.id for x in balances]))).unique()}
return [ReportAccount(account=accounts[x.id], return [ReportAccount(account=accounts[x.id],
amount=x.balance, amount=x.balance,
url=ledger_url(self.__currency, url=ledger_url(self.__currency,
@@ -300,14 +304,14 @@ class IncomeStatement(BaseReport):
total_str: str = gettext("Total") total_str: str = gettext("Total")
rows: list[CSVRow] = [CSVRow(None, gettext("Amount"))] rows: list[CSVRow] = [CSVRow(None, gettext("Amount"))]
for section in self.__sections: for section in self.__sections:
rows.append(CSVRow(str(section.title).title(), None)) rows.append(CSVRow(str(section.title), None))
for subsection in section.subsections: for subsection in section.subsections:
rows.append(CSVRow(f" {str(subsection.title).title()}", None)) rows.append(CSVRow(f" {str(subsection.title)}", None))
for account in subsection.accounts: for account in subsection.accounts:
rows.append(CSVRow(f" {str(account.account).title()}", rows.append(CSVRow(f" {str(account.account)}",
account.amount)) account.amount))
rows.append(CSVRow(f" {total_str}", subsection.total)) rows.append(CSVRow(f" {total_str}", subsection.total))
rows.append(CSVRow(section.accumulated.title.title(), rows.append(CSVRow(section.accumulated.title,
section.accumulated.amount)) section.accumulated.amount))
rows.append(CSVRow(None, None)) rows.append(CSVRow(None, None))
rows = rows[:-1] rows = rows[:-1]
+19 -19
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -24,18 +24,17 @@ import sqlalchemy as sa
from flask import render_template, Response from flask import render_template, Response
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from accounting.locale import gettext from ..period import Period, PeriodChooser
from accounting.models import Currency, Account, JournalEntry, \ from ..utils.base_page_params import BasePageParams
JournalEntryLineItem from ..utils.base_report import BaseReport
from accounting.report.period import Period, PeriodChooser from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from accounting.report.utils.base_page_params import BasePageParams from ..utils.report_chooser import ReportChooser
from accounting.report.utils.base_report import BaseReport from ..utils.report_type import ReportType
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \ from ..utils.urls import journal_url
period_spec from ... import db
from accounting.report.utils.report_chooser import ReportChooser from ...locale import gettext
from accounting.report.utils.report_type import ReportType from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
from accounting.report.utils.urls import journal_url from ...utils.pagination import Pagination
from accounting.utils.pagination import Pagination
class ReportLineItem: class ReportLineItem:
@@ -160,7 +159,7 @@ def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
gettext("Debit"), gettext("Credit"), gettext("Debit"), gettext("Credit"),
gettext("Note"))] gettext("Note"))]
rows.extend([CSVRow(x.journal_entry.date, x.currency.code, rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
str(x.account).title(), x.description, str(x.account), x.description,
x.debit, x.credit, x.journal_entry.note) x.debit, x.credit, x.journal_entry.note)
for x in line_items]) for x in line_items])
return rows return rows
@@ -185,20 +184,21 @@ class Journal(BaseReport):
:return: The line items. :return: The line items.
""" """
conditions: list[sa.BinaryExpression] = [] conditions: list[sa.ColumnElement[bool]] = []
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(JournalEntry.date >= self.__period.start) conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(JournalEntry.date <= self.__period.end) conditions.append(JournalEntry.date <= self.__period.end)
return JournalEntryLineItem.query.join(JournalEntry)\ return db.session.scalars(
.filter(*conditions)\ sa.select(JournalEntryLineItem).join(JournalEntry)
.where(*conditions)
.order_by(JournalEntry.date, .order_by(JournalEntry.date,
JournalEntry.no, JournalEntry.no,
JournalEntryLineItem.is_debit.desc(), JournalEntryLineItem.is_debit.desc(),
JournalEntryLineItem.no)\ JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.account), .options(selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.currency), selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all() selectinload(JournalEntryLineItem.journal_entry))).all()
def csv(self) -> Response: def csv(self) -> Response:
"""Returns the report as CSV for download. """Returns the report as CSV for download.
+24 -25
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -24,20 +24,18 @@ import sqlalchemy as sa
from flask import url_for, render_template, Response from flask import url_for, render_template, Response
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from accounting import db from ..period import Period, PeriodChooser
from accounting.locale import gettext from ..utils.base_page_params import BasePageParams
from accounting.models import Currency, Account, JournalEntry, \ from ..utils.base_report import BaseReport
JournalEntryLineItem from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from accounting.report.period import Period, PeriodChooser from ..utils.option_link import OptionLink
from accounting.report.utils.base_page_params import BasePageParams from ..utils.report_chooser import ReportChooser
from accounting.report.utils.base_report import BaseReport from ..utils.report_type import ReportType
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \ from ..utils.urls import ledger_url
period_spec from ... import db
from accounting.report.utils.option_link import OptionLink from ...locale import gettext
from accounting.report.utils.report_chooser import ReportChooser from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
from accounting.report.utils.report_type import ReportType from ...utils.pagination import Pagination
from accounting.report.utils.urls import ledger_url
from accounting.utils.pagination import Pagination
class ReportLineItem: class ReportLineItem:
@@ -117,7 +115,7 @@ class LineItemCollector:
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount), (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)) else_=-JournalEntryLineItem.amount))
select: sa.Select = sa.Select(balance_func).join(JournalEntry)\ select: sa.Select = sa.Select(balance_func).join(JournalEntry)\
.filter(JournalEntryLineItem.currency_code == self.__currency.code, .where(JournalEntryLineItem.currency_code == self.__currency.code,
JournalEntryLineItem.account_id == self.__account.id, JournalEntryLineItem.account_id == self.__account.id,
JournalEntry.date < self.__period.start) JournalEntry.date < self.__period.start)
balance: int | None = db.session.scalar(select) balance: int | None = db.session.scalar(select)
@@ -139,22 +137,22 @@ class LineItemCollector:
:return: The line items. :return: The line items.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code, = [JournalEntryLineItem.currency_code == self.__currency.code,
JournalEntryLineItem.account_id == self.__account.id] JournalEntryLineItem.account_id == self.__account.id]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(JournalEntry.date >= self.__period.start) conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(JournalEntry.date <= self.__period.end) conditions.append(JournalEntry.date <= self.__period.end)
return [ReportLineItem(x) for x in JournalEntryLineItem.query return [ReportLineItem(x) for x in db.session.scalars(
.join(JournalEntry) sa.select(JournalEntryLineItem).join(JournalEntry)
.filter(*conditions) .where(*conditions)
.order_by(JournalEntry.date, .order_by(JournalEntry.date,
JournalEntry.no, JournalEntry.no,
JournalEntryLineItem.is_debit.desc(), JournalEntryLineItem.is_debit.desc(),
JournalEntryLineItem.no) JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.journal_entry)) .options(selectinload(JournalEntryLineItem.journal_entry)))
.all()] .unique()]
def __get_total(self) -> ReportLineItem | None: def __get_total(self) -> ReportLineItem | None:
"""Composes the total line item. """Composes the total line item.
@@ -310,12 +308,13 @@ class PageParams(BasePageParams):
:return: The account options. :return: The account options.
""" """
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\ in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
.filter(JournalEntryLineItem.currency_code == self.currency.code)\ .where(JournalEntryLineItem.currency_code == self.currency.code)\
.group_by(JournalEntryLineItem.account_id) .group_by(JournalEntryLineItem.account_id)
return [OptionLink(str(x), ledger_url(self.currency, x, self.period), return [OptionLink(str(x), ledger_url(self.currency, x, self.period),
x.id == self.account.id) x.id == self.account.id)
for x in Account.query.filter(Account.id.in_(in_use)) for x in db.session.scalars(
.order_by(Account.base_code, Account.no).all()] sa.select(Account).where(Account.id.in_(in_use))
.order_by(Account.base_code, Account.no)).unique()]
class Ledger(BaseReport): class Ledger(BaseReport):
+28 -26
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -24,17 +24,18 @@ import sqlalchemy as sa
from flask import Response, render_template, request from flask import Response, render_template, request
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from accounting.locale import gettext
from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
JournalEntry, 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 csv_download
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.utils.pagination import Pagination
from accounting.utils.query import parse_query_keywords
from .journal import get_csv_rows from .journal import get_csv_rows
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import csv_download
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ... import db
from ...locale import gettext
from ...models import Currency, CurrencyL10n, Account, AccountL10n, \
JournalEntry, JournalEntryLineItem
from ...utils.pagination import Pagination
from ...utils.query import parse_query_keywords
class LineItemCollector: class LineItemCollector:
@@ -53,9 +54,9 @@ class LineItemCollector:
keywords: list[str] = parse_query_keywords(request.args.get("q")) keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0: if len(keywords) == 0:
return [] return []
conditions: list[sa.BinaryExpression] = [] conditions: list[sa.ColumnElement[bool]] = []
for k in keywords: for k in keywords:
sub_conditions: list[sa.BinaryExpression] \ sub_conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.description.icontains(k), = [JournalEntryLineItem.description.icontains(k),
JournalEntryLineItem.account_id.in_( JournalEntryLineItem.account_id.in_(
self.__get_account_condition(k)), self.__get_account_condition(k)),
@@ -69,15 +70,16 @@ class LineItemCollector:
except ArithmeticError: except ArithmeticError:
pass pass
conditions.append(sa.or_(*sub_conditions)) conditions.append(sa.or_(*sub_conditions))
return JournalEntryLineItem.query.join(JournalEntry)\ return db.session.scalars(
.filter(*conditions)\ sa.select(JournalEntryLineItem).join(JournalEntry)
.where(*conditions)
.order_by(JournalEntry.date, .order_by(JournalEntry.date,
JournalEntry.no, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.is_debit,
JournalEntryLineItem.no)\ JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.account), .options(selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.currency), selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all() selectinload(JournalEntryLineItem.journal_entry))).all()
@staticmethod @staticmethod
def __get_account_condition(k: str) -> sa.Select: def __get_account_condition(k: str) -> sa.Select:
@@ -86,20 +88,20 @@ class LineItemCollector:
:param k: The keyword. :param k: The keyword.
:return: The condition to filter the account. :return: The condition to filter the account.
""" """
code: sa.BinaryExpression = Account.base_code + "-" \ code: sa.ColumnElement[str] = Account.base_code + "-" \
+ sa.func.substr("000" + sa.cast(Account.no, sa.String), + sa.func.substr("000" + sa.cast(Account.no, sa.String),
sa.func.char_length(sa.cast(Account.no, sa.func.char_length(sa.cast(Account.no,
sa.String)) + 1) sa.String)) + 1)
select_l10n: sa.Select = sa.select(AccountL10n.account_id)\ select_l10n: sa.Select = sa.select(AccountL10n.account_id)\
.filter(AccountL10n.title.icontains(k)) .where(AccountL10n.title.icontains(k))
conditions: list[sa.BinaryExpression] \ conditions: list[sa.ColumnElement[bool]] \
= [Account.base_code.contains(k), = [Account.base_code.contains(k),
Account.title_l10n.icontains(k), Account.title_l10n.icontains(k),
code.contains(k), code.contains(k),
Account.id.in_(select_l10n)] Account.id.in_(select_l10n)]
if k in gettext("Needs Offset"): if k in gettext("Needs Offset"):
conditions.append(Account.is_need_offset) conditions.append(Account.is_need_offset)
return sa.select(Account.id).filter(sa.or_(*conditions)) return sa.select(Account.id).where(sa.or_(*conditions))
@staticmethod @staticmethod
def __get_currency_condition(k: str) -> sa.Select: def __get_currency_condition(k: str) -> sa.Select:
@@ -109,9 +111,9 @@ class LineItemCollector:
:return: The condition to filter the currency. :return: The condition to filter the currency.
""" """
select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\ select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\
.filter(CurrencyL10n.name.icontains(k)) .where(CurrencyL10n.name.icontains(k))
return sa.select(Currency.code).filter( return sa.select(Currency.code)\
sa.or_(Currency.code.icontains(k), .where(sa.or_(Currency.code.icontains(k),
Currency.name_l10n.icontains(k), Currency.name_l10n.icontains(k),
Currency.code.in_(select_l10n))) Currency.code.in_(select_l10n)))
@@ -122,7 +124,7 @@ class LineItemCollector:
:param k: The keyword. :param k: The keyword.
:return: The condition to filter the journal entry. :return: The condition to filter the journal entry.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.ColumnElement[bool]] \
= [JournalEntry.note.icontains(k)] = [JournalEntry.note.icontains(k)]
date: dt.datetime date: dt.datetime
try: try:
@@ -153,7 +155,7 @@ class LineItemCollector:
sa.extract("day", JournalEntry.date) == date.day)) sa.extract("day", JournalEntry.date) == date.day))
except ValueError: except ValueError:
pass pass
return sa.select(JournalEntry.id).filter(sa.or_(*conditions)) return sa.select(JournalEntry.id).where(sa.or_(*conditions))
class PageParams(BasePageParams): class PageParams(BasePageParams):
+18 -19
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -22,19 +22,17 @@ from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
from flask import Response, render_template from flask import Response, render_template
from accounting import db from ..period import Period, PeriodChooser
from accounting.locale import gettext from ..utils.base_page_params import BasePageParams
from accounting.models import Currency, Account, JournalEntry, \ from ..utils.base_report import BaseReport
JournalEntryLineItem from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from accounting.report.period import Period, PeriodChooser from ..utils.option_link import OptionLink
from accounting.report.utils.base_page_params import BasePageParams from ..utils.report_chooser import ReportChooser
from accounting.report.utils.base_report import BaseReport from ..utils.report_type import ReportType
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \ from ..utils.urls import ledger_url, trial_balance_url
period_spec from ... import db
from accounting.report.utils.option_link import OptionLink from ...locale import gettext
from accounting.report.utils.report_chooser import ReportChooser from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url, trial_balance_url
class ReportAccount: class ReportAccount:
@@ -178,7 +176,7 @@ class TrialBalance(BaseReport):
:return: None. :return: None.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code] = [JournalEntryLineItem.currency_code == self.__currency.code]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(JournalEntry.date >= self.__period.start) conditions.append(JournalEntry.date >= self.__period.start)
@@ -189,14 +187,15 @@ class TrialBalance(BaseReport):
else_=-JournalEntryLineItem.amount)).label("balance") else_=-JournalEntryLineItem.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\ select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(JournalEntry).join(Account)\ .join(JournalEntry).join(Account)\
.filter(*conditions)\ .where(*conditions)\
.group_by(Account.id)\ .group_by(Account.id)\
.having(balance_func != 0)\ .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] \
= {x.id: x for x in Account.query = {x.id: x for x in db.session.scalars(
.filter(Account.id.in_([x.id for x in balances])).all()} sa.select(Account)
.where(Account.id.in_([x.id for x in balances]))).unique()}
self.__accounts = [ReportAccount(account=accounts[x.id], self.__accounts = [ReportAccount(account=accounts[x.id],
amount=x.balance, amount=x.balance,
url=ledger_url(self.__currency, url=ledger_url(self.__currency,
@@ -224,7 +223,7 @@ class TrialBalance(BaseReport):
""" """
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Debit"), rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Debit"),
gettext("Credit"))] gettext("Credit"))]
rows.extend([CSVRow(str(x.account).title(), x.debit, x.credit) rows.extend([CSVRow(str(x.account), x.debit, x.credit)
for x in self.__accounts]) for x in self.__accounts])
rows.append(CSVRow(gettext("Total"), self.__total.debit, rows.append(CSVRow(gettext("Total"), self.__total.debit,
self.__total.credit)) self.__total.credit))
+20 -19
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 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.
@@ -20,22 +20,22 @@
import datetime as dt import datetime as dt
from decimal import Decimal from decimal import Decimal
import sqlalchemy as sa
from flask import render_template, Response from flask import render_template, Response
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from accounting.locale import gettext from ..utils.base_page_params import BasePageParams
from accounting.models import Currency, Account, JournalEntry, \ from ..utils.base_report import BaseReport
JournalEntryLineItem from ..utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.base_page_params import BasePageParams from ..utils.option_link import OptionLink
from accounting.report.utils.base_report import BaseReport from ..utils.report_chooser import ReportChooser
from accounting.report.utils.csv_export import BaseCSVRow, csv_download from ..utils.report_type import ReportType
from accounting.report.utils.option_link import OptionLink from ..utils.unapplied import get_accounts_with_unapplied, get_net_balances
from accounting.report.utils.report_chooser import ReportChooser from ..utils.urls import unapplied_url
from accounting.report.utils.report_type import ReportType from ... import db
from accounting.report.utils.unapplied import get_accounts_with_unapplied, \ from ...locale import gettext
get_net_balances from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
from accounting.report.utils.urls import unapplied_url from ...utils.pagination import Pagination
from accounting.utils.pagination import Pagination
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
@@ -178,13 +178,14 @@ class UnappliedOriginalLineItems(BaseReport):
""" """
net_balances: dict[int, Decimal | None] \ net_balances: dict[int, Decimal | None] \
= get_net_balances(self.__currency, self.__account) = get_net_balances(self.__currency, self.__account)
line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query \ line_items: list[JournalEntryLineItem] = db.session.scalars(
.join(Account).join(JournalEntry) \ sa.select(JournalEntryLineItem).join(Account).join(JournalEntry)
.filter(JournalEntryLineItem.id.in_(net_balances)) \ .where(JournalEntryLineItem.id.in_(net_balances))
.order_by(JournalEntry.date, JournalEntry.no, .order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.no) \ JournalEntryLineItem.is_debit, JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.currency), .options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all() selectinload(JournalEntryLineItem.journal_entry)))\
.unique().all()
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 \
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 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.
@@ -22,16 +22,16 @@ from decimal import Decimal
from flask import render_template, Response from flask import render_template, Response
from accounting.locale import gettext from ..utils.base_page_params import BasePageParams
from accounting.models import Currency, Account from ..utils.base_report import BaseReport
from accounting.report.utils.base_page_params import BasePageParams from ..utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.base_report import BaseReport from ..utils.option_link import OptionLink
from accounting.report.utils.csv_export import BaseCSVRow, csv_download from ..utils.report_chooser import ReportChooser
from accounting.report.utils.option_link import OptionLink from ..utils.report_type import ReportType
from accounting.report.utils.report_chooser import ReportChooser from ..utils.unapplied import get_accounts_with_unapplied
from accounting.report.utils.report_type import ReportType from ..utils.urls import unapplied_url
from accounting.report.utils.unapplied import get_accounts_with_unapplied from ...locale import gettext
from accounting.report.utils.urls import unapplied_url from ...models import Currency, Account
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
@@ -120,8 +120,7 @@ def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
:return: The CSV rows. :return: The CSV rows.
""" """
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))] rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
rows.extend([CSVRow(str(x).title(), x.count) rows.extend([CSVRow(str(x), x.count) for x in accounts])
for x in accounts])
return rows return rows
+13 -13
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 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.
@@ -23,18 +23,18 @@ from decimal import Decimal
from flask import render_template, Response from flask import render_template, Response
from flask_babel import LazyString from flask_babel import LazyString
from accounting.locale import gettext from ..utils.base_page_params import BasePageParams
from accounting.models import Currency, Account, JournalEntryLineItem from ..utils.base_report import BaseReport
from accounting.report.utils.base_page_params import BasePageParams from ..utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.base_report import BaseReport from ..utils.offset_matcher import OffsetMatcher, OffsetPair
from accounting.report.utils.csv_export import BaseCSVRow, csv_download from ..utils.option_link import OptionLink
from accounting.report.utils.offset_matcher import OffsetMatcher, OffsetPair from ..utils.report_chooser import ReportChooser
from accounting.report.utils.option_link import OptionLink from ..utils.report_type import ReportType
from accounting.report.utils.report_chooser import ReportChooser from ..utils.unmatched import get_accounts_with_unmatched
from accounting.report.utils.report_type import ReportType from ..utils.urls import unmatched_url
from accounting.report.utils.unmatched import get_accounts_with_unmatched from ...locale import gettext
from accounting.report.utils.urls import unmatched_url from ...models import Currency, Account, JournalEntryLineItem
from accounting.utils.pagination import Pagination from ...utils.pagination import Pagination
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 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.
@@ -22,16 +22,16 @@ from decimal import Decimal
from flask import render_template, Response from flask import render_template, Response
from accounting.locale import gettext from ..utils.base_page_params import BasePageParams
from accounting.models import Currency, Account from ..utils.base_report import BaseReport
from accounting.report.utils.base_page_params import BasePageParams from ..utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.base_report import BaseReport from ..utils.option_link import OptionLink
from accounting.report.utils.csv_export import BaseCSVRow, csv_download from ..utils.report_chooser import ReportChooser
from accounting.report.utils.option_link import OptionLink from ..utils.report_type import ReportType
from accounting.report.utils.report_chooser import ReportChooser from ..utils.unmatched import get_accounts_with_unmatched
from accounting.report.utils.report_type import ReportType from ..utils.urls import unmatched_url
from accounting.report.utils.unmatched import get_accounts_with_unmatched from ...locale import gettext
from accounting.report.utils.urls import unmatched_url from ...models import Currency, Account
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
@@ -120,8 +120,7 @@ def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
:return: The CSV rows. :return: The CSV rows.
""" """
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))] rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
rows.extend([CSVRow(str(x).title(), x.count) rows.extend([CSVRow(str(x), x.count) for x in accounts])
for x in accounts])
return rows return rows
+2 -2
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -19,7 +19,7 @@
""" """
from decimal import Decimal from decimal import Decimal
from accounting.template_filters import format_amount as core_format_amount from ..template_filters import format_amount as core_format_amount
def format_amount(value: Decimal | None) -> str | None: def format_amount(value: Decimal | None) -> str | None:
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -19,18 +19,17 @@
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Callable from collections.abc import Callable
from typing import Type
from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \ from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \
urlunparse urlunparse
import sqlalchemy as sa import sqlalchemy as sa
from flask import request from flask import request
from accounting import db
from accounting.models import Currency, JournalEntryLineItem
from accounting.utils.journal_entry_types import JournalEntryType
from .option_link import OptionLink from .option_link import OptionLink
from .report_chooser import ReportChooser from .report_chooser import ReportChooser
from ... import db
from ...models import Currency, JournalEntryLineItem
from ...utils.journal_entry_types import JournalEntryType
class BasePageParams(ABC): class BasePageParams(ABC):
@@ -53,7 +52,7 @@ class BasePageParams(ABC):
""" """
@property @property
def journal_entry_types(self) -> Type[JournalEntryType]: def journal_entry_types(self) -> type[JournalEntryType]:
"""Returns the journal entry types. """Returns the journal entry types.
:return: The journal entry types. :return: The journal entry types.
@@ -85,5 +84,6 @@ class BasePageParams(ABC):
sa.select(JournalEntryLineItem.currency_code) sa.select(JournalEntryLineItem.currency_code)
.group_by(JournalEntryLineItem.currency_code)).all()) .group_by(JournalEntryLineItem.currency_code)).all())
return [OptionLink(str(x), get_url(x), x.code == active_currency.code) return [OptionLink(str(x), get_url(x), x.code == active_currency.code)
for x in Currency.query.filter(Currency.code.in_(in_use)) for x in db.session.scalars(
.order_by(Currency.code).all()] sa.select(Currency).where(Currency.code.in_(in_use))
.order_by(Currency.code)).unique()]
+15 -11
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -26,7 +26,7 @@ from urllib.parse import quote
from flask import Response from flask import Response
from accounting.report.period import Period from ..period import Period
class BaseCSVRow(ABC): class BaseCSVRow(ABC):
@@ -66,15 +66,19 @@ def period_spec(period: Period) -> str:
""" """
start: str | None = __get_start_str(period.start) start: str | None = __get_start_str(period.start)
end: str | None = __get_end_str(period.end) end: str | None = __get_end_str(period.end)
if period.start is None and period.end is None: if start is None:
return "all-time" return "all-time" if end is None else f"until-{end}"
if start == end: return f"since-{start}" if end is None else __get_spec(start, end)
return start
if period.start is None:
return f"until-{end}" def __get_spec(start: str, end: str) -> str:
if period.end is None: """Constructs the period specification with both start and end
return f"since-{start}"
return f"{start}-{end}" :param start: The start date.
:param end: The end date.
:return: The period specification.
"""
return start if start == end else f"{start}-{end}"
def __get_start_str(start: dt.date | None) -> str | None: def __get_start_str(start: dt.date | None) -> str | None:
+17 -18
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 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.
@@ -23,10 +23,10 @@ import sqlalchemy as sa
from flask_babel import LazyString from flask_babel import LazyString
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from accounting.locale import lazy_gettext from ..utils.unapplied import get_net_balances
from accounting.models import Currency, Account, JournalEntry, \ from ... import db
JournalEntryLineItem from ...locale import lazy_gettext
from accounting.report.utils.unapplied import get_net_balances from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
class OffsetPair: class OffsetPair:
@@ -54,7 +54,7 @@ class OffsetMatcher:
:param currency: The currency. :param currency: The currency.
:param account: The account. :param account: The account.
""" """
self.__currency: Account = currency self.__currency: Currency = currency
"""The currency.""" """The currency."""
self.__account: Account = account self.__account: Account = account
"""The account.""" """The account."""
@@ -105,7 +105,7 @@ class OffsetMatcher:
""" """
net_balances: dict[int, Decimal | None] \ net_balances: dict[int, Decimal | None] \
= get_net_balances(self.__currency, self.__account) = get_net_balances(self.__currency, self.__account)
unmatched_offset_condition: sa.BinaryExpression \ unmatched_offset_condition: sa.ColumnElement[bool] \
= sa.and_(Account.id == self.__account.id, = sa.and_(Account.id == self.__account.id,
JournalEntryLineItem.currency_code JournalEntryLineItem.currency_code
== self.__currency.code, == self.__currency.code,
@@ -114,24 +114,23 @@ class OffsetMatcher:
JournalEntryLineItem.is_debit), JournalEntryLineItem.is_debit),
sa.and_(Account.base_code.startswith("1"), sa.and_(Account.base_code.startswith("1"),
sa.not_(JournalEntryLineItem.is_debit)))) sa.not_(JournalEntryLineItem.is_debit))))
self.line_items = JournalEntryLineItem.query \ self.line_items = db.session.scalars(
.join(Account).join(JournalEntry) \ sa.select(JournalEntryLineItem).join(Account).join(JournalEntry)
.filter(sa.or_(JournalEntryLineItem.id.in_(net_balances), .where(sa.or_(JournalEntryLineItem.id.in_(net_balances),
unmatched_offset_condition)) \ unmatched_offset_condition))
.order_by(JournalEntry.date, JournalEntry.no, .order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.no) \ JournalEntryLineItem.is_debit, JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.currency), .options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all() selectinload(JournalEntryLineItem.journal_entry)))\
.unique().all()
for line_item in self.line_items: for line_item in self.line_items:
line_item.is_offset = line_item.id in net_balances line_item.is_offset = line_item.id not in net_balances
self.unapplied = [x for x in self.line_items self.unapplied = [x for x in self.line_items if not x.is_offset]
if x.is_offset]
for line_item in self.unapplied: for line_item in self.unapplied:
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 \
else net_balances[line_item.id] else net_balances[line_item.id]
self.unmatched = [x for x in self.line_items self.unmatched = [x for x in self.line_items if x.is_offset]
if not x.is_offset]
self.__populate_accumulated_balances() self.__populate_accumulated_balances()
def __populate_accumulated_balances(self) -> None: def __populate_accumulated_balances(self) -> None:
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -25,18 +25,18 @@ from collections.abc import Iterator
from flask_babel import LazyString from flask_babel import LazyString
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account
from accounting.report.period import Period, get_period
from accounting.template_globals import default_currency_code
from accounting.utils.current_account import CurrentAccount
from accounting.utils.permission import can_edit
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, unmatched_url unapplied_url, unmatched_url
from ..period import Period, get_period
from ... import db
from ...locale import gettext
from ...models import Currency, Account
from ...template_globals import default_currency_code
from ...utils.current_account import CurrentAccount
from ...utils.permission import can_edit
class ReportChooser: class ReportChooser:
+9 -9
View File
@@ -22,21 +22,21 @@ from enum import Enum
class ReportType(Enum): class ReportType(Enum):
"""The report types.""" """The report types."""
JOURNAL: str = "journal" JOURNAL = "journal"
"""The journal.""" """The journal."""
LEDGER: str = "ledger" LEDGER = "ledger"
"""The ledger.""" """The ledger."""
INCOME_EXPENSES: str = "income-expenses" INCOME_EXPENSES = "income-expenses"
"""The income and expenses log.""" """The income and expenses log."""
TRIAL_BALANCE: str = "trial-balance" TRIAL_BALANCE = "trial-balance"
"""The trial balance.""" """The trial balance."""
INCOME_STATEMENT: str = "income-statement" INCOME_STATEMENT = "income-statement"
"""The income statement.""" """The income statement."""
BALANCE_SHEET: str = "balance-sheet" BALANCE_SHEET = "balance-sheet"
"""The balance sheet.""" """The balance sheet."""
UNAPPLIED: str = "unapplied" UNAPPLIED = "unapplied"
"""The unapplied original line items.""" """The unapplied original line items."""
UNMATCHED: str = "unmatched" UNMATCHED = "unmatched"
"""The unmatched offsets.""" """The unmatched offsets."""
SEARCH: str = "search" SEARCH = "search"
"""The search.""" """The search."""
+11 -11
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 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.
@@ -21,10 +21,9 @@ from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
from accounting import db from ... import db
from accounting.models import Currency, Account, JournalEntry, \ from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
JournalEntryLineItem from ...utils.offset_alias import offset_alias
from accounting.utils.offset_alias import offset_alias
def get_accounts_with_unapplied(currency: Currency) -> list[Account]: def get_accounts_with_unapplied(currency: Currency) -> list[Account]:
@@ -46,7 +45,7 @@ def get_accounts_with_unapplied(currency: Currency) -> list[Account]:
.join(offset, .join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id, JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\ isouter=True)\
.filter(Account.is_need_offset, .where(Account.is_need_offset,
JournalEntryLineItem.currency_code == currency.code, JournalEntryLineItem.currency_code == currency.code,
sa.or_(sa.and_(Account.base_code.startswith("2"), sa.or_(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)), sa.not_(JournalEntryLineItem.is_debit)),
@@ -59,13 +58,14 @@ def get_accounts_with_unapplied(currency: Currency) -> list[Account]:
= sa.func.count(JournalEntryLineItem.id).label("count") = sa.func.count(JournalEntryLineItem.id).label("count")
select: sa.Select = sa.select(Account.id, count_func)\ select: sa.Select = sa.select(Account.id, count_func)\
.join(JournalEntryLineItem, isouter=True)\ .join(JournalEntryLineItem, isouter=True)\
.filter(JournalEntryLineItem.id.in_(select_unapplied))\ .where(JournalEntryLineItem.id.in_(select_unapplied))\
.group_by(Account.id)\ .group_by(Account.id)\
.having(count_func > 0) .having(count_func > 0)
counts: dict[int, int] \ counts: dict[int, int] \
= {x.id: x.count for x in db.session.execute(select)} = {x.id: x.count for x in db.session.execute(select)}
accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\ accounts: list[Account] = db.session.scalars(
.order_by(Account.base_code, Account.no).all() sa.select(Account).where(Account.id.in_(counts))
.order_by(Account.base_code, Account.no)).unique().all()
for account in accounts: for account in accounts:
account.count = counts[account.id] account.count = counts[account.id]
return accounts return accounts
@@ -92,7 +92,7 @@ def get_net_balances(currency: Currency, account: Account) \
.join(offset, .join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id, JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True) \ isouter=True) \
.filter(Account.id == account.id, .where(Account.id == account.id,
JournalEntryLineItem.currency_code == currency.code, JournalEntryLineItem.currency_code == currency.code,
sa.or_(sa.and_(Account.base_code.startswith("2"), sa.or_(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)), sa.not_(JournalEntryLineItem.is_debit)),
@@ -101,4 +101,4 @@ def get_net_balances(currency: Currency, account: Account) \
.group_by(JournalEntryLineItem.id) \ .group_by(JournalEntryLineItem.id) \
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0)) .having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
return {x.id: x.net_balance return {x.id: x.net_balance
for x in db.session.execute(select_net_balances).all()} for x in db.session.execute(select_net_balances)}
+7 -7
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 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.
@@ -19,9 +19,8 @@
""" """
import sqlalchemy as sa import sqlalchemy as sa
from accounting import db from ... import db
from accounting.models import Currency, Account, JournalEntry, \ from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
JournalEntryLineItem
def get_accounts_with_unmatched(currency: Currency) -> list[Account]: def get_accounts_with_unmatched(currency: Currency) -> list[Account]:
@@ -36,7 +35,7 @@ def get_accounts_with_unmatched(currency: Currency) -> list[Account]:
select: sa.Select = sa.select(Account.id, count_func)\ select: sa.Select = sa.select(Account.id, count_func)\
.select_from(Account)\ .select_from(Account)\
.join(JournalEntryLineItem, isouter=True).join(JournalEntry)\ .join(JournalEntryLineItem, isouter=True).join(JournalEntry)\
.filter(Account.is_need_offset, .where(Account.is_need_offset,
JournalEntryLineItem.currency_code == currency.code, JournalEntryLineItem.currency_code == currency.code,
JournalEntryLineItem.original_line_item_id.is_(None), JournalEntryLineItem.original_line_item_id.is_(None),
sa.or_(sa.and_(Account.base_code.startswith("2"), sa.or_(sa.and_(Account.base_code.startswith("2"),
@@ -47,8 +46,9 @@ def get_accounts_with_unmatched(currency: Currency) -> list[Account]:
.having(count_func > 0) .having(count_func > 0)
counts: dict[int, int] \ counts: dict[int, int] \
= {x.id: x.count for x in db.session.execute(select)} = {x.id: x.count for x in db.session.execute(select)}
accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\ accounts: list[Account] = db.session.scalars(
.order_by(Account.base_code, Account.no).all() sa.select(Account).where(Account.id.in_(counts))
.order_by(Account.base_code, Account.no)).unique().all()
for account in accounts: for account in accounts:
account.count = counts[account.id] account.count = counts[account.id]
return accounts return accounts
+6 -6
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -19,11 +19,11 @@
""" """
from flask import url_for from flask import url_for
from accounting.models import Currency, Account from ...models import Currency, Account
from accounting.report.period import Period from ...report.period import Period
from accounting.template_globals import default_currency_code from ...template_globals import default_currency_code
from accounting.utils.current_account import CurrentAccount from ...utils.current_account import CurrentAccount
from accounting.utils.options import options from ...utils.options import options
def journal_url(period: Period) \ def journal_url(period: Period) \
+11 -11
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -19,15 +19,6 @@
""" """
from flask import Blueprint, request, Response, redirect, flash from flask import Blueprint, request, Response, redirect, flash
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency, Account
from accounting.template_globals import default_currency_code
from accounting.utils.cast import s
from accounting.utils.current_account import CurrentAccount
from accounting.utils.next_uri import or_next
from accounting.utils.options import options
from accounting.utils.permission import has_permission, can_view, can_edit
from .period import Period, get_period from .period import Period, get_period
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \ from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
IncomeStatement, BalanceSheet, Search IncomeStatement, BalanceSheet, Search
@@ -38,6 +29,15 @@ from .reports.unmatched_accounts import AccountsWithUnmatchedOffsets
from .template_filters import format_amount from .template_filters import format_amount
from .utils.offset_matcher import OffsetMatcher from .utils.offset_matcher import OffsetMatcher
from .utils.urls import unmatched_url from .utils.urls import unmatched_url
from .. import db
from ..locale import lazy_gettext
from ..models import Currency, Account
from ..template_globals import default_currency_code
from ..utils.cast import s
from ..utils.current_account import CurrentAccount
from ..utils.next_uri import or_next
from ..utils.options import options
from ..utils.permission import has_permission, can_view, can_edit
bp: Blueprint = Blueprint("accounting-report", __name__) bp: Blueprint = Blueprint("accounting-report", __name__)
"""The view blueprint for the reports.""" """The view blueprint for the reports."""
@@ -391,7 +391,7 @@ def get_unmatched(currency: Currency, account: Account) -> str | Response:
@bp.post("match-offsets/<currency:currency>/<needOffsetAccount:account>", @bp.post("match-offsets/<currency:currency>/<needOffsetAccount:account>",
endpoint="match-offsets") endpoint="match-offsets")
@has_permission(can_edit) @has_permission(can_edit)
def match_offsets(currency: Currency, account: Account) -> redirect: def match_offsets(currency: Currency, account: Account) -> Response:
"""Matches the original line items with their offsets. """Matches the original line items with their offsets.
:return: Redirection to the view of the unmatched offsets. :return: Redirection to the view of the unmatched offsets.
+29 -69
View File
@@ -48,7 +48,7 @@ class AccountForm {
/** /**
* The control of the base account * The control of the base account
* @type {HTMLDivElement} * @type {HTMLButtonElement}
*/ */
#baseControl; #baseControl;
@@ -60,7 +60,7 @@ class AccountForm {
/** /**
* The base account * The base account
* @type {HTMLDivElement} * @type {HTMLSpanElement}
*/ */
#base; #base;
@@ -225,15 +225,16 @@ class AccountForm {
/** /**
* The base account selector. * The base account selector.
* *
* @extends {BaseCombobox<BaseAccountOption>}
* @private * @private
*/ */
class BaseAccountSelector { class BaseAccountSelector extends BaseCombobox {
/** /**
* The account form * The account form
* @type {AccountForm} * @type {AccountForm}
*/ */
form; #form;
/** /**
* The selector modal * The selector modal
@@ -241,12 +242,6 @@ class BaseAccountSelector {
*/ */
#modal; #modal;
/**
* The query input
* @type {HTMLInputElement}
*/
#query;
/** /**
* The error message when the query has no result * The error message when the query has no result
* @type {HTMLParagraphElement} * @type {HTMLParagraphElement}
@@ -259,12 +254,6 @@ class BaseAccountSelector {
*/ */
#optionList; #optionList;
/**
* The options
* @type {BaseAccountOption[]}
*/
#options;
/** /**
* The button to clear the base account value * The button to clear the base account value
* @type {HTMLButtonElement} * @type {HTMLButtonElement}
@@ -277,29 +266,32 @@ class BaseAccountSelector {
* @param form {AccountForm} the form * @param form {AccountForm} the form
*/ */
constructor(form) { constructor(form) {
this.form = form;
const prefix = "accounting-base-selector"; const prefix = "accounting-base-selector";
const query = document.getElementById(`${prefix}-query`);
const options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new BaseAccountOption(element, form.saveBaseAccount.bind(form)));
super(query, options);
this.#form = form;
this.#modal = document.getElementById(`${prefix}-modal`); this.#modal = document.getElementById(`${prefix}-modal`);
this.#query = document.getElementById(`${prefix}-query`); this.#modal.addEventListener("hidden.bs.modal", () => this.#form.onBaseAccountSelectorClosed());
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 BaseAccountOption(this, element));
this.#clearButton = document.getElementById(`${prefix}-clear`);
this.#modal.addEventListener("hidden.bs.modal", () => this.form.onBaseAccountSelectorClosed()); this.#clearButton = document.getElementById(`${prefix}-clear`);
this.#query.oninput = () => this.#filterOptions(); this.#clearButton.onclick = () => this.#form.clearBaseAccount();
this.#clearButton.onclick = () => this.form.clearBaseAccount();
} }
/** /**
* Filters the options. * Filters the options.
* *
* @override
*/ */
#filterOptions() { filterOptions() {
this.shownOptions = [];
let isAnyMatched = false; let isAnyMatched = false;
for (const option of this.#options) { for (const option of this.options) {
if (option.isMatched(this.#query.value)) { if (option.isMatched(this.query.value)) {
option.setShown(true); option.setShown(true);
this.shownOptions.push(option);
isAnyMatched = true; isAnyMatched = true;
} else { } else {
option.setShown(false); option.setShown(false);
@@ -319,12 +311,11 @@ class BaseAccountSelector {
* *
*/ */
onOpen() { onOpen() {
this.#query.value = ""; this.query.value = "";
this.#filterOptions(); this.filterOptions();
for (const option of this.#options) { this.query.removeAttribute("aria-activedescendant");
option.setActive(option.code === this.form.baseCode); this.selectOption(this.shownOptions.find((option) => option.code === this.#form.baseCode));
} if (this.#form.baseCode === null) {
if (this.form.baseCode === null) {
this.#clearButton.classList.add("btn-secondary") this.#clearButton.classList.add("btn-secondary")
this.#clearButton.classList.remove("btn-danger"); this.#clearButton.classList.remove("btn-danger");
this.#clearButton.disabled = true; this.#clearButton.disabled = true;
@@ -339,14 +330,9 @@ class BaseAccountSelector {
/** /**
* A base account option. * A base account option.
* *
* @private
*/ */
class BaseAccountOption { class BaseAccountOption extends BaseOption {
/**
* The element
* @type {HTMLLIElement}
*/
#element;
/** /**
* The account code * The account code
@@ -369,16 +355,16 @@ class BaseAccountOption {
/** /**
* Constructs the account in the base account selector. * Constructs the account in the base account selector.
* *
* @param selector {BaseAccountSelector} the base account selector
* @param element {HTMLLIElement} the element * @param element {HTMLLIElement} the element
* @param save {function(BaseAccountOption): void} the callback to save the option
*/ */
constructor(selector, element) { constructor(element, save) {
this.#element = element; super(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 = () => selector.form.saveBaseAccount(this); element.onclick = () => save(this);
} }
/** /**
@@ -398,30 +384,4 @@ class BaseAccountOption {
} }
return false; return false;
} }
/**
* Sets whether the option is shown.
*
* @param isShown {boolean} true to show, or false otherwise
*/
setShown(isShown) {
if (isShown) {
this.#element.classList.remove("d-none");
} else {
this.#element.classList.add("d-none");
}
}
/**
* Sets whether the option is active.
*
* @param isActive {boolean} true if active, or false otherwise
*/
setActive(isActive) {
if (isActive) {
this.#element.classList.add("active");
} else {
this.#element.classList.remove("active");
}
}
} }
+237
View File
@@ -0,0 +1,237 @@
/* The Mia! Accounting Project
* base-combobox.js: The JavaScript for the base abstract combobox
*/
/* Copyright (c) 2026 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2026/4/16
*/
"use strict";
/**
* The base abstract combobox.
*
* @abstract
* @template {BaseOption} T
*/
class BaseCombobox {
/**
* The query input
* @type {HTMLInputElement}
*/
query;
/**
* The options
* @type {T[]}
*/
options;
/**
* The options that are shown
* @type {T[]}
*/
shownOptions;
/**
* Constructs a base abstract combobox.
*
* @param query {HTMLInputElement} the query input
* @param options {T[]} the options
*/
constructor(query, options) {
this.query = query;
this.query.oninput = () => this.filterOptions();
this.query.onkeydown = this.onQueryKeyDown.bind(this);
this.options = options;
this.shownOptions = [];
}
/**
* Actions when keys are pressed on the query input.
*
* @param event {KeyboardEvent} the key event
*/
onQueryKeyDown(event) {
if (this.shownOptions.length === 0) {
return;
}
const currentID = this.query.getAttribute("aria-activedescendant");
const currentIndex = this.shownOptions.findIndex((option) => option.elementID === currentID);
let newIndex;
switch (event.key) {
case "ArrowUp":
if (currentIndex === -1) {
newIndex = this.shownOptions.length - 1;
} else {
newIndex = (currentIndex - 1 + this.shownOptions.length) % this.shownOptions.length;
}
break;
case "ArrowDown":
if (currentIndex === -1) {
newIndex = 0;
} else {
newIndex = (currentIndex + 1) % this.shownOptions.length;
}
break;
case "Home":
if (this.query.value !== "") {
return;
}
newIndex = 0;
break;
case "End":
if (this.query.value !== "") {
return;
}
newIndex = this.shownOptions.length - 1;
break;
case "PageUp":
if (currentIndex === -1) {
newIndex = this.shownOptions.length - 1;
} else {
newIndex = Math.max(currentIndex - 10, 0);
}
break;
case "PageDown":
if (currentIndex === -1) {
newIndex = 0;
} else {
newIndex = Math.min(currentIndex + 10, this.shownOptions.length - 1);
}
break;
case "Enter":
event.preventDefault();
if (currentIndex !== -1) {
this.shownOptions[currentIndex].click();
}
return;
case "Escape":
if (this.query.value !== "") {
event.preventDefault();
event.stopPropagation();
this.query.value = "";
this.filterOptions();
}
return;
default:
return;
}
event.preventDefault();
this.selectOption(this.shownOptions[newIndex]);
}
/**
* Filters the options.
*
* @abstract
*/
filterOptions() {
throw new Error("Method not implemented");
}
/**
* Selects an option.
*
* @param option {T|undefined} the option.
*/
selectOption(option) {
this.options.forEach((opt) => opt.setActive(false));
if (option === undefined) {
return;
}
option.setActive(true);
this.query.setAttribute("aria-activedescendant", option.elementID);
option.scrollIntoView();
}
}
/**
* The base abstract option
*
* @abstract
*/
class BaseOption {
/**
* The element
* @type {HTMLLIElement}
*/
#element;
/**
* The element ID
* @type {string}
*/
elementID;
/**
* Constructs the base abstract option.
*
* @param element {HTMLLIElement} the element
*/
constructor(element) {
this.#element = element;
this.elementID = element.id;
}
/**
* Sets whether the option is shown.
*
* @param isShown {boolean} true to show, or false otherwise
*/
setShown(isShown) {
if (isShown) {
this.#element.classList.remove("d-none");
} else {
this.#element.classList.add("d-none");
}
}
/**
* Sets whether the option is active.
*
* @param isActive {boolean} true if active, or false otherwise
*/
setActive(isActive) {
if (isActive) {
this.#element.classList.add("active");
this.#element.ariaSelected = "true";
} else {
this.#element.classList.remove("active");
this.#element.ariaSelected = "false";
}
}
/**
* Clicks the option.
*
*/
click() {
this.#element.click();
}
/**
* Scrolls the option into view.
*
*/
scrollIntoView() {
this.#element.scrollIntoView({block: "nearest"});
}
}
+177
View File
@@ -0,0 +1,177 @@
/* The Mia! Accounting Project
* base-tablist.js: The JavaScript for base abstract tablist
*/
/* Copyright (c) 2026 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2026/4/16
*/
"use strict";
/**
* The base abstract tablist.
*
* @abstract
* @template {BaseTab} T
*/
class BaseTablist {
/**
* The tabs.
* @type {T[]}
*/
tabs;
/**
* The current tab.
* @type {T}
*/
currentTab;
/**
* Constructs a new base abstract tablist.
*
* @param tablist {HTMLUListElement} the tab list
*/
constructor(tablist) {
tablist.onkeydown = this.onTabKeyDown.bind(this);
}
/**
* Actions when keys are pressed on the tabs.
*
* @param event {KeyboardEvent} the key event
*/
onTabKeyDown(event) {
const currentIndex = this.tabs.indexOf(this.currentTab);
if (currentIndex === -1) {
return;
}
let newIndex = currentIndex;
switch (event.key) {
case "ArrowRight":
newIndex = (newIndex + 1) % this.tabs.length;
break;
case "ArrowLeft":
newIndex = (newIndex - 1 + this.tabs.length) % this.tabs.length;
break;
case "Home":
newIndex = 0;
break;
case "End":
newIndex = this.tabs.length - 1;
break;
default:
return;
}
event.preventDefault();
this.tabs[newIndex].focus();
this.onTabFocus(this.tabs[newIndex]);
}
/**
* Actions when a tab is focused.
*
* @param tab {T} the tab
*/
onTabFocus(tab) { /* Do nothing */ }
/**
* Switches to a tab.
*
* @param tab {T} the tab
*/
switchTo(tab) {
this.tabs.forEach(t => t.setActive(t === tab));
this.currentTab = tab;
this.currentTab.onActivated();
}
}
/**
* The base abstract tab.
*
* @abstract
*/
class BaseTab {
/**
* The tab element.
* @type {HTMLButtonElement}
*/
#tab;
/**
* The panel element.
* @type {HTMLDivElement}
*/
#panel;
/**
* Constructs a new base abstract tab.
*
* @param tab {HTMLButtonElement} The tab element.
* @param panel {HTMLDivElement} The panel element.
* @param switchTo {function(BaseTab): void} The function to switch to the tab.
*/
constructor(tab, panel, switchTo) {
this.#tab = tab;
this.#panel = panel;
this.#tab.onclick = () => switchTo(this);
}
/**
* Sets the active state of the tab.
*
* @param isActive {boolean} true if the tab is active, false otherwise
*/
setActive(isActive) {
if (isActive) {
this.#tab.classList.add("active");
this.#tab.tabIndex = 0;
this.#tab.ariaSelected = "true";
this.#panel.classList.remove("d-none");
} else {
this.#tab.classList.remove("active");
this.#tab.tabIndex = -1;
this.#tab.ariaSelected = "false";
this.#panel.classList.add("d-none");
}
}
/**
* Returns whether the tab is active.
*
* @returns {boolean} true if the tab is active, false otherwise
*/
isActive() {
return this.#tab.classList.contains("active");
}
/**
* Actions when the tab is activated.
*/
onActivated() { /* Do nothing */ }
/**
* Focuses the tab.
*/
focus() {
this.#tab.focus();
}
}
+170 -191
View File
@@ -25,8 +25,9 @@
/** /**
* A description editor. * A description editor.
* *
* @extends {BaseTablist<BaseDescriptionEditorTab>}
*/ */
class DescriptionEditor { class DescriptionEditor extends BaseTablist {
/** /**
* The line item editor * The line item editor
@@ -58,12 +59,6 @@ class DescriptionEditor {
*/ */
debitCredit; debitCredit;
/**
* The current tab
* @type {DescriptionEditorTabPlane}
*/
currentTab;
/** /**
* The description input * The description input
* @type {HTMLInputElement} * @type {HTMLInputElement}
@@ -125,10 +120,10 @@ class DescriptionEditor {
selectedAccount = null; selectedAccount = null;
/** /**
* The tab planes * The tabs by their ID.
* @type {{general: DescriptionEditorGeneralTagTab, travel: DescriptionEditorGeneralTripTab, bus: DescriptionEditorBusTripTab, recurring: DescriptionEditorRecurringTab, annotation: DescriptionEditorAnnotationTab}} * @type {DescriptionEditorTabFactory}
*/ */
tabPlanes = {}; #tabsByID;
/** /**
* Constructs a description editor. * Constructs a description editor.
@@ -137,31 +132,38 @@ class DescriptionEditor {
* @param debitCredit {string} either "debit" or "credit" * @param debitCredit {string} either "debit" or "credit"
*/ */
constructor(lineItemEditor, debitCredit) { constructor(lineItemEditor, debitCredit) {
const prefix = `accounting-description-editor-${debitCredit}`;
super(document.getElementById(`${prefix}-tab-list`));
this.prefix = prefix;
this.lineItemEditor = lineItemEditor; this.lineItemEditor = lineItemEditor;
this.debitCredit = debitCredit; this.debitCredit = debitCredit;
this.prefix = `accounting-description-editor-${debitCredit}`; this.#form = document.getElementById(prefix);
this.#form = document.getElementById(this.prefix); this.#modal = document.getElementById(`${prefix}-modal`);
this.#modal = document.getElementById(`${this.prefix}-modal`); this.#descriptionInput = document.getElementById(`${prefix}-description`);
this.#descriptionInput = document.getElementById(`${this.prefix}-description`); this.#offsetButton = document.getElementById(`${prefix}-offset`);
this.#offsetButton = document.getElementById(`${this.prefix}-offset`); this.number = document.getElementById(`${prefix}-annotation-number`);
this.number = document.getElementById(`${this.prefix}-annotation-number`); this.note = document.getElementById(`${prefix}-annotation-note`);
this.note = document.getElementById(`${this.prefix}-annotation-note`); this.#confirmedAccountPlaceholder = new DescriptionEditorConfirmedAccount(this, document.getElementById(`${prefix}-account-confirmed`));
this.#confirmedAccountPlaceholder = new DescriptionEditorConfirmedAccount(this, document.getElementById(`${this.prefix}-account-confirmed`)); this.#allSuggestedAccounts = Array.from(document.getElementsByClassName(`${prefix}-account`)).map((button) => new DescriptionEditorSuggestedAccount(this, button));
this.#allSuggestedAccounts = Array.from(document.getElementsByClassName(`${this.prefix}-account`)).map((button) => new DescriptionEditorSuggestedAccount(this, button));
for (const cls of [DescriptionEditorGeneralTagTab, DescriptionEditorGeneralTripTab, DescriptionEditorBusTripTab, DescriptionEditorRecurringTab, DescriptionEditorAnnotationTab]) { this.#tabsByID = new DescriptionEditorTabFactory(this);
const tab = new cls(this); this.tabs = [this.#tabsByID.general, this.#tabsByID.travel, this.#tabsByID.bus, this.#tabsByID.recurring, this.#tabsByID.annotation];
this.tabPlanes[tab.tabId()] = tab; this.currentTab = this.tabs[0];
}
this.currentTab = this.tabPlanes.general;
this.#descriptionInput.onchange = () => this.#onDescriptionChange(); this.#descriptionInput.onchange = () => this.#onDescriptionChange();
this.#offsetButton.onclick = () => this.lineItemEditor.originalLineItemSelector.onOpen(); this.#offsetButton.onclick = () => this.lineItemEditor.originalLineItemSelector.onOpen(this.#modal.id);
this.#form.onsubmit = () => { this.#form.onsubmit = () => {
if (this.currentTab.validate()) { if (this.currentTab.validate()) {
this.#submit(); this.#submit();
} }
return false; return false;
}; };
const closeButton = document.getElementById(`${prefix}-close`);
this.#modal.onkeydown = (event) => {
if (event.key === "Escape") {
closeButton.click();
}
};
} }
/** /**
@@ -199,26 +201,26 @@ class DescriptionEditor {
* *
*/ */
#onDescriptionChange() { #onDescriptionChange() {
this.#resetTabPlanes(); this.#resetTabs();
this.selectedAccount = null; this.selectedAccount = null;
this.description = this.description.trim(); this.description = this.description.trim();
for (const tabPlane of [this.tabPlanes.recurring, this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) { for (const tab of [this.#tabsByID.recurring, this.#tabsByID.bus, this.#tabsByID.travel, this.#tabsByID.general]) {
if (tabPlane.populate()) { if (tab.populate()) {
break; break;
} }
} }
this.tabPlanes.annotation.populate(); this.#tabsByID.annotation.populate();
} }
/** /**
* Resets the tab planes. * Resets the tabs.
* *
*/ */
#resetTabPlanes() { #resetTabs() {
for (const tabPlane of Object.values(this.tabPlanes)) { for (const tab of this.tabs) {
tabPlane.reset(); tab.reset();
} }
this.tabPlanes.general.switchToMe(); this.switchTo(this.tabs[0]);
} }
/** /**
@@ -344,6 +346,7 @@ class DescriptionEditor {
/** /**
* An account option in the description editor. * An account option in the description editor.
* *
* @private
*/ */
class DescriptionEditorAccount extends JournalEntryAccount { class DescriptionEditorAccount extends JournalEntryAccount {
@@ -415,6 +418,7 @@ class DescriptionEditorAccount extends JournalEntryAccount {
/** /**
* A suggested account. * A suggested account.
* *
* @private
*/ */
class DescriptionEditorSuggestedAccount extends DescriptionEditorAccount { class DescriptionEditorSuggestedAccount extends DescriptionEditorAccount {
@@ -432,6 +436,7 @@ class DescriptionEditorSuggestedAccount extends DescriptionEditorAccount {
/** /**
* The account option that is specified or confirmed by the user. * The account option that is specified or confirmed by the user.
* *
* @private
*/ */
class DescriptionEditorConfirmedAccount extends DescriptionEditorAccount { class DescriptionEditorConfirmedAccount extends DescriptionEditorAccount {
@@ -460,12 +465,63 @@ class DescriptionEditorConfirmedAccount extends DescriptionEditorAccount {
} }
/** /**
* A tab plane. * The tab factory.
*
* @private
*/
class DescriptionEditorTabFactory {
/**
* The general tag tab
* @type {GeneralTagTab}
*/
general;
/**
* The general trip tab
* @type {GeneralTripTab}
*/
travel;
/**
* The bus trip tab
* @type {BusTripTab}
*/
bus;
/**
* The recurring transactions tab
* @type {RecurringTab}
*/
recurring;
/**
* The annotation tab
* @type {AnnotationTab}
*/
annotation;
/**
* Constructs the tab factory
*
* @param editor {DescriptionEditor} the parent description editor
*/
constructor(editor) {
this.general = new GeneralTagTab(editor);
this.travel = new GeneralTripTab(editor);
this.bus = new BusTripTab(editor);
this.recurring = new RecurringTab(editor);
this.annotation = new AnnotationTab(editor);
}
}
/**
* The base abstract tab in the description editor.
* *
* @abstract * @abstract
* @private * @private
*/ */
class DescriptionEditorTabPlane { class BaseDescriptionEditorTab extends BaseTab {
/** /**
* The parent description editor * The parent description editor
@@ -480,47 +536,29 @@ class DescriptionEditorTabPlane {
prefix; prefix;
/** /**
* The tab * Constructs a base abstract tab in the description editor.
* @type {HTMLSpanElement}
*/
#tab;
/**
* The page
* @type {HTMLDivElement}
*/
#page;
/**
* Constructs a tab plane.
* *
* @param tabID {string} the tab ID
* @param editor {DescriptionEditor} the parent description editor * @param editor {DescriptionEditor} the parent description editor
*/ */
constructor(editor) { constructor(tabID, editor) {
const prefix = `${editor.prefix}-${tabID}`;
const tab = document.getElementById(`${prefix}-tab`);
const panel = document.getElementById(`${prefix}-panel`);
super(tab, panel, editor.switchTo.bind(editor));
this.editor = editor; this.editor = editor;
this.prefix = `${this.editor.prefix}-${this.tabId()}`; this.prefix = prefix;
this.#tab = document.getElementById(`${this.prefix}-tab`);
this.#page = document.getElementById(`${this.prefix}-page`);
this.#tab.onclick = () => this.switchToMe();
} }
/** /**
* The tab ID * Resets the tab panel input.
*
* @return {string}
* @abstract
*/
tabId() { throw new Error("Method not implemented.") };
/**
* Resets the tab plane input.
* *
* @abstract * @abstract
*/ */
reset() { throw new Error("Method not implemented."); } reset() { throw new Error("Method not implemented."); }
/** /**
* Populates the tab plane with the description input. * Populates the tab panel with the description input.
* *
* @return {boolean} true if the description input matches this tab, or false otherwise * @return {boolean} true if the description input matches this tab, or false otherwise
* @abstract * @abstract
@@ -528,39 +566,21 @@ class DescriptionEditorTabPlane {
populate() { throw new Error("Method not implemented."); } populate() { throw new Error("Method not implemented."); }
/** /**
* Validates the input in the tab plane. * Validates the input in the tab panel.
* *
* @return {boolean} true if valid, or false otherwise * @return {boolean} true if valid, or false otherwise
* @abstract * @abstract
*/ */
validate() { throw new Error("Method not implemented."); } validate() { throw new Error("Method not implemented."); }
/**
* Switches to the tab plane.
*
*/
switchToMe() {
for (const tabPlane of Object.values(this.editor.tabPlanes)) {
tabPlane.#tab.classList.remove("active")
tabPlane.#tab.ariaCurrent = "false";
tabPlane.#page.classList.add("d-none");
tabPlane.#page.ariaCurrent = "false";
}
this.#tab.classList.add("active");
this.#tab.ariaCurrent = "page";
this.#page.classList.remove("d-none");
this.#page.ariaCurrent = "page";
this.editor.currentTab = this;
}
} }
/** /**
* A tag plane with selectable tags. * The base abstract tab with selectable tags.
* *
* @abstract * @abstract
* @private * @private
*/ */
class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane { class BaseTagTab extends BaseDescriptionEditorTab {
/** /**
* The tag input * The tag input
@@ -578,20 +598,21 @@ class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
* The tag buttons * The tag buttons
* @type {HTMLButtonElement[]} * @type {HTMLButtonElement[]}
*/ */
tagButtons; #tagButtons;
/** /**
* Constructs a tab plane. * Constructs a base abstract tab with selectable tags.
* *
* @param tabID {string} the tab ID
* @param editor {DescriptionEditor} the parent description editor * @param editor {DescriptionEditor} the parent description editor
* @override * @override
*/ */
constructor(editor) { constructor(tabID, editor) {
super(editor); super(tabID, 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();
@@ -606,7 +627,7 @@ class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
onTagChange() { onTagChange() {
this.tag.value = this.tag.value.trim(); this.tag.value = this.tag.value.trim();
let isMatched = false; let isMatched = false;
for (const tagButton of this.tagButtons) { for (const tagButton of this.#tagButtons) {
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");
@@ -624,19 +645,18 @@ class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
} }
/** /**
* Updates the description according to the input in the tab plane. * Updates the description according to the input in the tab panel.
* *
* @abstract * @abstract
*/ */
updateDescription() { throw new Error("Method not implemented."); } updateDescription() { throw new Error("Method not implemented."); }
/** /**
* Switches to the tab plane. * @inheritDoc
* * @override
*/ */
switchToMe() { onActivated() {
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.updateCurrentSuggestedAccounts(tagButton); this.editor.updateCurrentSuggestedAccounts(tagButton);
return; return;
@@ -650,9 +670,9 @@ class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
* *
*/ */
initializeTagButtons() { initializeTagButtons() {
for (const tagButton of this.tagButtons) { for (const tagButton of this.#tagButtons) {
tagButton.onclick = () => { tagButton.onclick = () => {
for (const otherButton of this.tagButtons) { for (const otherButton of this.#tagButtons) {
otherButton.classList.remove("btn-primary"); otherButton.classList.remove("btn-primary");
otherButton.classList.add("btn-outline-primary"); otherButton.classList.add("btn-outline-primary");
} }
@@ -698,7 +718,7 @@ class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
} }
/** /**
* Resets the tab plane input. * Resets the tab panel input.
* *
* @override * @override
*/ */
@@ -706,7 +726,7 @@ class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
this.tag.value = ""; this.tag.value = "";
this.tag.classList.remove("is-invalid"); this.tag.classList.remove("is-invalid");
this.tagError.innerText = ""; this.tagError.innerText = "";
for (const tagButton of this.tagButtons) { for (const tagButton of this.#tagButtons) {
tagButton.classList.remove("btn-primary"); tagButton.classList.remove("btn-primary");
tagButton.classList.add("btn-outline-primary"); tagButton.classList.add("btn-outline-primary");
} }
@@ -714,24 +734,24 @@ class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
} }
/** /**
* The general tag tab plane. * The general tag tab.
* *
* @private * @private
*/ */
class DescriptionEditorGeneralTagTab extends DescriptionEditorTagTabPlane { class GeneralTagTab extends BaseTagTab {
/** /**
* The tab ID * Constructs a general tag tab.
* *
* @return {string} * @param editor {DescriptionEditor} the parent description editor
* @abstract * @override
*/ */
tabId() { constructor(editor) {
return "general"; super("general", editor);
}; }
/** /**
* Updates the description according to the input in the tab plane. * Updates the description according to the input in the tab panel.
* *
* @override * @override
*/ */
@@ -746,7 +766,7 @@ class DescriptionEditorGeneralTagTab extends DescriptionEditorTagTabPlane {
} }
/** /**
* Populates the tab plane with the description input. * Populates the tab panel with the description input.
* *
* @return {boolean} true if the description input matches this tab, or false otherwise * @return {boolean} true if the description input matches this tab, or false otherwise
* @override * @override
@@ -760,12 +780,12 @@ class DescriptionEditorGeneralTagTab extends DescriptionEditorTagTabPlane {
this.tag.value = found[1]; this.tag.value = found[1];
this.onTagChange(); this.onTagChange();
} }
this.switchToMe(); this.editor.switchTo(this);
return true; return true;
} }
/** /**
* Validates the input in the tab plane. * Validates the input in the tab panel.
* *
* @return {boolean} true if valid, or false otherwise * @return {boolean} true if valid, or false otherwise
*/ */
@@ -775,11 +795,11 @@ class DescriptionEditorGeneralTagTab extends DescriptionEditorTagTabPlane {
} }
/** /**
* The general trip tab plane. * The general trip tab.
* *
* @private * @private
*/ */
class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane { class GeneralTripTab extends BaseTagTab {
/** /**
* The origin * The origin
@@ -812,13 +832,13 @@ class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {
#directionButtons; #directionButtons;
/** /**
* Constructs a tab plane. * Constructs a general trip tab.
* *
* @param editor {DescriptionEditor} the parent description editor * @param editor {DescriptionEditor} the parent description editor
* @override * @override
*/ */
constructor(editor) { constructor(editor) {
super(editor); super("travel", 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`);
@@ -849,17 +869,7 @@ class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {
} }
/** /**
* The tab ID * Updates the description according to the input in the tab panel.
*
* @return {string}
* @abstract
*/
tabId() {
return "travel";
};
/**
* Updates the description according to the input in the tab plane.
* *
* @override * @override
*/ */
@@ -875,7 +885,7 @@ class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {
} }
/** /**
* Resets the tab plane input. * Resets the tab panel input.
* *
* @override * @override
*/ */
@@ -899,7 +909,7 @@ class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {
} }
/** /**
* Populates the tab plane with the description input. * Populates the tab panel with the description input.
* *
* @return {boolean} true if the description input matches this tab, or false otherwise * @return {boolean} true if the description input matches this tab, or false otherwise
* @override * @override
@@ -924,12 +934,12 @@ class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {
} }
} }
this.#to.value = found[4]; this.#to.value = found[4];
this.switchToMe(); this.editor.switchTo(this);
return true; return true;
} }
/** /**
* Validates the input in the tab plane. * Validates the input in the tab panel.
* *
* @return {boolean} true if valid, or false otherwise * @return {boolean} true if valid, or false otherwise
* @override * @override
@@ -974,11 +984,11 @@ class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {
} }
/** /**
* The bus trip tab plane. * The bus trip tab.
* *
* @private * @private
*/ */
class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane { class BusTripTab extends BaseTagTab {
/** /**
* The route * The route
@@ -1017,13 +1027,13 @@ class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {
#toError; #toError;
/** /**
* Constructs a tab plane. * Constructs a bus trip tab.
* *
* @param editor {DescriptionEditor} the parent description editor * @param editor {DescriptionEditor} the parent description editor
* @override * @override
*/ */
constructor(editor) { constructor(editor) {
super(editor); super("bus", 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`);
@@ -1048,17 +1058,7 @@ class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {
} }
/** /**
* The tab ID * Updates the description according to the input in the tab panel.
*
* @return {string}
* @abstract
*/
tabId() {
return "bus";
};
/**
* Updates the description according to the input in the tab plane.
* *
* @override * @override
*/ */
@@ -1067,7 +1067,7 @@ class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {
} }
/** /**
* Resets the tab plane input. * Resets the tab panel input.
* *
* @override * @override
*/ */
@@ -1085,7 +1085,7 @@ class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {
} }
/** /**
* Populates the tab plane with the description input. * Populates the tab panel with the description input.
* *
* @return {boolean} true if the description input matches this tab, or false otherwise * @return {boolean} true if the description input matches this tab, or false otherwise
* @override * @override
@@ -1102,12 +1102,12 @@ class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {
this.#route.value = found[2]; this.#route.value = found[2];
this.#from.value = found[3]; this.#from.value = found[3];
this.#to.value = found[4]; this.#to.value = found[4];
this.switchToMe(); this.editor.switchTo(this);
return true; return true;
} }
/** /**
* Validates the input in the tab plane. * Validates the input in the tab panel.
* *
* @return {boolean} true if valid, or false otherwise * @return {boolean} true if valid, or false otherwise
*/ */
@@ -1162,11 +1162,11 @@ class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {
} }
/** /**
* The recurring transaction tab plane. * The recurring transaction tab.
* *
* @private * @private
*/ */
class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane { class RecurringTab extends BaseDescriptionEditorTab {
/** /**
* The month names * The month names
@@ -1181,13 +1181,13 @@ class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
#itemButtons; #itemButtons;
/** /**
* Constructs a tab plane. * Constructs a recurring transaction tab.
* *
* @param editor {DescriptionEditor} the parent description editor * @param editor {DescriptionEditor} the parent description editor
* @override * @override
*/ */
constructor(editor) { constructor(editor) {
super(editor); super("recurring", editor);
this.#monthNames = [ this.#monthNames = [
"", "",
A_("January"), A_("February"), A_("March"), A_("April"), A_("January"), A_("February"), A_("March"), A_("April"),
@@ -1229,17 +1229,7 @@ class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
} }
/** /**
* The tab ID * Resets the tab panel input.
*
* @return {string}
* @abstract
*/
tabId() {
return "recurring";
};
/**
* Resets the tab plane input.
* *
* @override * @override
*/ */
@@ -1251,7 +1241,7 @@ class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
} }
/** /**
* Populates the tab plane with the description input. * Populates the tab panel with the description input.
* *
* @return {boolean} true if the description input matches this tab, or false otherwise * @return {boolean} true if the description input matches this tab, or false otherwise
* @override * @override
@@ -1261,7 +1251,7 @@ class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
if (this.#getDescription(itemButton) === this.editor.description) { 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.editor.switchTo(this);
return true; return true;
} }
} }
@@ -1269,11 +1259,10 @@ class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
} }
/** /**
* Switches to the tab plane. * @inheritDoc
* * @override
*/ */
switchToMe() { onActivated() {
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.updateCurrentSuggestedAccounts(itemButton); this.editor.updateCurrentSuggestedAccounts(itemButton);
@@ -1284,7 +1273,7 @@ class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
} }
/** /**
* Validates the input in the tab plane. * Validates the input in the tab panel.
* *
* @return {boolean} true if valid, or false otherwise * @return {boolean} true if valid, or false otherwise
* @override * @override
@@ -1295,20 +1284,20 @@ class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
} }
/** /**
* The annotation tab plane. * The annotation tab.
* *
* @private * @private
*/ */
class DescriptionEditorAnnotationTab extends DescriptionEditorTabPlane { class AnnotationTab extends BaseDescriptionEditorTab {
/** /**
* Constructs a tab plane. * Constructs an annotation tab.
* *
* @param editor {DescriptionEditor} the parent description editor * @param editor {DescriptionEditor} the parent description editor
* @override * @override
*/ */
constructor(editor) { constructor(editor) {
super(editor); super("annotation", editor);
this.editor.number.onchange = () => this.updateDescription(); this.editor.number.onchange = () => this.updateDescription();
this.editor.note.onchange = () => { this.editor.note.onchange = () => {
this.editor.note.value = this.editor.note.value.trim(); this.editor.note.value = this.editor.note.value.trim();
@@ -1317,17 +1306,7 @@ class DescriptionEditorAnnotationTab extends DescriptionEditorTabPlane {
} }
/** /**
* The tab ID * Updates the description according to the input in the tab panel.
*
* @return {string}
* @abstract
*/
tabId() {
return "annotation";
};
/**
* Updates the description according to the input in the tab plane.
* *
* @override * @override
*/ */
@@ -1345,7 +1324,7 @@ class DescriptionEditorAnnotationTab extends DescriptionEditorTabPlane {
} }
/** /**
* Resets the tab plane input. * Resets the tab panel input.
* *
* @override * @override
*/ */
@@ -1355,7 +1334,7 @@ class DescriptionEditorAnnotationTab extends DescriptionEditorTabPlane {
} }
/** /**
* Populates the tab plane with the description input. * Populates the tab panel with the description input.
* *
* @return {boolean} true if the description input matches this tab, or false otherwise * @return {boolean} true if the description input matches this tab, or false otherwise
* @override * @override
@@ -1379,7 +1358,7 @@ class DescriptionEditorAnnotationTab extends DescriptionEditorTabPlane {
} }
/** /**
* Validates the input in the tab plane. * Validates the input in the tab panel.
* *
* @return {boolean} true if valid, or false otherwise * @return {boolean} true if valid, or false otherwise
* @override * @override
@@ -25,14 +25,16 @@
/** /**
* The account selector. * The account selector.
* *
* @extends {BaseCombobox<BaseJournalEntryAccountOption>}
* @private
*/ */
class JournalEntryAccountSelector { class JournalEntryAccountSelector extends BaseCombobox {
/** /**
* The line item editor * The line item editor
* @type {JournalEntryLineItemEditor} * @type {JournalEntryLineItemEditor}
*/ */
lineItemEditor; #lineItemEditor;
/** /**
* Either "debit" or "credit" * Either "debit" or "credit"
@@ -46,12 +48,6 @@ class JournalEntryAccountSelector {
*/ */
#clearButton #clearButton
/**
* The query input
* @type {HTMLInputElement}
*/
#query;
/** /**
* The error message when the query has no result * The error message when the query has no result
* @type {HTMLParagraphElement} * @type {HTMLParagraphElement}
@@ -64,15 +60,9 @@ class JournalEntryAccountSelector {
*/ */
#optionList; #optionList;
/**
* The options
* @type {JournalEntryAccountOption[]}
*/
#options;
/** /**
* The more item to show all accounts * The more item to show all accounts
* @type {HTMLLIElement} * @type {MoreItems}
*/ */
#more; #more;
@@ -89,40 +79,55 @@ class JournalEntryAccountSelector {
* @param debitCredit {string} either "debit" or "credit" * @param debitCredit {string} either "debit" or "credit"
*/ */
constructor(lineItemEditor, debitCredit) { constructor(lineItemEditor, debitCredit) {
this.lineItemEditor = lineItemEditor
this.#debitCredit = debitCredit;
const prefix = `accounting-account-selector-${debitCredit}`; const prefix = `accounting-account-selector-${debitCredit}`;
this.#query = document.getElementById(`${prefix}-query`); const query = document.getElementById(`${prefix}-query`);
const options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new JournalEntryAccountOption(element, lineItemEditor.saveAccount.bind(lineItemEditor)));
super(query, options);
this.#lineItemEditor = lineItemEditor;
this.#debitCredit = debitCredit;
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)); const moreElement = document.getElementById(`${prefix}-more`);
this.#more = document.getElementById(`${prefix}-more`); this.#more = new MoreItems(moreElement);
this.#clearButton = document.getElementById(`${prefix}-btn-clear`); moreElement.onclick = () => {
this.#more.onclick = () => {
this.#isShowMore = true; this.#isShowMore = true;
this.#more.classList.add("d-none"); this.#more.setShown(false);
this.#filterOptions(); this.filterOptions();
};
this.#clearButton = document.getElementById(`${prefix}-btn-clear`);
this.#clearButton.onclick = () => this.#lineItemEditor.clearAccount();
const modal = document.getElementById(`${prefix}-modal`);
const closeButton = document.getElementById(`${prefix}-close`);
modal.onkeydown = (event) => {
if (event.key === "Escape") {
closeButton.click();
}
}; };
this.#query.oninput = () => this.#filterOptions();
this.#clearButton.onclick = () => this.lineItemEditor.clearAccount();
} }
/** /**
* Filters the options. * Filters the options.
* *
* @override
*/ */
#filterOptions() { filterOptions() {
this.shownOptions = [];
const codesInUse = this.#getCodesUsedInForm(); const codesInUse = this.#getCodesUsedInForm();
let isAnyMatched = false; let isAnyMatched = false;
for (const option of this.#options) { for (const option of this.options) {
if (option.isMatched(this.#isShowMore, codesInUse, this.#query.value)) { if (option.isMatched(this.#isShowMore, codesInUse, this.query.value)) {
option.setShown(true); option.setShown(true);
this.shownOptions.push(option);
isAnyMatched = true; isAnyMatched = true;
} else { } else {
option.setShown(false); option.setShown(false);
} }
} }
if (!this.#isShowMore) {
this.shownOptions.push(this.#more);
}
if (!isAnyMatched && this.#isShowMore) { 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");
@@ -138,9 +143,9 @@ class JournalEntryAccountSelector {
* @return {string[]} the account codes that are used in the form * @return {string[]} the account codes that are used in the form
*/ */
#getCodesUsedInForm() { #getCodesUsedInForm() {
const inUse = this.lineItemEditor.form.getAccountCodesUsed(this.#debitCredit); const inUse = this.#lineItemEditor.form.getAccountCodesUsed(this.#debitCredit);
if (this.lineItemEditor.account !== null) { if (this.#lineItemEditor.account !== null) {
inUse.push(this.lineItemEditor.account.code); inUse.push(this.#lineItemEditor.account.code);
} }
return inUse return inUse
} }
@@ -150,14 +155,13 @@ class JournalEntryAccountSelector {
* *
*/ */
onOpen() { onOpen() {
this.#query.value = ""; this.query.value = "";
this.#isShowMore = false; this.#isShowMore = false;
this.#more.classList.remove("d-none"); this.#more.setShown(true);
this.#filterOptions(); this.filterOptions();
for (const option of this.#options) { this.query.removeAttribute("aria-activedescendant");
option.setActive(this.lineItemEditor.account !== null && option.code === this.lineItemEditor.account.code); this.selectOption(this.shownOptions.find((option) => this.#lineItemEditor.account !== null && option.code === this.#lineItemEditor.account.code));
} if (this.#lineItemEditor.account === 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;
@@ -168,6 +172,17 @@ class JournalEntryAccountSelector {
} }
} }
/**
* Selects an option.
*
* @param option {BaseJournalEntryAccountOption|undefined} the option.
* @override
*/
selectOption(option) {
this.#more.setActive(false);
super.selectOption(option);
}
/** /**
* Returns the account selector instances. * Returns the account selector instances.
* *
@@ -185,22 +200,37 @@ class JournalEntryAccountSelector {
} }
/** /**
* An account option * The base abstract account option
* *
* @private
*/ */
class JournalEntryAccountOption { class BaseJournalEntryAccountOption extends BaseOption {
/**
* The element
* @type {HTMLLIElement}
*/
#element;
/** /**
* The account code * The account code
* @type {string} * @type {string}
*/ */
code; code = "";
/**
* Returns whether the account matches the query.
*
* @param isShowMore {boolean} true to show all accounts, or false to show only those in use
* @param codesInUse {string[]} the account codes that are used in the form
* @param query {string} the query term
* @return {boolean} true if the option matches, or false otherwise
*/
isMatched(isShowMore, codesInUse, query) {
return false;
}
}
/**
* An account option
*
* @private
*/
class JournalEntryAccountOption extends BaseJournalEntryAccountOption {
/** /**
* The account title * The account title
@@ -235,11 +265,11 @@ class JournalEntryAccountOption {
/** /**
* Constructs the account in the account selector. * Constructs the account in the account selector.
* *
* @param selector {JournalEntryAccountSelector} the account selector
* @param element {HTMLLIElement} the element * @param element {HTMLLIElement} the element
* @param save {function(JournalEntryAccountOption): void} the callback to save the option
*/ */
constructor(selector, element) { constructor(element, save) {
this.#element = element; super(element);
this.code = element.dataset.code; this.code = element.dataset.code;
this.title = element.dataset.title; this.title = element.dataset.title;
this.text = element.dataset.text; this.text = element.dataset.text;
@@ -247,7 +277,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 = () => selector.lineItemEditor.saveAccount(this); element.onclick = () => save(this);
} }
/** /**
@@ -257,6 +287,7 @@ class JournalEntryAccountOption {
* @param codesInUse {string[]} the account codes that are used in the form * @param codesInUse {string[]} the account codes that are used in the form
* @param query {string} the query term * @param query {string} the query term
* @return {boolean} true if the option matches, or false otherwise * @return {boolean} true if the option matches, or false otherwise
* @override
*/ */
isMatched(isShowMore, codesInUse, query) { isMatched(isShowMore, codesInUse, query) {
return this.#isInUseMatched(isShowMore, codesInUse) && this.#isQueryMatched(query); return this.#isInUseMatched(isShowMore, codesInUse) && this.#isQueryMatched(query);
@@ -290,30 +321,12 @@ class JournalEntryAccountOption {
} }
return false; return false;
} }
/**
* Sets whether the option is shown.
*
* @param isShown {boolean} true to show, or false otherwise
*/
setShown(isShown) {
if (isShown) {
this.#element.classList.remove("d-none");
} else {
this.#element.classList.add("d-none");
}
} }
/** /**
* Sets whether the option is active. * The more item to show all accounts.
* *
* @param isActive {boolean} true if active, or false otherwise * @private
*/ */
setActive(isActive) { class MoreItems extends BaseJournalEntryAccountOption {
if (isActive) {
this.#element.classList.add("active");
} else {
this.#element.classList.remove("active");
}
}
} }
@@ -701,12 +701,23 @@ class DebitCreditSubForm {
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.#element.role = "button";
this.#element.tabIndex = 0;
this.#element.onkeydown = (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
this.#element.click();
}
};
} 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.#element.removeAttribute("role");
this.#element.tabIndex = -1;
this.#element.onkeydown = null;
} }
setElementShown(this.#content, this.lineItems.length !== 0); setElementShown(this.#content, this.lineItems.length !== 0);
} }
@@ -986,6 +997,12 @@ class LineItemSubForm {
this.#element.parentElement.removeChild(this.#element); this.#element.parentElement.removeChild(this.#element);
this.debitCreditSubForm.deleteLineItem(this); this.debitCreditSubForm.deleteLineItem(this);
}; };
this.#control.onkeydown = (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
this.#control.click();
}
};
} }
/** /**
@@ -66,13 +66,13 @@ class JournalEntryLineItemEditor {
/** /**
* The control of the original line item * The control of the original line item
* @type {HTMLDivElement} * @type {HTMLButtonElement}
*/ */
#originalLineItemControl; #originalLineItemControl;
/** /**
* The original line item * The original line item
* @type {HTMLDivElement} * @type {HTMLSpanElement}
*/ */
#originalLineItemText; #originalLineItemText;
@@ -90,13 +90,13 @@ class JournalEntryLineItemEditor {
/** /**
* The control of the description * The control of the description
* @type {HTMLDivElement} * @type {HTMLButtonElement}
*/ */
#descriptionControl; #descriptionControl;
/** /**
* The description * The description
* @type {HTMLDivElement} * @type {HTMLSpanElement}
*/ */
#descriptionText; #descriptionText;
@@ -108,13 +108,13 @@ class JournalEntryLineItemEditor {
/** /**
* The control of the account * The control of the account
* @type {HTMLDivElement} * @type {HTMLButtonElement}
*/ */
#accountControl; #accountControl;
/** /**
* The account * The account
* @type {HTMLDivElement} * @type {HTMLSpanElement}
*/ */
#accountText; #accountText;
@@ -228,7 +228,7 @@ class JournalEntryLineItemEditor {
this.#accountSelectors = JournalEntryAccountSelector.getInstances(this); this.#accountSelectors = JournalEntryAccountSelector.getInstances(this);
this.originalLineItemSelector = new OriginalLineItemSelector(this); this.originalLineItemSelector = new OriginalLineItemSelector(this);
this.#originalLineItemControl.onclick = () => this.originalLineItemSelector.onOpen() this.#originalLineItemControl.onclick = () => this.originalLineItemSelector.onOpen(this.modal.id)
this.#originalLineItemDelete.onclick = () => this.clearOriginalLineItem(); this.#originalLineItemDelete.onclick = () => this.clearOriginalLineItem();
this.#descriptionControl.onclick = () => this.#descriptionEditors[this.debitCredit].onOpen(); this.#descriptionControl.onclick = () => this.#descriptionEditors[this.debitCredit].onOpen();
this.#accountControl.onclick = () => this.#accountSelectors[this.debitCredit].onOpen(); this.#accountControl.onclick = () => this.#accountSelectors[this.debitCredit].onOpen();
@@ -290,7 +290,9 @@ class JournalEntryLineItemEditor {
this.account = originalLineItem.account.copy(); this.account = originalLineItem.account.copy();
this.isAccountConfirmed = false; this.isAccountConfirmed = false;
this.#accountText.innerText = this.account.text; this.#accountText.innerText = this.account.text;
if (this.#amountInput.value === "" || new Decimal(this.#amountInput.value).greaterThan(originalLineItem.netBalance)) {
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";
this.#validate(); this.#validate();
+56 -73
View File
@@ -175,6 +175,7 @@ class OptionForm {
/** /**
* The recurring expenses or incomes sub-form. * The recurring expenses or incomes sub-form.
* *
* @private
*/ */
class RecurringExpenseIncomeSubForm { class RecurringExpenseIncomeSubForm {
@@ -297,6 +298,14 @@ class RecurringExpenseIncomeSubForm {
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");
this.#element.role = "button";
this.#element.tabIndex = 0;
this.#element.onkeydown = (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
this.#element.click();
}
};
} 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");
@@ -304,6 +313,9 @@ class RecurringExpenseIncomeSubForm {
delete this.#element.dataset.bsTarget; delete this.#element.dataset.bsTarget;
this.#element.onclick = null; this.#element.onclick = null;
this.#content.classList.remove("d-none"); this.#content.classList.remove("d-none");
this.#element.removeAttribute("role");
this.#element.tabIndex = -1;
this.#element.onkeydown = null;
} }
} }
@@ -350,6 +362,7 @@ class RecurringExpenseIncomeSubForm {
/** /**
* A recurring item sub-form. * A recurring item sub-form.
* *
* @private
*/ */
class RecurringItemSubForm { class RecurringItemSubForm {
@@ -373,7 +386,7 @@ class RecurringItemSubForm {
/** /**
* The control * The control
* @type {HTMLDivElement} * @type {HTMLButtonElement}
*/ */
#control; #control;
@@ -397,7 +410,7 @@ class RecurringItemSubForm {
/** /**
* The text display of the name * The text display of the name
* @type {HTMLDivElement} * @type {HTMLSpanElement}
*/ */
#nameText; #nameText;
@@ -409,7 +422,7 @@ class RecurringItemSubForm {
/** /**
* The text display of the account * The text display of the account
* @type {HTMLDivElement} * @type {HTMLSpanElement}
*/ */
#accountText; #accountText;
@@ -421,7 +434,7 @@ class RecurringItemSubForm {
/** /**
* The text display of the description template * The text display of the description template
* @type {HTMLDivElement} * @type {HTMLSpanElement}
*/ */
#descriptionTemplateText; #descriptionTemplateText;
@@ -551,6 +564,7 @@ class RecurringItemSubForm {
/** /**
* The recurring item editor. * The recurring item editor.
* *
* @private
*/ */
class RecurringItemEditor { class RecurringItemEditor {
@@ -592,13 +606,13 @@ class RecurringItemEditor {
/** /**
* The control of the account * The control of the account
* @type {HTMLDivElement} * @type {HTMLButtonElement}
*/ */
#accountControl; #accountControl;
/** /**
* The text display of the account * The text display of the account
* @type {HTMLDivElement} * @type {HTMLSpanElement}
*/ */
#accountContainer; #accountContainer;
@@ -829,14 +843,16 @@ class RecurringItemEditor {
/** /**
* The account selector for the recurring item editor. * The account selector for the recurring item editor.
* *
* @extends {BaseCombobox<RecurringAccount>}
* @private
*/ */
class RecurringAccountSelector { class RecurringAccountSelector extends BaseCombobox {
/** /**
* The recurring item editor * The recurring item editor
* @type {RecurringItemEditor} * @type {RecurringItemEditor}
*/ */
editor; #editor;
/** /**
* Either "expense" or "income" * Either "expense" or "income"
@@ -844,12 +860,6 @@ class RecurringAccountSelector {
*/ */
#expenseIncome; #expenseIncome;
/**
* The query input
* @type {HTMLInputElement}
*/
#query;
/** /**
* The error message when the query has no result * The error message when the query has no result
* @type {HTMLParagraphElement} * @type {HTMLParagraphElement}
@@ -862,12 +872,6 @@ class RecurringAccountSelector {
*/ */
#optionList; #optionList;
/**
* The account options
* @type {RecurringAccount[]}
*/
#options;
/** /**
* The button to clear the account * The button to clear the account
* @type {HTMLButtonElement} * @type {HTMLButtonElement}
@@ -880,28 +884,39 @@ class RecurringAccountSelector {
* @param editor {RecurringItemEditor} the recurring item editor * @param editor {RecurringItemEditor} the recurring item editor
*/ */
constructor(editor) { constructor(editor) {
this.editor = editor;
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`); const query = document.getElementById(`${prefix}-query`);
const options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new RecurringAccount(element, editor.saveAccount.bind(editor)));
super(query, options);
this.#editor = editor;
this.#expenseIncome = editor.expenseIncome;
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.#clearButton = document.getElementById(`${prefix}-clear`);
this.#query.oninput = () => this.#filterOptions(); this.#clearButton = document.getElementById(`${prefix}-clear`);
this.#clearButton.onclick = () => this.editor.clearAccount(); this.#clearButton.onclick = () => this.#editor.clearAccount();
const modal = document.getElementById(`${prefix}-modal`);
const closeButton = document.getElementById(`${prefix}-close`);
modal.onkeydown = (event) => {
if (event.key === "Escape") {
closeButton.click();
}
};
} }
/** /**
* Filters the options. * Filters the options.
* *
* @override
*/ */
#filterOptions() { filterOptions() {
this.shownOptions = [];
let isAnyMatched = false; let isAnyMatched = false;
for (const option of this.#options) { for (const option of this.options) {
if (option.isMatched(this.#query.value)) { if (option.isMatched(this.query.value)) {
option.setShown(true); option.setShown(true);
this.shownOptions.push(option);
isAnyMatched = true; isAnyMatched = true;
} else { } else {
option.setShown(false); option.setShown(false);
@@ -921,12 +936,11 @@ class RecurringAccountSelector {
* *
*/ */
onOpen() { onOpen() {
this.#query.value = ""; this.query.value = "";
this.#filterOptions(); this.filterOptions();
for (const option of this.#options) { this.query.removeAttribute("aria-activedescendant");
option.setActive(option.code === this.editor.accountCode); this.selectOption(this.shownOptions.find((option) => option.code === this.#editor.accountCode));
} if (this.#editor.accountCode === null) {
if (this.editor.accountCode === 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;
@@ -941,14 +955,9 @@ class RecurringAccountSelector {
/** /**
* An account in the account selector for the recurring item editor. * An account in the account selector for the recurring item editor.
* *
* @private
*/ */
class RecurringAccount { class RecurringAccount extends BaseOption {
/**
* The element
* @type {HTMLLIElement}
*/
#element;
/** /**
* The account code * The account code
@@ -971,16 +980,16 @@ class RecurringAccount {
/** /**
* Constructs the account in the account selector for the recurring item editor. * Constructs the account in the account selector for the recurring item editor.
* *
* @param selector {RecurringAccountSelector} the account selector
* @param element {HTMLLIElement} the element * @param element {HTMLLIElement} the element
* @param save {function(RecurringAccount): void} the callback to save the option
*/ */
constructor(selector, element) { constructor(element, save) {
this.#element = element; super(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 = () => selector.editor.saveAccount(this); element.onclick = () => save(this);
} }
/** /**
@@ -1000,30 +1009,4 @@ class RecurringAccount {
} }
return false; return false;
} }
/**
* Sets whether the option is shown.
*
* @param isShown {boolean} true to show, or false otherwise
*/
setShown(isShown) {
if (isShown) {
this.#element.classList.remove("d-none");
} else {
this.#element.classList.add("d-none");
}
}
/**
* Sets whether the option is active.
*
* @param isActive {boolean} true if active, or false otherwise
*/
setActive(isActive) {
if (isActive) {
this.#element.classList.add("active");
} else {
this.#element.classList.remove("active");
}
}
} }
@@ -25,26 +25,16 @@
/** /**
* The original line item selector. * The original line item selector.
* *
* @extends {BaseCombobox<OriginalLineItem>}
* @private
*/ */
class OriginalLineItemSelector { class OriginalLineItemSelector extends BaseCombobox {
/** /**
* The line item editor * The line item editor
* @type {JournalEntryLineItemEditor} * @type {JournalEntryLineItemEditor}
*/ */
lineItemEditor; #lineItemEditor;
/**
* The prefix of the HTML ID and class names
* @type {string}
*/
#prefix = "accounting-original-line-item-selector";
/**
* The query input
* @type {HTMLInputElement}
*/
#query;
/** /**
* The error message when the query has no result * The error message when the query has no result
@@ -58,12 +48,6 @@ class OriginalLineItemSelector {
*/ */
#optionList; #optionList;
/**
* The options
* @type {OriginalLineItem[]}
*/
#options;
/** /**
* The options by their ID * The options by their ID
* @type {Object.<string, OriginalLineItem>} * @type {Object.<string, OriginalLineItem>}
@@ -81,22 +65,37 @@ class OriginalLineItemSelector {
*/ */
#debitCredit; #debitCredit;
/**
* The close button.
* @type {HTMLButtonElement}
*/
#closeButton;
/** /**
* Constructs an original line item selector. * Constructs an original line item selector.
* *
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor * @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
*/ */
constructor(lineItemEditor) { constructor(lineItemEditor) {
this.lineItemEditor = lineItemEditor; const prefix = "accounting-original-line-item-selector";
this.#query = document.getElementById(`${this.#prefix}-query`); const query = document.getElementById(`${prefix}-query`);
this.#queryNoResult = document.getElementById(`${this.#prefix}-option-no-result`); const options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new OriginalLineItem(element, lineItemEditor.saveOriginalLineItem.bind(lineItemEditor), lineItemEditor.form));
this.#optionList = document.getElementById(`${this.#prefix}-option-list`); super(query, options);
this.#options = Array.from(document.getElementsByClassName(`${this.#prefix}-option`)).map((element) => new OriginalLineItem(this, element)); this.#lineItemEditor = lineItemEditor;
this.#queryNoResult = document.getElementById(`${prefix}-option-no-result`);
this.#optionList = document.getElementById(`${prefix}-option-list`);
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;
} }
this.#query.oninput = () => this.#filterOptions(); this.#closeButton = document.getElementById(`${prefix}-close`);
const modal = document.getElementById(`${prefix}-modal`);
modal.onkeydown = (event) => {
if (event.key === "Escape") {
this.#closeButton.click();
}
};
} }
/** /**
@@ -126,7 +125,7 @@ class OriginalLineItemSelector {
* *
*/ */
#updateNetBalances() { #updateNetBalances() {
const otherLineItems = this.lineItemEditor.form.getLineItems().filter((lineItem) => lineItem !== this.lineItemEditor.lineItem); const otherLineItems = this.#lineItemEditor.form.getLineItems().filter((lineItem) => lineItem !== this.#lineItemEditor.lineItem);
const otherOffsets = {} const otherOffsets = {}
for (const otherLineItem of otherLineItems) { for (const otherLineItem of otherLineItems) {
const otherOriginalLineItemId = otherLineItem.originalLineItemId; const otherOriginalLineItemId = otherLineItem.originalLineItemId;
@@ -139,7 +138,7 @@ class OriginalLineItemSelector {
} }
otherOffsets[otherOriginalLineItemId] = otherOffsets[otherOriginalLineItemId].plus(amount); otherOffsets[otherOriginalLineItemId] = otherOffsets[otherOriginalLineItemId].plus(amount);
} }
for (const option of this.#options) { for (const option of this.options) {
if (option.id in otherOffsets) { if (option.id in otherOffsets) {
option.updateNetBalance(otherOffsets[option.id]); option.updateNetBalance(otherOffsets[option.id]);
} else { } else {
@@ -151,12 +150,15 @@ class OriginalLineItemSelector {
/** /**
* Filters the options. * Filters the options.
* *
* @override
*/ */
#filterOptions() { filterOptions() {
this.shownOptions = [];
let isAnyMatched = false; let isAnyMatched = false;
for (const option of this.#options) { for (const option of this.options) {
if (option.isMatched(this.#debitCredit, this.#currencyCode, this.#query.value)) { if (option.isMatched(this.#debitCredit, this.#currencyCode, this.query.value)) {
option.setShown(true); option.setShown(true);
this.shownOptions.push(option);
isAnyMatched = true; isAnyMatched = true;
} else { } else {
option.setShown(false); option.setShown(false);
@@ -174,24 +176,26 @@ class OriginalLineItemSelector {
/** /**
* The callback when the original line item selector is shown. * The callback when the original line item selector is shown.
* *
* @param parentID {string} the ID of the parent element
*/ */
onOpen() { onOpen(parentID) {
this.#currencyCode = this.lineItemEditor.currencyCode; this.#closeButton.dataset.bsTarget = `#${parentID}`;
this.#debitCredit = this.lineItemEditor.debitCredit; this.#currencyCode = this.#lineItemEditor.currencyCode;
for (const option of this.#options) { this.#debitCredit = this.#lineItemEditor.debitCredit;
option.setActive(option.id === this.lineItemEditor.originalLineItemId); this.query.value = "";
}
this.#query.value = "";
this.#updateNetBalances(); this.#updateNetBalances();
this.#filterOptions(); this.filterOptions();
this.query.removeAttribute("aria-activedescendant");
this.selectOption(this.shownOptions.find((option) => option.id === this.#lineItemEditor.originalLineItemId));
} }
} }
/** /**
* An original line item. * An original line item.
* *
* @private
*/ */
class OriginalLineItem { class OriginalLineItem extends BaseOption {
/** /**
* The journal entry form * The journal entry form
@@ -199,12 +203,6 @@ class OriginalLineItem {
*/ */
#form; #form;
/**
* The element
* @type {HTMLLIElement}
*/
#element;
/** /**
* The ID * The ID
* @type {string} * @type {string}
@@ -274,12 +272,13 @@ class OriginalLineItem {
/** /**
* Constructs an original line item. * Constructs an original line item.
* *
* @param selector {OriginalLineItemSelector} the original line item selector
* @param element {HTMLLIElement} the element * @param element {HTMLLIElement} the element
* @param save {function(OriginalLineItem): void} the callback to save the option
* @param form {JournalEntryForm} the journal entry form
*/ */
constructor(selector, element) { constructor(element, save, form) {
this.#form = selector.lineItemEditor.form; super(element);
this.#element = element; this.#form = form;
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;
@@ -291,7 +290,8 @@ class OriginalLineItem {
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 = () => selector.lineItemEditor.saveOriginalLineItem(this);
element.onclick = () => save(this);
} }
/** /**
@@ -380,30 +380,4 @@ class OriginalLineItem {
const whole = Number(this.netBalance.minus(frac)); const whole = Number(this.netBalance.minus(frac));
return String(whole) + String(frac).substring(1); return String(whole) + String(frac).substring(1);
} }
/**
* Sets whether the option is shown.
*
* @param isShown {boolean} true to show, or false otherwise
*/
setShown(isShown) {
if (isShown) {
this.#element.classList.remove("d-none");
} else {
this.#element.classList.add("d-none");
}
}
/**
* Sets whether the option is active.
*
* @param isActive {boolean} true if active, or false otherwise
*/
setActive(isActive) {
if (isActive) {
this.#element.classList.add("active");
} else {
this.#element.classList.remove("active");
}
}
} }
+56 -111
View File
@@ -2,7 +2,7 @@
* period-chooser.js: The JavaScript for the period chooser * period-chooser.js: The JavaScript for the period chooser
*/ */
/* Copyright (c) 2023 imacat. /* Copyright (c) 2023-2026 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.
@@ -30,20 +30,16 @@ document.addEventListener("DOMContentLoaded", () => {
/** /**
* The period chooser. * The period chooser.
* *
* @extends {BaseTablist<BasePeriodTab>}
* @private
*/ */
class PeriodChooser { class PeriodChooser extends BaseTablist {
/** /**
* The modal of the period chooser * The URL template for different periods.
* @type {HTMLDivElement} * @type {string}
*/ */
modal; urlTemplate;
/**
* The tab planes
* @type {{month: MonthTab, year: YearTab, day: DayTab, custom: CustomTab}}
*/
tabPlanes = {};
/** /**
* Constructs the period chooser. * Constructs the period chooser.
@@ -51,12 +47,23 @@ class PeriodChooser {
*/ */
constructor() { constructor() {
const prefix = "accounting-period-chooser"; const prefix = "accounting-period-chooser";
this.modal = document.getElementById(`${prefix}-modal`); super(document.getElementById(`${prefix}-tab-list`));
for (const cls of [MonthTab, YearTab, DayTab, CustomTab]) { this.tabs = [new MonthTab(this), new YearTab(this), new DayTab(this), new CustomTab(this)];
const tab = new cls(this); const modal = document.getElementById(`${prefix}-modal`);
this.tabPlanes[tab.tabId()] = tab; this.urlTemplate = modal.dataset.urlTemplate;
for (const tab of this.tabs) {
if (tab.isActive()) {
this.currentTab = tab;
break;
} }
} }
}
/**
* @inheritDoc
* @override
*/
onTabFocus(tab) { this.switchTo(tab); }
/** /**
* The period chooser. * The period chooser.
@@ -74,12 +81,12 @@ class PeriodChooser {
} }
/** /**
* A tab plane. * A base abstract period tab.
* *
* @abstract * @abstract
* @private * @private
*/ */
class TabPlane { class BasePeriodTab extends BaseTab {
/** /**
* The period chooser * The period chooser
@@ -94,62 +101,27 @@ class TabPlane {
prefix; prefix;
/** /**
* The tab * Constructs a base abstract period tab.
* @type {HTMLSpanElement}
*/
#tab;
/**
* The page
* @type {HTMLDivElement}
*/
#page;
/**
* Constructs a tab plane.
* *
* @param tabID {string} the tab ID
* @param chooser {PeriodChooser} the period chooser * @param chooser {PeriodChooser} the period chooser
*/ */
constructor(chooser) { constructor(tabID, chooser) {
const prefix = `accounting-period-chooser-${tabID}`;
const tab = document.getElementById(`${prefix}-tab`);
const panel = document.getElementById(`${prefix}-panel`);
super(tab, panel, chooser.switchTo.bind(chooser));
this.chooser = chooser; this.chooser = chooser;
this.prefix = `accounting-period-chooser-${this.tabId()}`; this.prefix = prefix;
this.#tab = document.getElementById(`${this.prefix}-tab`);
this.#page = document.getElementById(`${this.prefix}-page`);
this.#tab.onclick = () => this.#switchToMe();
}
/**
* The tab ID
*
* @return {string}
* @abstract
*/
tabId() { throw new Error("Method not implemented.") };
/**
* Switches to the tab plane.
*
*/
#switchToMe() {
for (const tabPlane of Object.values(this.chooser.tabPlanes)) {
tabPlane.#tab.classList.remove("active")
tabPlane.#tab.ariaCurrent = "false";
tabPlane.#page.classList.add("d-none");
tabPlane.#page.ariaCurrent = "false";
}
this.#tab.classList.add("active");
this.#tab.ariaCurrent = "page";
this.#page.classList.remove("d-none");
this.#page.ariaCurrent = "page";
} }
} }
/** /**
* The month tab plane. * The month tab.
* *
* @private * @private
*/ */
class MonthTab extends TabPlane { class MonthTab extends BasePeriodTab {
/** /**
* The month chooser. * The month chooser.
@@ -158,12 +130,12 @@ class MonthTab extends TabPlane {
#monthChooser #monthChooser
/** /**
* Constructs a tab plane. * Constructs a month tab.
* *
* @param chooser {PeriodChooser} the period chooser * @param chooser {PeriodChooser} the period chooser
*/ */
constructor(chooser) { constructor(chooser) {
super(chooser); super("month", chooser);
const monthChooser = document.getElementById(`${this.prefix}-chooser`); const monthChooser = document.getElementById(`${this.prefix}-chooser`);
if (monthChooser !== null) { if (monthChooser !== null) {
this.#monthChooser = new tempusDominus.TempusDominus(monthChooser, { this.#monthChooser = new tempusDominus.TempusDominus(monthChooser, {
@@ -183,45 +155,36 @@ class MonthTab extends TabPlane {
const date = e.detail.date; const date = e.detail.date;
const zeroPaddedMonth = `0${date.month + 1}`.slice(-2) const zeroPaddedMonth = `0${date.month + 1}`.slice(-2)
const period = `${date.year}-${zeroPaddedMonth}`; const period = `${date.year}-${zeroPaddedMonth}`;
window.location = chooser.modal.dataset.urlTemplate window.location = chooser.urlTemplate
.replaceAll("PERIOD", period); .replaceAll("PERIOD", period);
}); });
} }
} }
/**
* The tab ID
*
* @return {string}
*/
tabId() {
return "month";
}
} }
/** /**
* The year tab plane. * The year tab.
* *
* @private * @private
*/ */
class YearTab extends TabPlane { class YearTab extends BasePeriodTab {
/** /**
* The tab ID * Constructs a year tab.
* *
* @return {string} * @param chooser {PeriodChooser} the period chooser
*/ */
tabId() { constructor(chooser) {
return "year"; super("year", chooser);
} }
} }
/** /**
* The day tab plane. * The day tab.
* *
* @private * @private
*/ */
class DayTab extends TabPlane { class DayTab extends BasePeriodTab {
/** /**
* The day input * The day input
@@ -236,18 +199,18 @@ class DayTab extends TabPlane {
#dateError; #dateError;
/** /**
* Constructs a tab plane. * Constructs a day tab.
* *
* @param chooser {PeriodChooser} the period chooser * @param chooser {PeriodChooser} the period chooser
*/ */
constructor(chooser) { constructor(chooser) {
super(chooser); super("day", chooser);
this.#date = document.getElementById(`${this.prefix}-date`); this.#date = document.getElementById(`${this.prefix}-date`);
this.#dateError = document.getElementById(`${this.prefix}-date-error`); this.#dateError = document.getElementById(`${this.prefix}-date-error`);
if (this.#date !== null) { if (this.#date !== null) {
this.#date.onchange = () => { this.#date.onchange = () => {
if (this.#validateDate()) { if (this.#validateDate()) {
window.location = chooser.modal.dataset.urlTemplate window.location = chooser.urlTemplate
.replaceAll("PERIOD", this.#date.value); .replaceAll("PERIOD", this.#date.value);
} }
}; };
@@ -274,23 +237,14 @@ class DayTab extends TabPlane {
this.#dateError.innerText = ""; this.#dateError.innerText = "";
return true; return true;
} }
/**
* The tab ID
*
* @return {string}
*/
tabId() {
return "day";
}
} }
/** /**
* The custom tab plane. * The custom tab.
* *
* @private * @private
*/ */
class CustomTab extends TabPlane { class CustomTab extends BasePeriodTab {
/** /**
* The start of the period * The start of the period
@@ -320,20 +274,20 @@ class CustomTab extends TabPlane {
* The confirm button * The confirm button
* @type {HTMLButtonElement} * @type {HTMLButtonElement}
*/ */
#conform; #confirm;
/** /**
* Constructs a tab plane. * Constructs a custom tab.
* *
* @param chooser {PeriodChooser} the period chooser * @param chooser {PeriodChooser} the period chooser
*/ */
constructor(chooser) { constructor(chooser) {
super(chooser); super("custom", chooser);
this.#start = document.getElementById(`${this.prefix}-start`); this.#start = document.getElementById(`${this.prefix}-start`);
this.#startError = document.getElementById(`${this.prefix}-start-error`); this.#startError = document.getElementById(`${this.prefix}-start-error`);
this.#end = document.getElementById(`${this.prefix}-end`); this.#end = document.getElementById(`${this.prefix}-end`);
this.#endError = document.getElementById(`${this.prefix}-end-error`); this.#endError = document.getElementById(`${this.prefix}-end-error`);
this.#conform = document.getElementById(`${this.prefix}-confirm`); this.#confirm = document.getElementById(`${this.prefix}-confirm`);
if (this.#start !== null) { if (this.#start !== null) {
this.#start.onchange = () => { this.#start.onchange = () => {
if (this.#validateStart()) { if (this.#validateStart()) {
@@ -345,12 +299,12 @@ class CustomTab extends TabPlane {
this.#start.max = this.#end.value; this.#start.max = this.#end.value;
} }
}; };
this.#conform.onclick = () => { this.#confirm.onclick = () => {
let isValid = true; let isValid = true;
isValid = this.#validateStart() && isValid; isValid = this.#validateStart() && isValid;
isValid = this.#validateEnd() && isValid; isValid = this.#validateEnd() && isValid;
if (isValid) { if (isValid) {
window.location = chooser.modal.dataset.urlTemplate window.location = chooser.urlTemplate
.replaceAll("PERIOD", `${this.#start.value}-${this.#end.value}`); .replaceAll("PERIOD", `${this.#start.value}-${this.#end.value}`);
} }
}; };
@@ -406,13 +360,4 @@ class CustomTab extends TabPlane {
this.#endError.innerText = ""; this.#endError.innerText = "";
return true; return true;
} }
/**
* The tab ID
*
* @return {string}
*/
tabId() {
return "custom";
}
} }
+37
View File
@@ -0,0 +1,37 @@
/* The Mia! Accounting Project
* timezone.js: The JavaScript for the timezone
*/
/* Copyright (c) 2024 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2024/6/4
*/
"use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
setTimeZone();
});
/**
* Sets the time zone.
*
* @private
*/
function setTimeZone() {
document.cookie = `accounting-tz=${Intl.DateTimeFormat().resolvedOptions().timeZone}; SameSite=Strict`;
}
+4 -3
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -23,7 +23,8 @@ from typing import Any
from flask_babel import get_locale from flask_babel import get_locale
from accounting.locale import gettext from .locale import gettext
from .utils.timezone import get_tz_today
def format_amount(value: Decimal | None) -> str | None: def format_amount(value: Decimal | None) -> str | None:
@@ -47,7 +48,7 @@ def format_date(value: dt.date) -> str:
:param value: The date. :param value: The date.
:return: The human-friendly date text. :return: The human-friendly date text.
""" """
today: dt.date = dt.date.today() today: dt.date = get_tz_today()
if value == today: if value == today:
return gettext("Today") return gettext("Today")
if value == today - dt.timedelta(days=1): if value == today - dt.timedelta(days=1):
+8 -4
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting 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-2026 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.
@@ -17,8 +17,11 @@
"""The template globals. """The template globals.
""" """
from accounting.models import Currency import sqlalchemy as sa
from accounting.utils.options import options
from . import db
from .models import Currency
from .utils.options import options
def currency_options() -> list[Currency]: def currency_options() -> list[Currency]:
@@ -26,7 +29,8 @@ def currency_options() -> list[Currency]:
:return: The currency options. :return: The currency options.
""" """
return Currency.query.order_by(Currency.code).all() return db.session.scalars(
sa.select(Currency).order_by(Currency.code)).unique().all()
def default_currency_code() -> str: def default_currency_code() -> str:
@@ -23,6 +23,6 @@ First written: 2023/2/1
{% block header %}{% block title %}{{ A_("Add a New Account") }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Add a New Account") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.account.list") }}{% endblock %} {% block back_url %}{{ url_for("accounting.account.list")|accounting_or_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.account.store") }}{% endblock %} {% block action_url %}{{ url_for("accounting.account.store") }}{% endblock %}
@@ -27,28 +27,28 @@ First written: 2023/1/31
<div class="mb-3 accounting-toolbar"> <div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.list")|accounting_or_next }}"> <a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i> <i class="fa-solid fa-circle-chevron-left" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Back") }}</span> <span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a> </a>
{% if accounting_can_edit() %} {% if accounting_can_edit() %}
<a class="btn btn-primary d-none d-md-inline" role="button" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}"> <a class="btn btn-primary d-none d-md-inline" role="button" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i> <i class="fa-solid fa-pen-to-square" aria-hidden="true"></i>
{{ A_("Edit") }} {{ A_("Edit") }}
</a> </a>
{% endif %} {% endif %}
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.order", base=obj.base)|accounting_append_next }}"> <a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.order", base=obj.base)|accounting_append_next }}">
<i class="fa-solid fa-bars-staggered"></i> <i class="fa-solid fa-bars-staggered" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Order") }}</span> <span class="d-none d-md-inline">{{ A_("Order") }}</span>
</a> </a>
{% if accounting_can_edit() %} {% if accounting_can_edit() %}
{% if obj.can_delete %} {% if obj.can_delete %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal"> <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i> <i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Delete") }}</span> <span class="d-none d-md-inline">{{ A_("Delete") }}</span>
</button> </button>
{% else %} {% else %}
<button class="btn btn-secondary" type="button" disabled="disabled"> <button class="btn btn-secondary" type="button" disabled="disabled">
<i class="fa-solid fa-trash"></i> <i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Delete") }}</span> <span class="d-none d-md-inline">{{ A_("Delete") }}</span>
</button> </button>
{% endif %} {% endif %}
@@ -57,8 +57,8 @@ First written: 2023/1/31
{% if accounting_can_edit() %} {% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab"> <div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}"> <a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}" aria-label="{{ A_("Edit") }}">
<i class="fa-solid fa-pen-to-square"></i> <i class="fa-solid fa-pen-to-square" aria-hidden="true"></i>
</a> </a>
</div> </div>
{% endif %} {% endif %}
@@ -90,7 +90,7 @@ First written: 2023/1/31
{% endif %} {% endif %}
<div class="accounting-card col-sm-6"> <div class="accounting-card col-sm-6">
<div class="accounting-card-title">{{ obj.title|title }}</div> <div class="accounting-card-title">{{ obj.title }}</div>
<div class="accounting-card-code">{{ obj.code }}</div> <div class="accounting-card-code">{{ obj.code }}</div>
{% if obj.is_need_offset %} {% if obj.is_need_offset %}
<div> <div>
@@ -22,6 +22,7 @@ First written: 2023/2/1
{% extends "accounting/base.html" %} {% extends "accounting/base.html" %}
{% block accounting_scripts %} {% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/base-combobox.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/account-form.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/account-form.js") }}"></script>
{% endblock %} {% endblock %}
@@ -29,7 +30,7 @@ First written: 2023/2/1
<div class="mb-3 accounting-toolbar"> <div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}"> <a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i> <i class="fa-solid fa-circle-chevron-left" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Back") }}</span> <span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a> </a>
</div> </div>
@@ -41,9 +42,9 @@ First written: 2023/2/1
{% endif %} {% endif %}
<div class="form-floating mb-3"> <div class="form-floating mb-3">
<input id="accounting-base-code" type="hidden" name="base_code" value="{{ form.base_code.data|accounting_default }}"> <input id="accounting-base-code" type="hidden" name="base_code" value="{{ form.base_code.data|accounting_default }}">
<div id="accounting-base-control" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-modal"> <button id="accounting-base-control" class="form-control text-start accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" type="button" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-modal">
<label class="form-label" for="accounting-base">{{ A_("Base account") }}</label> <span class="form-label">{{ A_("Base account") }}</span>
<div id="accounting-base"> <span id="accounting-base">
{% if form.base_code.data %} {% if form.base_code.data %}
{% if form.base_code.errors %} {% if form.base_code.errors %}
{{ A_("(Unknown)") }} {{ A_("(Unknown)") }}
@@ -51,15 +52,15 @@ First written: 2023/2/1
{{ form.selected_base }} {{ form.selected_base }}
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </span>
</div> </button>
<div id="accounting-base-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div> <div id="accounting-base-error" class="invalid-feedback" role="alert">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
</div> </div>
<div class="form-floating mb-3"> <div class="form-floating mb-3">
<input id="accounting-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ form.title.data|accounting_default }}" placeholder=" " required="required"> <input id="accounting-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ form.title.data|accounting_default }}" placeholder=" " required="required">
<label class="form-label" for="accounting-title">{{ A_("Title") }}</label> <label class="form-label" for="accounting-title">{{ A_("Title") }}</label>
<div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div> <div id="accounting-title-error" class="invalid-feedback" role="alert">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
</div> </div>
<div id="accounting-is-need-offset-control" class="form-check form-switch mb-3 {% if form.base_code.data[0] not in ["1", "2", "3"] %} d-none {% endif %}"> <div id="accounting-is-need-offset-control" class="form-check form-switch mb-3 {% if form.base_code.data[0] not in ["1", "2", "3"] %} d-none {% endif %}">
@@ -71,14 +72,14 @@ First written: 2023/2/1
<div class="d-none d-md-block"> <div class="d-none d-md-block">
<button class="btn btn-primary" type="submit"> <button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i> <i class="fa-solid fa-floppy-disk" aria-hidden="true"></i>
{{ A_("Save") }} {{ A_("Save") }}
</button> </button>
</div> </div>
<div class="d-md-none accounting-material-fab"> <div class="d-md-none accounting-material-fab">
<button class="btn btn-primary" type="submit"> <button class="btn btn-primary" type="submit" aria-label="{{ A_("Save") }}">
<i class="fa-solid fa-floppy-disk"></i> <i class="fa-solid fa-floppy-disk" aria-hidden="true"></i>
</button> </button>
</div> </div>
</form> </form>
@@ -92,21 +93,21 @@ First written: 2023/2/1
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="input-group mb-2"> <div class="input-group mb-2">
<input id="accounting-base-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required"> <input id="accounting-base-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required" role="combobox" aria-expanded="true" aria-controls="accounting-base-selector-option-list" aria-autocomplete="list" aria-activedescendant="">
<label class="input-group-text" for="accounting-base-selector-query"> <label class="input-group-text" for="accounting-base-selector-query">
<i class="fa-solid fa-magnifying-glass"></i> <i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
{{ A_("Search") }} {{ A_("Search") }}
</label> </label>
</div> </div>
<ul id="accounting-base-selector-option-list" class="list-group accounting-selector-list"> <ul id="accounting-base-selector-option-list" class="list-group accounting-selector-list" role="listbox" tabindex="-1" aria-labelledby="accounting-base-selector-modal-label">
{% for base in form.base_options %} {% for base in form.base_options %}
<li class="list-group-item accounting-clickable accounting-base-selector-option" data-code="{{ base.code }}" data-text="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}" data-bs-dismiss="modal"> <li id="accounting-base-selector-option-{{ base.code }}" class="list-group-item accounting-clickable accounting-base-selector-option" role="option" aria-selected="false" data-code="{{ base.code }}" data-text="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}" data-bs-dismiss="modal">
{{ base }} {{ base }}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<p id="accounting-base-selector-option-no-result" class="d-none">{{ A_("There is no data.") }}</p> <p id="accounting-base-selector-option-no-result" class="d-none" role="status">{{ A_("There is no data.") }}</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
@@ -28,15 +28,15 @@ First written: 2023/1/30
<div class="mb-2 accounting-toolbar"> <div class="mb-2 accounting-toolbar">
{% if accounting_can_edit() %} {% if accounting_can_edit() %}
<a class="btn btn-primary text-nowrap d-none d-md-block" role="button" href="{{ url_for("accounting.account.create")|accounting_append_next }}"> <a class="btn btn-primary text-nowrap d-none d-md-block" role="button" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i> <i class="fa-solid fa-plus" aria-hidden="true"></i>
{{ A_("New") }} {{ A_("New") }}
</a> </a>
{% endif %} {% endif %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> <form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit"> <button type="submit">
<i class="fa-solid fa-magnifying-glass"></i> <i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Search") }}</span> <span class="d-none d-md-inline">{{ A_("Search") }}</span>
</button> </button>
</label> </label>
@@ -45,8 +45,8 @@ First written: 2023/1/30
{% if accounting_can_edit() %} {% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab"> <div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.create")|accounting_append_next }}"> <a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.create")|accounting_append_next }}" aria-label="{{ A_("New") }}">
<i class="fa-solid fa-plus"></i> <i class="fa-solid fa-plus" aria-hidden="true"></i>
</a> </a>
</div> </div>
{% endif %} {% endif %}
@@ -32,7 +32,7 @@ First written: 2023/2/2
<div class="mb-3 accounting-toolbar"> <div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.list")|accounting_or_next }}"> <a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i> <i class="fa-solid fa-circle-chevron-left" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Back") }}</span> <span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a> </a>
</div> </div>
@@ -51,21 +51,21 @@ First written: 2023/2/2
<span id="accounting-order-{{ account.id }}-code">{{ account.code }}</span> <span id="accounting-order-{{ account.id }}-code">{{ account.code }}</span>
{{ account.title }} {{ account.title }}
</div> </div>
<i class="fa-solid fa-bars"></i> <i class="fa-solid fa-bars" aria-hidden="true"></i>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<div class="d-none d-md-block"> <div class="d-none d-md-block">
<button class="btn btn-primary" type="submit"> <button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i> <i class="fa-solid fa-floppy-disk" aria-hidden="true"></i>
{{ A_("Save") }} {{ A_("Save") }}
</button> </button>
</div> </div>
<div class="d-md-none accounting-material-fab"> <div class="d-md-none accounting-material-fab">
<button class="btn btn-primary" type="submit"> <button class="btn btn-primary" type="submit" aria-label="{{ A_("Save") }}">
<i class="fa-solid fa-floppy-disk"></i> <i class="fa-solid fa-floppy-disk" aria-hidden="true"></i>
</button> </button>
</div> </div>
</form> </form>
@@ -27,13 +27,13 @@ First written: 2023/2/1
<div class="mb-3 accounting-toolbar"> <div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.list")|accounting_or_next }}"> <a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i> <i class="fa-solid fa-circle-chevron-left" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Back") }}</span> <span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a> </a>
</div> </div>
<div class="accounting-card col-sm-6"> <div class="accounting-card col-sm-6">
<div class="accounting-card-title">{{ obj.title|title }}</div> <div class="accounting-card-title">{{ obj.title }}</div>
<div class="accounting-card-code">{{ obj.code }}</div> <div class="accounting-card-code">{{ obj.code }}</div>
{% if obj.accounts %} {% if obj.accounts %}
<div> <div>
@@ -26,11 +26,11 @@ First written: 2023/1/26
{% block content %} {% block content %}
<div class="mb-2 accounting-toolbar"> <div class="mb-2 accounting-toolbar">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> <form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit"> <button type="submit">
<i class="fa-solid fa-magnifying-glass"></i> <i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Search") }}</span> <span class="d-none d-md-inline">{{ A_("Search") }}</span>
</button> </button>
</label> </label>
@@ -2,7 +2,7 @@
The Mia! Accounting Project The Mia! Accounting Project
base.html: The application-wide base template. base.html: The application-wide base template.
Copyright (c) 2023 imacat. Copyright (c) 2023-2024 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.
@@ -27,5 +27,6 @@ First written: 2023/1/27
{% block scripts %} {% block scripts %}
<script src="{{ url_for("accounting.babel_catalog") }}"></script> <script src="{{ url_for("accounting.babel_catalog") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/timezone.js") }}"></script>
{% block accounting_scripts %}{% endblock %} {% block accounting_scripts %}{% endblock %}
{% endblock %} {% endblock %}
@@ -23,6 +23,6 @@ First written: 2023/2/6
{% block header %}{% block title %}{{ A_("Add a New Currency") }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Add a New Currency") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.currency.list") }}{% endblock %} {% block back_url %}{{ url_for("accounting.currency.list")|accounting_or_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.currency.store") }}{% endblock %} {% block action_url %}{{ url_for("accounting.currency.store") }}{% endblock %}
@@ -27,24 +27,24 @@ First written: 2023/2/6
<div class="mb-3 accounting-toolbar"> <div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.list")|accounting_or_next }}"> <a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i> <i class="fa-solid fa-circle-chevron-left" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Back") }}</span> <span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a> </a>
{% if accounting_can_edit() %} {% if accounting_can_edit() %}
<a class="btn btn-primary d-none d-md-inline" role="button" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}"> <a class="btn btn-primary d-none d-md-inline" role="button" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i> <i class="fa-solid fa-pen-to-square" aria-hidden="true"></i>
{{ A_("Edit") }} {{ A_("Edit") }}
</a> </a>
{% endif %} {% endif %}
{% if accounting_can_edit() %} {% if accounting_can_edit() %}
{% if obj.can_delete %} {% if obj.can_delete %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal"> <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i> <i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Delete") }}</span> <span class="d-none d-md-inline">{{ A_("Delete") }}</span>
</button> </button>
{% else %} {% else %}
<button class="btn btn-secondary" type="button" disabled="disabled"> <button class="btn btn-secondary" type="button" disabled="disabled">
<i class="fa-solid fa-trash"></i> <i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Delete") }}</span> <span class="d-none d-md-inline">{{ A_("Delete") }}</span>
</button> </button>
{% endif %} {% endif %}
@@ -53,8 +53,8 @@ First written: 2023/2/6
{% if accounting_can_edit() %} {% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab"> <div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}"> <a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}" aria-label="{{ A_("Edit") }}">
<i class="fa-solid fa-pen-to-square"></i> <i class="fa-solid fa-pen-to-square" aria-hidden="true"></i>
</a> </a>
</div> </div>
{% endif %} {% endif %}
@@ -29,7 +29,7 @@ First written: 2023/2/6
<div class="mb-3 accounting-toolbar"> <div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}"> <a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i> <i class="fa-solid fa-circle-chevron-left" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Back") }}</span> <span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a> </a>
</div> </div>
@@ -42,25 +42,25 @@ First written: 2023/2/6
<div class="form-floating mb-3"> <div class="form-floating mb-3">
<input id="accounting-code" class="form-control {% if form.code.errors %} is-invalid {% endif %}" type="text" name="code" value="{{ form.code.data|accounting_default }}" placeholder=" " required="required" data-exists-url="{{ url_for("accounting.currency-api.exists") }}" data-original="{% block original_code %}{% endblock %}" data-blocklist="{{ form.CODE_BLOCKLIST|tojson|forceescape }}"> <input id="accounting-code" class="form-control {% if form.code.errors %} is-invalid {% endif %}" type="text" name="code" value="{{ form.code.data|accounting_default }}" placeholder=" " required="required" data-exists-url="{{ url_for("accounting.currency-api.exists") }}" data-original="{% block original_code %}{% endblock %}" data-blocklist="{{ form.CODE_BLOCKLIST|tojson|forceescape }}">
<label class="form-label" for="accounting-code">{{ A_("Code") }}</label> <label class="form-label" for="accounting-code">{{ A_("Code") }}</label>
<div id="accounting-code-error" class="invalid-feedback">{% if form.code.errors %}{{ form.code.errors[0] }}{% endif %}</div> <div id="accounting-code-error" class="invalid-feedback" role="alert">{% if form.code.errors %}{{ form.code.errors[0] }}{% endif %}</div>
</div> </div>
<div class="form-floating mb-3"> <div class="form-floating mb-3">
<input id="accounting-name" class="form-control {% if form.name.errors %} is-invalid {% endif %}" type="text" name="name" value="{{ form.name.data|accounting_default }}" placeholder=" " required="required"> <input id="accounting-name" class="form-control {% if form.name.errors %} is-invalid {% endif %}" type="text" name="name" value="{{ form.name.data|accounting_default }}" placeholder=" " required="required">
<label class="form-label" for="accounting-name">{{ A_("Name") }}</label> <label class="form-label" for="accounting-name">{{ A_("Name") }}</label>
<div id="accounting-name-error" class="invalid-feedback">{% if form.name.errors %}{{ form.name.errors[0] }}{% endif %}</div> <div id="accounting-name-error" class="invalid-feedback" role="alert">{% if form.name.errors %}{{ form.name.errors[0] }}{% endif %}</div>
</div> </div>
<div class="d-none d-md-block"> <div class="d-none d-md-block">
<button class="btn btn-primary" type="submit"> <button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i> <i class="fa-solid fa-floppy-disk" aria-hidden="true"></i>
{{ A_("Save") }} {{ A_("Save") }}
</button> </button>
</div> </div>
<div class="d-md-none accounting-material-fab"> <div class="d-md-none accounting-material-fab">
<button class="btn btn-primary" type="submit"> <button class="btn btn-primary" type="submit" aria-label="{{ A_("Save") }}">
<i class="fa-solid fa-floppy-disk"></i> <i class="fa-solid fa-floppy-disk" aria-hidden="true"></i>
</button> </button>
</div> </div>
</form> </form>
@@ -28,15 +28,15 @@ First written: 2023/2/6
<div class="mb-2 accounting-toolbar"> <div class="mb-2 accounting-toolbar">
{% if accounting_can_edit() %} {% if accounting_can_edit() %}
<a class="btn btn-primary text-nowrap d-none d-md-block" role="button" href="{{ url_for("accounting.currency.create")|accounting_append_next }}"> <a class="btn btn-primary text-nowrap d-none d-md-block" role="button" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i> <i class="fa-solid fa-plus" aria-hidden="true"></i>
{{ A_("New") }} {{ A_("New") }}
</a> </a>
{% endif %} {% endif %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> <form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit"> <button type="submit">
<i class="fa-solid fa-magnifying-glass"></i> <i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Search") }}</span> <span class="d-none d-md-inline">{{ A_("Search") }}</span>
</button> </button>
</label> </label>
@@ -45,8 +45,8 @@ First written: 2023/2/6
{% if accounting_can_edit() %} {% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab"> <div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.create")|accounting_append_next }}"> <a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.create")|accounting_append_next }}" aria-label="{{ A_("New") }}">
<i class="fa-solid fa-plus"></i> <i class="fa-solid fa-plus" aria-hidden="true"></i>
</a> </a>
</div> </div>
{% endif %} {% endif %}
@@ -22,39 +22,39 @@ First written: 2023/1/26
{# <ul> For SonarQube not to complain about incorrect HTML #} {# <ul> For SonarQube not to complain about incorrect HTML #}
{% if accounting_can_view() %} {% if accounting_can_view() %}
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<span class="nav-link dropdown-toggle" data-bs-toggle="dropdown"> <button class="nav-link dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-file-invoice-dollar"></i> <i class="fa-solid fa-file-invoice-dollar" aria-hidden="true"></i>
{{ A_("Accounting") }} {{ A_("Accounting") }}
</span> </button>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li> <li>
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting-report.") %} active {% endif %}" href="{{ url_for("accounting-report.default") }}"> <a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting-report.") %} active {% endif %}" href="{{ url_for("accounting-report.default") }}">
<i class="fa-solid fa-book"></i> <i class="fa-solid fa-book" aria-hidden="true"></i>
{{ A_("Reports") }} {{ A_("Reports") }}
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}"> <a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}">
<i class="fa-solid fa-clipboard"></i> <i class="fa-solid fa-clipboard" aria-hidden="true"></i>
{{ A_("Accounts") }} {{ A_("Accounts") }}
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.base-account.") %} active {% endif %}" href="{{ url_for("accounting.base-account.list") }}"> <a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.base-account.") %} active {% endif %}" href="{{ url_for("accounting.base-account.list") }}">
<i class="fa-solid fa-list"></i> <i class="fa-solid fa-list" aria-hidden="true"></i>
{{ A_("Base Accounts") }} {{ A_("Base Accounts") }}
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.currency.") %} active {% endif %}" href="{{ url_for("accounting.currency.list") }}"> <a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.currency.") %} active {% endif %}" href="{{ url_for("accounting.currency.list") }}">
<i class="fa-solid fa-money-bill-wave"></i> <i class="fa-solid fa-money-bill-wave" aria-hidden="true"></i>
{{ A_("Currencies") }} {{ A_("Currencies") }}
</a> </a>
</li> </li>
{% if accounting_can_admin() %} {% if accounting_can_admin() %}
<li> <li>
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.option.") %} active {% endif %}" href="{{ url_for("accounting.option.detail") }}"> <a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.option.") %} active {% endif %}" href="{{ url_for("accounting.option.detail") }}">
<i class="fa-solid fa-gear"></i> <i class="fa-solid fa-gear" aria-hidden="true"></i>
{{ A_("Settings") }} {{ A_("Settings") }}
</a> </a>
</li> </li>
@@ -38,7 +38,7 @@ First written: 2023/1/26
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<li class="page-item d-none d-md-inline active dropdown"> <li class="page-item d-none d-md-inline active dropdown">
<div class="page-link dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"> <div class="page-link dropdown-toggle" role="button" tabindex="0" data-bs-toggle="dropdown" aria-expanded="false">
{{ pagination.page_size }} {{ pagination.page_size }}
</div> </div>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
@@ -23,6 +23,6 @@ First written: 2023/2/25
{% block header %}{% block title %}{{ A_("Add a New Cash Disbursement Journal Entry") }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Add a New Cash Disbursement Journal Entry") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting-report.default") }}{% endblock %} {% block back_url %}{{ url_for("accounting-report.default")|accounting_or_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %} {% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}
@@ -2,7 +2,7 @@
The Mia! Accounting Project The Mia! Accounting Project
detail.html: The cash disbursement journal entry detail detail.html: The cash disbursement journal entry detail
Copyright (c) 2023 imacat. Copyright (c) 2023-2026 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.
@@ -21,9 +21,9 @@ First written: 2023/2/26
#} #}
{% extends "accounting/journal-entry/include/detail.html" %} {% extends "accounting/journal-entry/include/detail.html" %}
{% block as_trasfer %} {% block as_transfer %}
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}"> <a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-table-columns"></i> <i class="fa-solid fa-table-columns" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("As Transfer") }}</span> <span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
</a> </a>
{% endblock %} {% endblock %}
@@ -19,31 +19,31 @@ account-selector-modal.html: The modal for the account selector
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25 First written: 2023/2/25
#} #}
<div id="accounting-account-selector-{{ debit_credit }}-modal" class="modal fade accounting-account-selector" data-debit-credit="{{ debit_credit }}" tabindex="-1" aria-labelledby="accounting-account-selector-{{ debit_credit }}-modal-label" aria-hidden="true"> <div id="accounting-account-selector-{{ debit_credit }}-modal" class="modal fade accounting-account-selector" data-debit-credit="{{ debit_credit }}" tabindex="-1" aria-labelledby="accounting-account-selector-{{ debit_credit }}-modal-label" aria-hidden="true" data-bs-keyboard="false">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-account-selector-{{ debit_credit }}-modal-label">{{ A_("Select Account") }}</h1> <h1 class="modal-title fs-5" id="accounting-account-selector-{{ debit_credit }}-modal-label">{{ A_("Select Account") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button> <button id="accounting-account-selector-{{ debit_credit }}-close" type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="input-group mb-2"> <div class="input-group mb-2">
<input id="accounting-account-selector-{{ debit_credit }}-query" class="form-control form-control-sm" type="search" placeholder=" " required="required"> <input id="accounting-account-selector-{{ debit_credit }}-query" class="form-control form-control-sm" type="search" placeholder=" " required="required" role="combobox" aria-expanded="true" aria-controls="accounting-account-selector-{{ debit_credit }}-option-list" aria-autocomplete="list" aria-activedescendant="">
<label class="input-group-text" for="accounting-account-selector-{{ debit_credit }}-query"> <label class="input-group-text" for="accounting-account-selector-{{ debit_credit }}-query">
<i class="fa-solid fa-magnifying-glass"></i> <i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
{{ A_("Search") }} {{ A_("Search") }}
</label> </label>
</div> </div>
<ul id="accounting-account-selector-{{ debit_credit }}-option-list" class="list-group accounting-selector-list"> <ul id="accounting-account-selector-{{ debit_credit }}-option-list" class="list-group accounting-selector-list" role="listbox" tabindex="-1" aria-labelledby="accounting-account-selector-{{ debit_credit }}-modal-label">
{% for account in account_options %} {% for account in account_options %}
<li id="accounting-account-selector-{{ debit_credit }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ debit_credit }}-option {% if account.is_in_use %} accounting-account-is-in-use {% endif %} {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" data-code="{{ account.code }}" data-title="{{ account.title }}" data-text="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal"> <li id="accounting-account-selector-{{ debit_credit }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ debit_credit }}-option {% if account.is_in_use %} accounting-account-is-in-use {% endif %} {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" role="option" aria-selected="false" data-code="{{ account.code }}" data-title="{{ account.title }}" data-text="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
{{ account }} {{ account }}
</li> </li>
{% endfor %} {% endfor %}
<li id="accounting-account-selector-{{ debit_credit }}-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li> <li id="accounting-account-selector-{{ debit_credit }}-more" class="list-group-item accounting-clickable" role="option" aria-selected="false">{{ A_("More…") }}</li>
</ul> </ul>
<p id="accounting-account-selector-{{ debit_credit }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p> <p id="accounting-account-selector-{{ debit_credit }}-option-no-result" class="d-none" role="status">{{ A_("There is no data.") }}</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">{{ A_("Cancel") }}</button> <button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">{{ A_("Cancel") }}</button>
@@ -20,14 +20,15 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/28 First written: 2023/2/28
#} #}
<form id="accounting-description-editor-{{ description_editor.debit_credit }}" class="accounting-description-editor" data-debit-credit="{{ description_editor.debit_credit }}"> <form id="accounting-description-editor-{{ description_editor.debit_credit }}" class="accounting-description-editor" data-debit-credit="{{ description_editor.debit_credit }}">
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label" aria-hidden="true"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label" aria-hidden="true" data-bs-keyboard="false">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label"> <h1 class="modal-title fs-5" id="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label">
<label for="accounting-description-editor-{{ description_editor.debit_credit }}-description">{{ A_("Description") }}</label> <label for="accounting-description-editor-{{ description_editor.debit_credit }}-description">{{ A_("Description") }}</label>
</h1> </h1>
<button class="btn-close" type="button" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button> <button id="accounting-description-editor-{{ description_editor.debit_credit }}-close" class="btn-close" type="button" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="d-flex justify-content-between mb-3"> <div class="d-flex justify-content-between mb-3">
@@ -38,40 +39,40 @@ First written: 2023/2/28
</div> </div>
{# Tab navigation #} {# Tab navigation #}
<ul class="nav nav-tabs mb-2"> <ul id="accounting-description-editor-{{ description_editor.debit_credit }}-tab-list" class="nav nav-tabs mb-2" role="tablist" aria-label="{{ A_("Description Type") }}">
<li class="nav-item"> <li class="nav-item">
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tab" class="nav-link active accounting-clickable" aria-current="page"> <button id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tab" class="nav-link active accounting-clickable" type="button" tabindex="0" role="tab" aria-controls="accounting-description-editor-{{ description_editor.debit_credit }}-general-panel" aria-selected="true">
{{ A_("General") }} {{ A_("General") }}
</span> </button>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tab" class="nav-link accounting-clickable" aria-current="false"> <button id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tab" class="nav-link accounting-clickable" type="button" tabindex="-1" role="tab" aria-controls="accounting-description-editor-{{ description_editor.debit_credit }}-travel-panel" aria-selected="false">
{{ A_("Travel") }} {{ A_("Travel") }}
</span> </button>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tab" class="nav-link accounting-clickable" aria-current="false"> <button id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tab" class="nav-link accounting-clickable" type="button" tabindex="-1" role="tab" aria-controls="accounting-description-editor-{{ description_editor.debit_credit }}-bus-panel" aria-selected="false">
{{ A_("Bus") }} {{ A_("Bus") }}
</span> </button>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab" class="nav-link accounting-clickable" aria-current="false"> <button id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab" class="nav-link accounting-clickable" type="button" tabindex="-1" role="tab" aria-controls="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-panel" aria-selected="false">
{{ A_("Recurring") }} {{ A_("Recurring") }}
</span> </button>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-tab" class="nav-link accounting-clickable" aria-current="false"> <button id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-tab" class="nav-link accounting-clickable" type="button" tabindex="-1" role="tab" aria-controls="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-panel" aria-selected="false">
{{ A_("Annotation") }} {{ A_("Annotation") }}
</span> </button>
</li> </li>
</ul> </ul>
{# A general description with a tag #} {# A general description with a tag #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-page" aria-current="page" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-general-tab"> <div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-panel" role="tabpanel" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-general-tab">
<div class="form-floating mb-2"> <div class="form-floating mb-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag" class="form-control" type="text" value="" placeholder=" "> <input id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag">{{ A_("Tag") }}</label> <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag">{{ A_("Tag") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag-error" class="invalid-feedback"></div> <div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag-error" class="invalid-feedback" role="alert"></div>
</div> </div>
<div class="accounting-description-editor-buttons"> <div class="accounting-description-editor-buttons">
@@ -84,11 +85,11 @@ First written: 2023/2/28
</div> </div>
{# A general trip with the origin and distination #} {# A general trip with the origin and distination #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tab"> <div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-panel" class="d-none" role="tabpanel" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tab">
<div class="form-floating mb-2"> <div class="form-floating mb-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag" class="form-control" type="text" value="" placeholder=" "> <input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag">{{ A_("Tag") }}</label> <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag">{{ A_("Tag") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag-error" class="invalid-feedback"></div> <div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag-error" class="invalid-feedback" role="alert"></div>
</div> </div>
<div class="accounting-description-editor-buttons"> <div class="accounting-description-editor-buttons">
@@ -103,7 +104,7 @@ First written: 2023/2/28
<div class="form-floating"> <div class="form-floating">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from" class="form-control" type="text" value="" placeholder=" "> <input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from">{{ A_("From") }}</label> <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from">{{ A_("From") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from-error" class="invalid-feedback"></div> <div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from-error" class="invalid-feedback" role="alert"></div>
</div> </div>
<div class="btn-group-vertical ms-1 me-1"> <div class="btn-group-vertical ms-1 me-1">
<button class="btn btn-primary accounting-description-editor-{{ description_editor.debit_credit }}-travel-direction accounting-default" type="button" tabindex="-1" data-arrow="&rarr;">&rarr;</button> <button class="btn btn-primary accounting-description-editor-{{ description_editor.debit_credit }}-travel-direction accounting-default" type="button" tabindex="-1" data-arrow="&rarr;">&rarr;</button>
@@ -112,23 +113,23 @@ First written: 2023/2/28
<div class="form-floating"> <div class="form-floating">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to" class="form-control" type="text" value="" placeholder=" "> <input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to">{{ A_("To") }}</label> <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to">{{ A_("To") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to-error" class="invalid-feedback"></div> <div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to-error" class="invalid-feedback" role="alert"></div>
</div> </div>
</div> </div>
</div> </div>
{# A bus trip with the route name or route number, the origin and distination #} {# A bus trip with the route name or route number, the origin and distination #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tab"> <div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-panel" class="d-none" role="tabpanel" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tab">
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between mb-2">
<div class="form-floating me-2"> <div class="form-floating me-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag" class="form-control" type="text" value="" placeholder=" "> <input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag">{{ A_("Tag") }}</label> <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag">{{ A_("Tag") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag-error" class="invalid-feedback"></div> <div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag-error" class="invalid-feedback" role="alert"></div>
</div> </div>
<div class="form-floating"> <div class="form-floating">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route" class="form-control" type="text" value="" placeholder=" "> <input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route">{{ A_("Route") }}</label> <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route">{{ A_("Route") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route-error" class="invalid-feedback"></div> <div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route-error" class="invalid-feedback" role="alert"></div>
</div> </div>
</div> </div>
@@ -144,18 +145,18 @@ First written: 2023/2/28
<div class="form-floating me-2"> <div class="form-floating me-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from" class="form-control" type="text" value="" placeholder=" "> <input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from">{{ A_("From") }}</label> <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from">{{ A_("From") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from-error" class="invalid-feedback"></div> <div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from-error" class="invalid-feedback" role="alert"></div>
</div> </div>
<div class="form-floating"> <div class="form-floating">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to" class="form-control" type="text" value="" placeholder=" "> <input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to">{{ A_("To") }}</label> <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to">{{ A_("To") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to-error" class="invalid-feedback"></div> <div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to-error" class="invalid-feedback" role="alert"></div>
</div> </div>
</div> </div>
</div> </div>
{# A recurring transaction #} {# A recurring transaction #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab"> <div id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-panel" class="d-none" role="tabpanel" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab">
<div class="accounting-description-editor-buttons"> <div class="accounting-description-editor-buttons">
{% for recurring in description_editor.recurring %} {% for recurring in description_editor.recurring %}
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-recurring-item" type="button" tabindex="-1" data-template="{{ recurring.description_template }}" data-accounts="{{ recurring.account_codes|tojson|forceescape }}"> <button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-recurring-item" type="button" tabindex="-1" data-template="{{ recurring.description_template }}" data-accounts="{{ recurring.account_codes|tojson|forceescape }}">
@@ -166,17 +167,17 @@ First written: 2023/2/28
</div> </div>
{# The annotation #} {# The annotation #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-tab"> <div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-panel" class="d-none" role="tabpanel" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-tab">
<div class="form-floating"> <div class="form-floating">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number" class="form-control" type="number" min="1" value="" placeholder=" "> <input id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number" class="form-control" type="number" min="1" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number">{{ A_("The Number of Items") }}</label> <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number">{{ A_("The Number of Items") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number-error" class="invalid-feedback"></div> <div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number-error" class="invalid-feedback" role="alert"></div>
</div> </div>
<div class="form-floating mt-2"> <div class="form-floating mt-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note" class="form-control" type="text" value="" placeholder=" "> <input id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note">{{ A_("Note") }}</label> <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note">{{ A_("Note") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note-error" class="invalid-feedback"></div> <div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note-error" class="invalid-feedback" role="alert"></div>
</div> </div>
</div> </div>
@@ -26,7 +26,7 @@ First written: 2023/3/14
<div> <div>
<div class="small"> <div class="small">
<span class="d-none d-md-inline">{{ line_item.account.code }}</span> <span class="d-none d-md-inline">{{ line_item.account.code }}</span>
{{ line_item.account.title|title }} {{ line_item.account.title }}
</div> </div>
{% if line_item.description is not none %} {% if line_item.description is not none %}
<div>{{ line_item.description }}</div> <div>{{ line_item.description }}</div>

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