Compare commits
264 Commits
Author | SHA1 | Date | |
---|---|---|---|
4d11517e21 | |||
308e4ac69d | |||
de09e1498b | |||
c26c4686c5 | |||
c95f4fcc47 | |||
71af74fc8a | |||
56e972c371 | |||
7feb6da062 | |||
af71874f9d | |||
3fa8818a27 | |||
be46d8aa14 | |||
20f55058ac | |||
e9d1a53e03 | |||
38141759fd | |||
7fb3e3bc2c | |||
05ac5158f8 | |||
ec257a4b57 | |||
5ebb89a6d5 | |||
900d60d1ae | |||
bc792c145f | |||
4432484acd | |||
7ad3f9e0cb | |||
060a52f7a2 | |||
c17430d211 | |||
8fd99bb617 | |||
ce388eb6c8 | |||
1850f9787e | |||
c6d55fad1c | |||
0c647d8f21 | |||
5d1f87582e | |||
ef086b3f81 | |||
b4be1db712 | |||
5d44ebdfd8 | |||
9859604c81 | |||
d31e495f6b | |||
7c4102be44 | |||
1fd50e23d9 | |||
9635448f18 | |||
e7f1ca332e | |||
3d2e40865e | |||
5132141c68 | |||
e37f6792c9 | |||
e6b1136a14 | |||
d7bc01ccb4 | |||
27beff3f8f | |||
c6c545b99f | |||
6d5a2fae6a | |||
8819eabcd0 | |||
3582d960ca | |||
02e10a301a | |||
f0187434d2 | |||
34af52e3c3 | |||
965df82c1c | |||
df53f06094 | |||
140d3c6010 | |||
a65dccac92 | |||
740e1cfac1 | |||
b62f31d385 | |||
1c740b9bbc | |||
380256eda7 | |||
74b695c089 | |||
6d1e705e4b | |||
8abe20dba5 | |||
ed7a8ac0fd | |||
74eee034d0 | |||
d19d23fe37 | |||
4ce577d7d8 | |||
a340fad109 | |||
555ad388bc | |||
2f27ad5bef | |||
c6487bf9d4 | |||
ff3dd28cd7 | |||
a14ffa93ed | |||
672fcbcbdf | |||
cb4258dd6d | |||
6fc21f82af | |||
13e3ef5875 | |||
21b3320e66 | |||
5c47e63ae3 | |||
f59378002e | |||
531e90e8ad | |||
8fc33131dd | |||
62716eb545 | |||
14d5d1e8d6 | |||
4306ed739f | |||
1f87bc00e8 | |||
ff9ff4bdcf | |||
578233d66d | |||
5e7f790f87 | |||
d64f354ee0 | |||
ba3d8c6d4e | |||
4f7f87b10d | |||
4273f99644 | |||
ffe834bedd | |||
e448e009c9 | |||
b6802c51bb | |||
2515c1ea1f | |||
0ef6409f75 | |||
ed18b81ad8 | |||
b46cec6fab | |||
6c122666a0 | |||
7ddc9ececf | |||
4eebbd9692 | |||
338b49c965 | |||
f438f97571 | |||
9b273115a0 | |||
58d1add810 | |||
c189615ca4 | |||
5687852dfb | |||
d74c62dbb7 | |||
987e98ebc0 | |||
7083f22577 | |||
7b10eb68bc | |||
f277010991 | |||
729a7fd107 | |||
c8230c949d | |||
3c98960efe | |||
c5d0d91a7d | |||
fb06e9db44 | |||
d47e2e231b | |||
cb89f34455 | |||
11ab4a4ba6 | |||
5dc8387ad9 | |||
26b70bb625 | |||
f30a96d7e9 | |||
a1627b7fbf | |||
7c3b8c8f44 | |||
b19f4fa939 | |||
41c3e06ce4 | |||
8a3df7a689 | |||
196a115c99 | |||
005f9083aa | |||
12dbae56c4 | |||
a98723c57b | |||
d5bd3b8383 | |||
617dd29f23 | |||
b0a4a735f3 | |||
41770e38b8 | |||
d8a6614543 | |||
8d76b5130e | |||
43fc4b9b8d | |||
3ed8d7f1d2 | |||
ea7c194d7e | |||
041a905fc0 | |||
10d1be8bd1 | |||
6e1d35eda4 | |||
52b5151fe0 | |||
f9fc033de6 | |||
116d00a557 | |||
329e3d5362 | |||
47e8944f06 | |||
e7c43ae390 | |||
b8b51b34d3 | |||
d083036719 | |||
7fe81c710b | |||
9993f65627 | |||
fe01d5418d | |||
2f7b9932a0 | |||
1eed16b732 | |||
ede1160943 | |||
3814f0cb18 | |||
24315b8203 | |||
3c200d0dc6 | |||
9f1e724875 | |||
f838e7f893 | |||
edb893ecd3 | |||
436a4c367f | |||
1813ce0cfa | |||
7683347997 | |||
46ffc7a73d | |||
e0a807d625 | |||
dffcf6d2ce | |||
84d239e4b1 | |||
fcefc64117 | |||
81fbb380b4 | |||
d7ac8a3dcf | |||
bcd3418e2c | |||
ef9e5cb5b3 | |||
e797cfeb8c | |||
22bae7f766 | |||
aa669e9f53 | |||
898a1af7b5 | |||
f762bcf48f | |||
5d4effa360 | |||
dd05478bf3 | |||
9450915404 | |||
8d126e183f | |||
bfb08cf5fc | |||
a7bcf4b5c1 | |||
cd49ca44b1 | |||
734362396f | |||
88147bea66 | |||
cca43c68a6 | |||
480e2d2d8f | |||
be100ce7ec | |||
eca91d32ed | |||
1f95212494 | |||
0173104c84 | |||
6e33fa775d | |||
e244ff70e6 | |||
ace782a26b | |||
90289a0db2 | |||
7e7e1a2844 | |||
ddd028736c | |||
e1d35a64da | |||
39807ef480 | |||
39723b1299 | |||
8cd004bede | |||
4f112dd386 | |||
b806b1ed1f | |||
1d0a79e33c | |||
d4a690ebbc | |||
68687897f3 | |||
a7250fd9bf | |||
eabe80b790 | |||
fe77f87110 | |||
32c27d7c07 | |||
14b871b57a | |||
9d5fce2752 | |||
d333151731 | |||
b2e500a714 | |||
b705795b44 | |||
250f4ff1ae | |||
6bed180790 | |||
10fbc3f638 | |||
f65dc6fc42 | |||
9833bac6e4 | |||
7d412b20d7 | |||
9bfcd3c50c | |||
55c2ce6695 | |||
493677e0aa | |||
710c26d016 | |||
24415018b7 | |||
c50b9a2000 | |||
af9bd14eed | |||
9e1ff16e96 | |||
f7c1fd77f2 | |||
641315537d | |||
a895bd8560 | |||
ca86a08f3e | |||
e118422441 | |||
b3777cffbf | |||
39c9c17007 | |||
3ab4eacf9f | |||
cff3d1b6bd | |||
f41db78831 | |||
73f7d14e7b | |||
f6ed6b10a7 | |||
b5aaee4d15 | |||
c849d6b3d4 | |||
a9908a7df4 | |||
063c769158 | |||
f8e9871300 | |||
78a62a9575 | |||
85fde6219e | |||
4eb9346d8d | |||
11966a52ba | |||
8cf81b5459 | |||
cc958a39b3 | |||
9065686cc5 | |||
9a41cb10a1 | |||
6957e52d0d | |||
9cd9e90be0 | |||
2839dc60b4 |
@ -28,10 +28,10 @@ accounting.account.forms module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.account.query module
|
||||
-------------------------------
|
||||
accounting.account.queries module
|
||||
---------------------------------
|
||||
|
||||
.. automodule:: accounting.account.query
|
||||
.. automodule:: accounting.account.queries
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
@ -20,10 +20,10 @@ accounting.base\_account.converters module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.base\_account.query module
|
||||
-------------------------------------
|
||||
accounting.base\_account.queries module
|
||||
---------------------------------------
|
||||
|
||||
.. automodule:: accounting.base_account.query
|
||||
.. automodule:: accounting.base_account.queries
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
@ -28,10 +28,10 @@ accounting.currency.forms module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.currency.query module
|
||||
--------------------------------
|
||||
accounting.currency.queries module
|
||||
----------------------------------
|
||||
|
||||
.. automodule:: accounting.currency.query
|
||||
.. automodule:: accounting.currency.queries
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
69
docs/source/accounting.report.period.rst
Normal file
69
docs/source/accounting.report.period.rst
Normal file
@ -0,0 +1,69 @@
|
||||
accounting.report.period package
|
||||
================================
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
accounting.report.period.chooser module
|
||||
---------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.period.chooser
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.period.description module
|
||||
-------------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.period.description
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.period.month\_end module
|
||||
------------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.period.month_end
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.period.parser module
|
||||
--------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.period.parser
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.period.period module
|
||||
--------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.period.period
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.period.shortcuts module
|
||||
-----------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.period.shortcuts
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.period.specification module
|
||||
---------------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.period.specification
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: accounting.report.period
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
69
docs/source/accounting.report.reports.rst
Normal file
69
docs/source/accounting.report.reports.rst
Normal file
@ -0,0 +1,69 @@
|
||||
accounting.report.reports package
|
||||
=================================
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
accounting.report.reports.balance\_sheet module
|
||||
-----------------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.reports.balance_sheet
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.reports.income\_expenses module
|
||||
-------------------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.reports.income_expenses
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.reports.income\_statement module
|
||||
--------------------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.reports.income_statement
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.reports.journal module
|
||||
----------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.reports.journal
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.reports.ledger module
|
||||
---------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.reports.ledger
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.reports.search module
|
||||
---------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.reports.search
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.reports.trial\_balance module
|
||||
-----------------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.reports.trial_balance
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: accounting.report.reports
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
47
docs/source/accounting.report.rst
Normal file
47
docs/source/accounting.report.rst
Normal file
@ -0,0 +1,47 @@
|
||||
accounting.report package
|
||||
=========================
|
||||
|
||||
Subpackages
|
||||
-----------
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 4
|
||||
|
||||
accounting.report.period
|
||||
accounting.report.reports
|
||||
accounting.report.utils
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
accounting.report.converters module
|
||||
-----------------------------------
|
||||
|
||||
.. automodule:: accounting.report.converters
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.template\_filters module
|
||||
------------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.template_filters
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.views module
|
||||
------------------------------
|
||||
|
||||
.. automodule:: accounting.report.views
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: accounting.report
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
77
docs/source/accounting.report.utils.rst
Normal file
77
docs/source/accounting.report.utils.rst
Normal file
@ -0,0 +1,77 @@
|
||||
accounting.report.utils package
|
||||
===============================
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
accounting.report.utils.base\_page\_params module
|
||||
-------------------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.utils.base_page_params
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.utils.base\_report module
|
||||
-------------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.utils.base_report
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.utils.csv\_export module
|
||||
------------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.utils.csv_export
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.utils.ie\_account module
|
||||
------------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.utils.ie_account
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.utils.option\_link module
|
||||
-------------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.utils.option_link
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.utils.report\_chooser module
|
||||
----------------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.utils.report_chooser
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.utils.report\_type module
|
||||
-------------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.utils.report_type
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.utils.urls module
|
||||
-----------------------------------
|
||||
|
||||
.. automodule:: accounting.report.utils.urls
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: accounting.report.utils
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
@ -10,6 +10,7 @@ Subpackages
|
||||
accounting.account
|
||||
accounting.base_account
|
||||
accounting.currency
|
||||
accounting.report
|
||||
accounting.transaction
|
||||
accounting.utils
|
||||
|
||||
@ -32,6 +33,22 @@ accounting.models module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.template\_filters module
|
||||
-----------------------------------
|
||||
|
||||
.. automodule:: accounting.template_filters
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.template\_globals module
|
||||
-----------------------------------
|
||||
|
||||
.. automodule:: accounting.template_globals
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
|
@ -12,14 +12,6 @@ accounting.transaction.converters module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.transaction.dispatcher module
|
||||
----------------------------------------
|
||||
|
||||
.. automodule:: accounting.transaction.dispatcher
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.transaction.forms module
|
||||
-----------------------------------
|
||||
|
||||
@ -28,26 +20,26 @@ accounting.transaction.forms module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.transaction.query module
|
||||
-----------------------------------
|
||||
accounting.transaction.operators module
|
||||
---------------------------------------
|
||||
|
||||
.. automodule:: accounting.transaction.query
|
||||
.. automodule:: accounting.transaction.operators
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.transaction.summary\_helper module
|
||||
accounting.transaction.summary\_editor module
|
||||
---------------------------------------------
|
||||
|
||||
.. automodule:: accounting.transaction.summary_helper
|
||||
.. automodule:: accounting.transaction.summary_editor
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.transaction.template module
|
||||
--------------------------------------
|
||||
accounting.transaction.template\_filters module
|
||||
-----------------------------------------------
|
||||
|
||||
.. automodule:: accounting.transaction.template
|
||||
.. automodule:: accounting.transaction.template_filters
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
@ -60,6 +60,14 @@ accounting.utils.strip\_text module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.txn\_types module
|
||||
----------------------------------
|
||||
|
||||
.. automodule:: accounting.utils.txn_types
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.user module
|
||||
----------------------------
|
||||
|
||||
|
@ -17,7 +17,7 @@
|
||||
|
||||
[metadata]
|
||||
name = mia-accounting-flask
|
||||
version = 0.4.0
|
||||
version = 0.5.0
|
||||
author = imacat
|
||||
author_email = imacat@mail.imacat.idv.tw
|
||||
description = The Mia! Accounting Flask project.
|
||||
|
@ -58,12 +58,26 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
|
||||
template_folder="templates",
|
||||
static_folder="static")
|
||||
|
||||
from .template_filters import format_amount, format_date, default
|
||||
bp.add_app_template_filter(format_amount, "accounting_format_amount")
|
||||
bp.add_app_template_filter(format_date, "accounting_format_date")
|
||||
bp.add_app_template_filter(default, "accounting_default")
|
||||
|
||||
from .template_globals import currency_options, default_currency_code
|
||||
bp.add_app_template_global(currency_options,
|
||||
"accounting_currency_options")
|
||||
bp.add_app_template_global(default_currency_code,
|
||||
"accounting_default_currency_code")
|
||||
|
||||
from . import locale
|
||||
locale.init_app(app, bp)
|
||||
|
||||
from .utils import permission
|
||||
permission.init_app(bp, can_view_func, can_edit_func)
|
||||
|
||||
from .utils import next_uri
|
||||
next_uri.init_app(bp)
|
||||
|
||||
from . import base_account
|
||||
base_account.init_app(app, bp)
|
||||
|
||||
@ -76,7 +90,7 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
|
||||
from . import transaction
|
||||
transaction.init_app(app, bp)
|
||||
|
||||
from .utils import next_uri
|
||||
next_uri.init_app(bp)
|
||||
from . import report
|
||||
report.init_app(app, bp)
|
||||
|
||||
app.register_blueprint(bp)
|
||||
|
@ -30,7 +30,7 @@ from accounting.utils.user import has_user, get_user_pk
|
||||
|
||||
AccountData = tuple[int, str, int, str, str, str, bool]
|
||||
"""The format of the account data, as a list of (ID, base account code, number,
|
||||
English, Traditional Chinese, Simplified Chinese, is-pay-off-needed) tuples."""
|
||||
English, Traditional Chinese, Simplified Chinese, is-offset-needed) tuples."""
|
||||
|
||||
|
||||
def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
||||
@ -93,10 +93,10 @@ def init_accounts_command(username: str) -> None:
|
||||
data: list[AccountData] = []
|
||||
for base in bases_to_add:
|
||||
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
|
||||
is_pay_off_needed: bool = True if re.match("^[12]1[34]", base.code) \
|
||||
is_offset_needed: bool = True if re.match("^[12]1[34]", base.code) \
|
||||
else False
|
||||
data.append((get_new_id(), base.code, 1, base.title_l10n,
|
||||
l10n["zh_Hant"], l10n["zh_Hans"], is_pay_off_needed))
|
||||
l10n["zh_Hant"], l10n["zh_Hans"], is_offset_needed))
|
||||
__add_accounting_accounts(data, creator_pk)
|
||||
click.echo(F"{len(data)} added. Accounting accounts initialized.")
|
||||
|
||||
@ -113,7 +113,7 @@ def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
|
||||
base_code=x[1],
|
||||
no=x[2],
|
||||
title_l10n=x[3],
|
||||
is_pay_off_needed=x[6],
|
||||
is_offset_needed=x[6],
|
||||
created_by_id=creator_pk,
|
||||
updated_by_id=creator_pk)
|
||||
for x in data]
|
||||
|
@ -66,8 +66,8 @@ class AccountForm(FlaskForm):
|
||||
filters=[strip_text],
|
||||
validators=[DataRequired(lazy_gettext("Please fill in the title"))])
|
||||
"""The title."""
|
||||
is_pay_off_needed = BooleanField()
|
||||
"""Whether the the entries of this account need pay-off."""
|
||||
is_offset_needed = BooleanField()
|
||||
"""Whether the the entries of this account need offset."""
|
||||
|
||||
def populate_obj(self, obj: Account) -> None:
|
||||
"""Populates the form data into an account object.
|
||||
@ -87,7 +87,7 @@ class AccountForm(FlaskForm):
|
||||
obj.base_code = self.base_code.data
|
||||
obj.no = count + 1
|
||||
obj.title = self.title.data
|
||||
obj.is_pay_off_needed = self.is_pay_off_needed.data
|
||||
obj.is_offset_needed = self.is_offset_needed.data
|
||||
if is_new:
|
||||
current_user_pk: int = get_current_user_pk()
|
||||
obj.created_by_id = current_user_pk
|
||||
|
@ -14,7 +14,7 @@
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The account query.
|
||||
"""The queries for the account management.
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
@ -47,8 +47,8 @@ def get_account_query() -> list[Account]:
|
||||
Account.title_l10n.contains(k),
|
||||
code.contains(k),
|
||||
Account.id.in_(l10n_matches)]
|
||||
if k in gettext("Pay-off needed"):
|
||||
sub_conditions.append(Account.is_pay_off_needed)
|
||||
if k in gettext("Need offset"):
|
||||
sub_conditions.append(Account.is_offset_needed)
|
||||
conditions.append(sa.or_(*sub_conditions))
|
||||
|
||||
return Account.query.filter(*conditions)\
|
@ -33,7 +33,7 @@ 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 .query import get_account_query
|
||||
from .queries import get_account_query
|
||||
|
||||
bp: Blueprint = Blueprint("account", __name__)
|
||||
"""The view blueprint for the account management."""
|
||||
|
@ -14,7 +14,7 @@
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The base account query.
|
||||
"""The queries for the base account management.
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
@ -34,7 +34,7 @@ def list_accounts() -> str:
|
||||
|
||||
:return: The account list.
|
||||
"""
|
||||
from .query import get_base_account_query
|
||||
from .queries import get_base_account_query
|
||||
accounts: list[BaseAccount] = get_base_account_query()
|
||||
pagination: Pagination = Pagination[BaseAccount](accounts)
|
||||
return render_template("accounting/base-account/list.html",
|
||||
|
@ -14,7 +14,7 @@
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The currency query.
|
||||
"""The queries for the currency management.
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
@ -47,7 +47,7 @@ def list_currencies() -> str:
|
||||
|
||||
:return: The currency list.
|
||||
"""
|
||||
from .query import get_currency_query
|
||||
from .queries import get_currency_query
|
||||
currencies: list[Currency] = get_currency_query()
|
||||
pagination: Pagination = Pagination[Currency](currencies)
|
||||
return render_template("accounting/currency/list.html",
|
||||
|
@ -52,7 +52,7 @@ class BaseAccount(db.Model):
|
||||
|
||||
:return: The string representation of the base account.
|
||||
"""
|
||||
return F"{self.code} {self.title}"
|
||||
return f"{self.code} {self.title}"
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
@ -113,8 +113,8 @@ class Account(db.Model):
|
||||
"""The account number under the base account."""
|
||||
title_l10n = db.Column("title", db.String, nullable=False)
|
||||
"""The title."""
|
||||
is_pay_off_needed = db.Column(db.Boolean, nullable=False, default=False)
|
||||
"""Whether the entries of this account need pay-off."""
|
||||
is_offset_needed = db.Column(db.Boolean, nullable=False, default=False)
|
||||
"""Whether the entries of this account need offset."""
|
||||
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||
server_default=db.func.now())
|
||||
"""The time of creation."""
|
||||
@ -141,17 +141,11 @@ class Account(db.Model):
|
||||
entries = db.relationship("JournalEntry", back_populates="account")
|
||||
"""The journal entries."""
|
||||
|
||||
__CASH = "1111-001"
|
||||
CASH_CODE: str = "1111-001"
|
||||
"""The code of the cash account,"""
|
||||
__RECEIVABLE = "1141-001"
|
||||
"""The code of the receivable account,"""
|
||||
__PAYABLE = "2141-001"
|
||||
"""The code of the payable account,"""
|
||||
__ACCUMULATED_CHANGE = "3351-001"
|
||||
ACCUMULATED_CHANGE_CODE: str = "3351-001"
|
||||
"""The code of the accumulated-change account,"""
|
||||
__BROUGHT_FORWARD = "3352-001"
|
||||
"""The code of the brought-forward account,"""
|
||||
__NET_CHANGE = "3353-001"
|
||||
NET_CHANGE_CODE: str = "3353-001"
|
||||
"""The code of the net-change account,"""
|
||||
|
||||
def __str__(self) -> str:
|
||||
@ -159,7 +153,7 @@ class Account(db.Model):
|
||||
|
||||
:return: The string representation of this account.
|
||||
"""
|
||||
return F"{self.base_code}-{self.no:03d} {self.title}"
|
||||
return f"{self.base_code}-{self.no:03d} {self.title}"
|
||||
|
||||
@property
|
||||
def code(self) -> str:
|
||||
@ -167,7 +161,7 @@ class Account(db.Model):
|
||||
|
||||
:return: The code.
|
||||
"""
|
||||
return F"{self.base_code}-{self.no:03d}"
|
||||
return f"{self.base_code}-{self.no:03d}"
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
@ -271,23 +265,7 @@ class Account(db.Model):
|
||||
|
||||
:return: The cash account
|
||||
"""
|
||||
return cls.find_by_code(cls.__CASH)
|
||||
|
||||
@classmethod
|
||||
def receivable(cls) -> t.Self:
|
||||
"""Returns the receivable account.
|
||||
|
||||
:return: The receivable account
|
||||
"""
|
||||
return cls.find_by_code(cls.__RECEIVABLE)
|
||||
|
||||
@classmethod
|
||||
def payable(cls) -> t.Self:
|
||||
"""Returns the payable account.
|
||||
|
||||
:return: The payable account
|
||||
"""
|
||||
return cls.find_by_code(cls.__PAYABLE)
|
||||
return cls.find_by_code(cls.CASH_CODE)
|
||||
|
||||
@classmethod
|
||||
def accumulated_change(cls) -> t.Self:
|
||||
@ -295,23 +273,7 @@ class Account(db.Model):
|
||||
|
||||
:return: The accumulated-change account
|
||||
"""
|
||||
return cls.find_by_code(cls.__ACCUMULATED_CHANGE)
|
||||
|
||||
@classmethod
|
||||
def brought_forward(cls) -> t.Self:
|
||||
"""Returns the brought-forward account.
|
||||
|
||||
:return: The brought-forward account
|
||||
"""
|
||||
return cls.find_by_code(cls.__BROUGHT_FORWARD)
|
||||
|
||||
@classmethod
|
||||
def net_change(cls) -> t.Self:
|
||||
"""Returns the net-change account.
|
||||
|
||||
:return: The net-change account
|
||||
"""
|
||||
return cls.find_by_code(cls.__NET_CHANGE)
|
||||
return cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE)
|
||||
|
||||
@property
|
||||
def is_modified(self) -> bool:
|
||||
@ -392,7 +354,7 @@ class Currency(db.Model):
|
||||
|
||||
:return: The string representation of the currency.
|
||||
"""
|
||||
return F"{self.name} ({self.code})"
|
||||
return f"{self.name} ({self.code})"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@ -588,7 +550,7 @@ class Transaction(db.Model):
|
||||
for currency in self.currencies:
|
||||
if len(currency.debit) > 1:
|
||||
return False
|
||||
if currency.debit[0].account.code != "1111-001":
|
||||
if currency.debit[0].account.code != Account.CASH_CODE:
|
||||
return False
|
||||
return True
|
||||
|
||||
@ -602,7 +564,7 @@ class Transaction(db.Model):
|
||||
for currency in self.currencies:
|
||||
if len(currency.credit) > 1:
|
||||
return False
|
||||
if currency.credit[0].account.code != "1111-001":
|
||||
if currency.credit[0].account.code != Account.CASH_CODE:
|
||||
return False
|
||||
return True
|
||||
|
||||
@ -635,15 +597,15 @@ class JournalEntry(db.Model):
|
||||
"""True for a debit entry, or False for a credit entry."""
|
||||
no = db.Column(db.Integer, nullable=False)
|
||||
"""The entry number under the transaction and debit or credit."""
|
||||
pay_off_target_id = db.Column(db.Integer,
|
||||
offset_original_id = db.Column(db.Integer,
|
||||
db.ForeignKey(id, onupdate="CASCADE"),
|
||||
nullable=True)
|
||||
"""The ID of the pay-off target entry."""
|
||||
pay_off_target = db.relationship("JournalEntry", back_populates="pay_off",
|
||||
"""The ID of the original entry to offset."""
|
||||
offset_original = db.relationship("JournalEntry", back_populates="offsets",
|
||||
remote_side=id, passive_deletes=True)
|
||||
"""The pay-off target entry."""
|
||||
pay_off = db.relationship("JournalEntry", back_populates="pay_off_target")
|
||||
"""The pay-off entries."""
|
||||
"""The original entry to offset."""
|
||||
offsets = db.relationship("JournalEntry", back_populates="offset_original")
|
||||
"""The offset entries."""
|
||||
currency_code = db.Column(db.String,
|
||||
db.ForeignKey(Currency.code, onupdate="CASCADE"),
|
||||
nullable=False)
|
||||
@ -678,3 +640,19 @@ class JournalEntry(db.Model):
|
||||
:return: The account code.
|
||||
"""
|
||||
return self.account.code
|
||||
|
||||
@property
|
||||
def debit(self) -> Decimal | None:
|
||||
"""Returns the debit amount.
|
||||
|
||||
:return: The debit amount, or None if this is not a debit entry.
|
||||
"""
|
||||
return self.amount if self.is_debit else None
|
||||
|
||||
@property
|
||||
def credit(self) -> Decimal | None:
|
||||
"""Returns the credit amount.
|
||||
|
||||
:return: The credit amount, or None if this is not a credit entry.
|
||||
"""
|
||||
return None if self.is_debit else self.amount
|
||||
|
35
src/accounting/report/__init__.py
Normal file
35
src/accounting/report/__init__.py
Normal file
@ -0,0 +1,35 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The report management.
|
||||
|
||||
"""
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
|
||||
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||
"""Initialize the application.
|
||||
|
||||
:param app: The Flask application.
|
||||
:param bp: The blueprint of the accounting application.
|
||||
:return: None.
|
||||
"""
|
||||
from .converters import PeriodConverter, IncomeExpensesAccountConverter
|
||||
app.url_map.converters["period"] = PeriodConverter
|
||||
app.url_map.converters["ieAccount"] = IncomeExpensesAccountConverter
|
||||
|
||||
from .views import bp as report_bp
|
||||
bp.register_blueprint(report_bp, url_prefix="/reports")
|
79
src/accounting/report/converters.py
Normal file
79
src/accounting/report/converters.py
Normal file
@ -0,0 +1,79 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The path converters for the report management.
|
||||
|
||||
"""
|
||||
import re
|
||||
|
||||
from flask import abort
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from accounting.models import Account
|
||||
from .period import Period, get_period
|
||||
from .utils.ie_account import IncomeExpensesAccount
|
||||
|
||||
|
||||
class PeriodConverter(BaseConverter):
|
||||
"""The supplier converter to convert the period specification from and to
|
||||
the corresponding period in the routes."""
|
||||
|
||||
def to_python(self, value: str) -> Period:
|
||||
"""Converts a period specification to a period.
|
||||
|
||||
:param value: The period specification.
|
||||
:return: The corresponding period.
|
||||
"""
|
||||
try:
|
||||
return get_period(value)
|
||||
except ValueError:
|
||||
abort(404)
|
||||
|
||||
def to_url(self, value: Period) -> str:
|
||||
"""Converts a period to its specification.
|
||||
|
||||
:param value: The period.
|
||||
:return: Its specification.
|
||||
"""
|
||||
return value.spec
|
||||
|
||||
|
||||
class IncomeExpensesAccountConverter(BaseConverter):
|
||||
"""The supplier converter to convert the income and expenses log pseudo
|
||||
account code from and to the corresponding pseudo account in the routes."""
|
||||
|
||||
def to_python(self, value: str) -> IncomeExpensesAccount:
|
||||
"""Converts an account code to an account.
|
||||
|
||||
:param value: The account code.
|
||||
:return: The corresponding account.
|
||||
"""
|
||||
if value == IncomeExpensesAccount.CURRENT_AL_CODE:
|
||||
return IncomeExpensesAccount.current_assets_and_liabilities()
|
||||
if not re.match("^[12][12]", value):
|
||||
abort(404)
|
||||
account: Account | None = Account.find_by_code(value)
|
||||
if account is None:
|
||||
abort(404)
|
||||
return IncomeExpensesAccount(account)
|
||||
|
||||
def to_url(self, value: IncomeExpensesAccount) -> str:
|
||||
"""Converts an account to account code.
|
||||
|
||||
:param value: The account.
|
||||
:return: Its code.
|
||||
"""
|
||||
return value.code
|
22
src/accounting/report/period/__init__.py
Normal file
22
src/accounting/report/period/__init__.py
Normal file
@ -0,0 +1,22 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The period utility.
|
||||
|
||||
"""
|
||||
from .chooser import PeriodChooser
|
||||
from .parser import get_period
|
||||
from .period import Period
|
97
src/accounting/report/period/chooser.py
Normal file
97
src/accounting/report/period/chooser.py
Normal file
@ -0,0 +1,97 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The period chooser.
|
||||
|
||||
This file is largely taken from the NanoParma ERP project, first written in
|
||||
2021/9/16 by imacat (imacat@nanoparma.com).
|
||||
|
||||
"""
|
||||
import typing as t
|
||||
from datetime import date
|
||||
|
||||
from accounting.models import Transaction
|
||||
from .period import Period
|
||||
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
|
||||
LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
|
||||
|
||||
|
||||
class PeriodChooser:
|
||||
"""The period chooser."""
|
||||
|
||||
def __init__(self, get_url: t.Callable[[Period], str]):
|
||||
"""Constructs a period chooser.
|
||||
|
||||
:param get_url: The callback to return the URL of the current report in
|
||||
a period.
|
||||
"""
|
||||
self.__get_url: t.Callable[[Period], str] = get_url
|
||||
"""The callback to return the URL of the current report in a period."""
|
||||
|
||||
# Shortcut periods
|
||||
self.this_month_url: str = get_url(ThisMonth())
|
||||
"""The URL for this month."""
|
||||
self.last_month_url: str = get_url(LastMonth())
|
||||
"""The URL for last month."""
|
||||
self.since_last_month_url: str = get_url(SinceLastMonth())
|
||||
"""The URL since last mint."""
|
||||
self.this_year_url: str = get_url(ThisYear())
|
||||
"""The URL for this year."""
|
||||
self.last_year_url: str = get_url(LastYear())
|
||||
"""The URL for last year."""
|
||||
self.today_url: str = get_url(Today())
|
||||
"""The URL for today."""
|
||||
self.yesterday_url: str = get_url(Yesterday())
|
||||
"""The URL for yesterday."""
|
||||
self.all_url: str = get_url(AllTime())
|
||||
"""The URL for all period."""
|
||||
self.url_template: str = get_url(TemplatePeriod())
|
||||
"""The URL template."""
|
||||
|
||||
first: Transaction | None \
|
||||
= Transaction.query.order_by(Transaction.date).first()
|
||||
start: date | None = None if first is None else first.date
|
||||
|
||||
# Attributes
|
||||
self.data_start: date | None = start
|
||||
"""The start of the data."""
|
||||
self.has_data: bool = start is not None
|
||||
"""Whether there is any data."""
|
||||
self.has_last_month: bool = False
|
||||
"""Where there is data in last month."""
|
||||
self.has_last_year: bool = False
|
||||
"""Whether there is data in last year."""
|
||||
self.has_yesterday: bool = False
|
||||
"""Whether there is data in yesterday."""
|
||||
self.available_years: list[int] = []
|
||||
"""The available years."""
|
||||
|
||||
if self.has_data:
|
||||
today: date = date.today()
|
||||
self.has_last_month = start < date(today.year, today.month, 1)
|
||||
self.has_last_year = start.year < today.year
|
||||
self.has_yesterday = start < today
|
||||
if start.year < today.year - 1:
|
||||
self.available_years \
|
||||
= reversed(range(start.year, today.year - 1))
|
||||
|
||||
def year_url(self, year: int) -> str:
|
||||
"""Returns the period URL of a year.
|
||||
|
||||
:param year: The year
|
||||
:return: The period URL of the year.
|
||||
"""
|
||||
return self.__get_url(YearPeriod(year))
|
179
src/accounting/report/period/description.py
Normal file
179
src/accounting/report/period/description.py
Normal file
@ -0,0 +1,179 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The period description composer.
|
||||
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
|
||||
from accounting.locale import gettext
|
||||
|
||||
|
||||
def get_desc(start: date | None, end: date | None) -> str:
|
||||
"""Returns the period description.
|
||||
|
||||
:param start: The start of the period.
|
||||
:param end: The end of the period.
|
||||
:return: The period description.
|
||||
"""
|
||||
if start is None and end is None:
|
||||
return gettext("for all time")
|
||||
if start is None:
|
||||
return __get_until_desc(end)
|
||||
if end is None:
|
||||
return __get_since_desc(start)
|
||||
try:
|
||||
return __get_year_desc(start, end)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return __get_month_desc(start, end)
|
||||
except ValueError:
|
||||
pass
|
||||
return __get_day_desc(start, end)
|
||||
|
||||
|
||||
def __get_since_desc(start: date) -> str:
|
||||
"""Returns the description without the end day.
|
||||
|
||||
:param start: The start of the period.
|
||||
:return: The description without the end day.
|
||||
"""
|
||||
|
||||
def get_start_desc() -> str:
|
||||
"""Returns the description of the start day.
|
||||
|
||||
:return: The description of the start day.
|
||||
"""
|
||||
if start.month == 1 and start.day == 1:
|
||||
return str(start.year)
|
||||
if start.day == 1:
|
||||
return __format_month(start)
|
||||
return __format_day(start)
|
||||
|
||||
return gettext("since %(start)s", start=get_start_desc())
|
||||
|
||||
|
||||
def __get_until_desc(end: date) -> str:
|
||||
"""Returns the description without the start day.
|
||||
|
||||
:param end: The end of the period.
|
||||
:return: The description without the start day.
|
||||
"""
|
||||
|
||||
def get_end_desc() -> str:
|
||||
"""Returns the description of the end day.
|
||||
|
||||
:return: The description of the end day.
|
||||
"""
|
||||
if end.month == 12 and end.day == 31:
|
||||
return str(end.year)
|
||||
if (end + timedelta(days=1)).day == 1:
|
||||
return __format_month(end)
|
||||
return __format_day(end)
|
||||
|
||||
return gettext("until %(end)s", end=get_end_desc())
|
||||
|
||||
|
||||
def __get_year_desc(start: date, end: date) -> str:
|
||||
"""Returns the description as a year range.
|
||||
|
||||
:param start: The start of the period.
|
||||
:param end: The end of the period.
|
||||
:return: The description as a year range.
|
||||
:raise ValueError: The period is not a year range.
|
||||
"""
|
||||
if start.month != 1 or start.day != 1 \
|
||||
or end.month != 12 or end.day != 31:
|
||||
raise ValueError
|
||||
start_text: str = str(start.year)
|
||||
if start.year == end.year:
|
||||
return __get_in_desc(start_text)
|
||||
return __get_from_to_desc(start_text, str(end.year))
|
||||
|
||||
|
||||
def __get_month_desc(start: date, end: date) -> str:
|
||||
"""Returns the description as a month range.
|
||||
|
||||
:param start: The start of the period.
|
||||
:param end: The end of the period.
|
||||
:return: The description as a month range.
|
||||
:raise ValueError: The period is not a month range.
|
||||
"""
|
||||
if start.day != 1 or (end + timedelta(days=1)).day != 1:
|
||||
raise ValueError
|
||||
start_text: str = __format_month(start)
|
||||
if start.year == end.year and start.month == end.month:
|
||||
return __get_in_desc(start_text)
|
||||
if start.year == end.year:
|
||||
return __get_from_to_desc(start_text, str(end.month))
|
||||
return __get_from_to_desc(start_text, __format_month(end))
|
||||
|
||||
|
||||
def __get_day_desc(start: date, end: date) -> str:
|
||||
"""Returns the description as a day range.
|
||||
|
||||
:param start: The start of the period.
|
||||
:param end: The end of the period.
|
||||
:return: The description as a day range.
|
||||
:raise ValueError: The period is a month or year range.
|
||||
"""
|
||||
start_text: str = __format_day(start)
|
||||
if start == end:
|
||||
return __get_in_desc(start_text)
|
||||
if start.year == end.year and start.month == end.month:
|
||||
return __get_from_to_desc(start_text, str(end.day))
|
||||
if start.year == end.year:
|
||||
end_month_day: str = f"{end.month}/{end.day}"
|
||||
return __get_from_to_desc(start_text, end_month_day)
|
||||
return __get_from_to_desc(start_text, __format_day(end))
|
||||
|
||||
|
||||
def __format_month(month: date) -> str:
|
||||
"""Formats a month.
|
||||
|
||||
:param month: The month.
|
||||
:return: The formatted month.
|
||||
"""
|
||||
return f"{month.year}/{month.month}"
|
||||
|
||||
|
||||
def __format_day(day: date) -> str:
|
||||
"""Formats a day.
|
||||
|
||||
:param day: The day.
|
||||
:return: The formatted day.
|
||||
"""
|
||||
return f"{day.year}/{day.month}/{day.day}"
|
||||
|
||||
|
||||
def __get_in_desc(period: str) -> str:
|
||||
"""Returns the description of a whole year, month, or day.
|
||||
|
||||
:param period: The time period.
|
||||
:return: The description of a whole year, month, or day.
|
||||
"""
|
||||
return gettext("in %(period)s", period=period)
|
||||
|
||||
|
||||
def __get_from_to_desc(start: str, end: str) -> str:
|
||||
"""Returns the description of a separated start and end.
|
||||
|
||||
:param start: The start.
|
||||
:param end: The end.
|
||||
:return: The description of the separated start and end.
|
||||
"""
|
||||
return gettext("in %(start)s-%(end)s", start=start, end=end)
|
31
src/accounting/report/period/month_end.py
Normal file
31
src/accounting/report/period/month_end.py
Normal file
@ -0,0 +1,31 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The utility to return the end of a month.
|
||||
|
||||
"""
|
||||
import calendar
|
||||
from datetime import date
|
||||
|
||||
|
||||
def month_end(day: date) -> date:
|
||||
"""Returns the end day of month for a date.
|
||||
|
||||
:param day: The date.
|
||||
:return: The end day of the month of that day.
|
||||
"""
|
||||
last_day: int = calendar.monthrange(day.year, day.month)[1]
|
||||
return date(day.year, day.month, last_day)
|
119
src/accounting/report/period/parser.py
Normal file
119
src/accounting/report/period/parser.py
Normal file
@ -0,0 +1,119 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The period specification parser.
|
||||
|
||||
"""
|
||||
import calendar
|
||||
import re
|
||||
import typing as t
|
||||
from datetime import date
|
||||
|
||||
from .period import Period
|
||||
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
|
||||
LastYear, Today, Yesterday, AllTime
|
||||
|
||||
DATE_SPEC_RE: str = r"(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?"
|
||||
"""The regular expression of a date specification."""
|
||||
|
||||
|
||||
def get_period(spec: str | None = None) -> Period:
|
||||
"""Returns a period instance.
|
||||
|
||||
:param spec: The period specification, or omit for the default.
|
||||
:return: The period instance.
|
||||
:raise ValueError: When the period specification is invalid.
|
||||
"""
|
||||
if spec is None:
|
||||
return ThisMonth()
|
||||
named_periods: dict[str, t.Type[t.Callable[[], Period]]] = {
|
||||
"this-month": lambda: ThisMonth(),
|
||||
"last-month": lambda: LastMonth(),
|
||||
"since-last-month": lambda: SinceLastMonth(),
|
||||
"this-year": lambda: ThisYear(),
|
||||
"last-year": lambda: LastYear(),
|
||||
"today": lambda: Today(),
|
||||
"yesterday": lambda: Yesterday(),
|
||||
"all-time": lambda: AllTime(),
|
||||
}
|
||||
if spec in named_periods:
|
||||
return named_periods[spec]()
|
||||
start, end = __parse_spec(spec)
|
||||
if start is not None and end is not None and start > end:
|
||||
raise ValueError
|
||||
return Period(start, end)
|
||||
|
||||
|
||||
def __parse_spec(text: str) -> tuple[date | None, date | None]:
|
||||
"""Parses the period specification.
|
||||
|
||||
:param text: The period specification.
|
||||
:return: The start and end day of the period. The start and end day
|
||||
may be None.
|
||||
:raise ValueError: When the date is invalid.
|
||||
"""
|
||||
if text == "-":
|
||||
return None, None
|
||||
m = re.match(f"^{DATE_SPEC_RE}$", text)
|
||||
if m is not None:
|
||||
return __get_start(m[1], m[2], m[3]), \
|
||||
__get_end(m[1], m[2], m[3])
|
||||
m = re.match(f"^{DATE_SPEC_RE}-$", text)
|
||||
if m is not None:
|
||||
return __get_start(m[1], m[2], m[3]), None
|
||||
m = re.match(f"-{DATE_SPEC_RE}$", text)
|
||||
if m is not None:
|
||||
return None, __get_end(m[1], m[2], m[3])
|
||||
m = re.match(f"^{DATE_SPEC_RE}-{DATE_SPEC_RE}$", text)
|
||||
if m is not None:
|
||||
return __get_start(m[1], m[2], m[3]), \
|
||||
__get_end(m[4], m[5], m[6])
|
||||
raise ValueError
|
||||
|
||||
|
||||
def __get_start(year: str, month: str | None, day: str | None) -> date:
|
||||
"""Returns the start of the period from the date representation.
|
||||
|
||||
:param year: The year.
|
||||
:param month: The month, if any.
|
||||
:param day: The day, if any.
|
||||
:return: The start of the period.
|
||||
:raise ValueError: When the date is invalid.
|
||||
"""
|
||||
if day is not None:
|
||||
return date(int(year), int(month), int(day))
|
||||
if month is not None:
|
||||
return date(int(year), int(month), 1)
|
||||
return date(int(year), 1, 1)
|
||||
|
||||
|
||||
def __get_end(year: str, month: str | None, day: str | None) -> date:
|
||||
"""Returns the end of the period from the date representation.
|
||||
|
||||
:param year: The year.
|
||||
:param month: The month, if any.
|
||||
:param day: The day, if any.
|
||||
:return: The end of the period.
|
||||
:raise ValueError: When the date is invalid.
|
||||
"""
|
||||
if day is not None:
|
||||
return date(int(year), int(month), int(day))
|
||||
if month is not None:
|
||||
year_n: int = int(year)
|
||||
month_n: int = int(month)
|
||||
day_n: int = calendar.monthrange(year_n, month_n)[1]
|
||||
return date(year_n, month_n, day_n)
|
||||
return date(int(year), 12, 31)
|
129
src/accounting/report/period/period.py
Normal file
129
src/accounting/report/period/period.py
Normal file
@ -0,0 +1,129 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The date period.
|
||||
|
||||
This file is largely taken from the NanoParma ERP project, first written in
|
||||
2021/9/16 by imacat (imacat@nanoparma.com).
|
||||
|
||||
"""
|
||||
import typing as t
|
||||
from datetime import date, timedelta
|
||||
|
||||
from .description import get_desc
|
||||
from .month_end import month_end
|
||||
from .specification import get_spec
|
||||
|
||||
|
||||
class Period:
|
||||
"""A date period."""
|
||||
|
||||
def __init__(self, start: date | None, end: date | None):
|
||||
"""Constructs a new date period.
|
||||
|
||||
:param start: The start date, or None from the very beginning.
|
||||
:param end: The end date, or None till no end.
|
||||
"""
|
||||
self.start: date | None = start
|
||||
"""The start of the period."""
|
||||
self.end: date | None = end
|
||||
"""The end of the period."""
|
||||
self.is_default: bool = False
|
||||
"""Whether the is the default period."""
|
||||
self.is_this_month: bool = False
|
||||
"""Whether the period is this month."""
|
||||
self.is_last_month: bool = False
|
||||
"""Whether the period is last month."""
|
||||
self.is_since_last_month: bool = False
|
||||
"""Whether the period is since last month."""
|
||||
self.is_this_year: bool = False
|
||||
"""Whether the period is this year."""
|
||||
self.is_last_year: bool = False
|
||||
"""Whether the period is last year."""
|
||||
self.is_today: bool = False
|
||||
"""Whether the period is today."""
|
||||
self.is_yesterday: bool = False
|
||||
"""Whether the period is yesterday."""
|
||||
self.is_all: bool = start is None and end is None
|
||||
"""Whether the period is all time."""
|
||||
self.spec: str = ""
|
||||
"""The period specification."""
|
||||
self.desc: str = ""
|
||||
"""The text description."""
|
||||
self.is_a_month: bool = False
|
||||
"""Whether the period is a whole month."""
|
||||
self.is_type_month: bool = False
|
||||
"""Whether the period is for the month chooser."""
|
||||
self.is_a_year: bool = False
|
||||
"""Whether the period is a whole year."""
|
||||
self.is_a_day: bool = False
|
||||
"""Whether the period is a single day."""
|
||||
self._set_properties()
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
"""Sets the following properties.
|
||||
|
||||
* self.spec
|
||||
* self.desc
|
||||
* self.is_a_month
|
||||
* self.is_type_month
|
||||
* self.is_a_year
|
||||
* self.is_a_day
|
||||
|
||||
Override this method to set the properties in the subclasses, to skip
|
||||
the calculation.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.spec = get_spec(self.start, self.end)
|
||||
self.desc = get_desc(self.start, self.end)
|
||||
if self.start is None or self.end is None:
|
||||
return
|
||||
self.is_a_month = self.start.day == 1 \
|
||||
and self.end == month_end(self.start)
|
||||
self.is_type_month = self.is_a_month
|
||||
self.is_a_year = self.start == date(self.start.year, 1, 1) \
|
||||
and self.end == date(self.start.year, 12, 31)
|
||||
self.is_a_day = self.start == self.end
|
||||
|
||||
def is_year(self, year: int) -> bool:
|
||||
"""Returns whether the period is the specific year period.
|
||||
|
||||
:param year: The year.
|
||||
:return: True if the period is the year period, or False otherwise.
|
||||
"""
|
||||
if not self.is_a_year:
|
||||
return False
|
||||
return self.start.year == year
|
||||
|
||||
@property
|
||||
def is_type_arbitrary(self) -> bool:
|
||||
"""Returns whether this period is an arbitrary period.
|
||||
|
||||
:return: True if this is an arbitrary period, or False otherwise.
|
||||
"""
|
||||
return not self.is_type_month and not self.is_a_year \
|
||||
and not self.is_a_day
|
||||
|
||||
@property
|
||||
def before(self) -> t.Self | None:
|
||||
"""Returns the period before this period.
|
||||
|
||||
:return: The period before this period.
|
||||
"""
|
||||
if self.start is None:
|
||||
return None
|
||||
return Period(None, self.start - timedelta(days=1))
|
168
src/accounting/report/period/shortcuts.py
Normal file
168
src/accounting/report/period/shortcuts.py
Normal file
@ -0,0 +1,168 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The named shortcut periods.
|
||||
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
|
||||
from accounting.locale import gettext
|
||||
from .month_end import month_end
|
||||
from .period import Period
|
||||
|
||||
|
||||
class ThisMonth(Period):
|
||||
"""The period of this month."""
|
||||
def __init__(self):
|
||||
today: date = date.today()
|
||||
this_month_start: date = date(today.year, today.month, 1)
|
||||
super().__init__(this_month_start, month_end(today))
|
||||
self.is_default = True
|
||||
self.is_this_month = True
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
self.spec = "this-month"
|
||||
self.desc = gettext("This month")
|
||||
self.is_a_month = True
|
||||
self.is_type_month = True
|
||||
|
||||
|
||||
class LastMonth(Period):
|
||||
"""The period of this month."""
|
||||
def __init__(self):
|
||||
today: date = date.today()
|
||||
year: int = today.year
|
||||
month: int = today.month - 1
|
||||
if month < 1:
|
||||
year = year - 1
|
||||
month = 12
|
||||
start: date = date(year, month, 1)
|
||||
super().__init__(start, month_end(start))
|
||||
self.is_last_month = True
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
self.spec = "last-month"
|
||||
self.desc = gettext("Last month")
|
||||
self.is_a_month = True
|
||||
self.is_type_month = True
|
||||
|
||||
|
||||
class SinceLastMonth(Period):
|
||||
"""The period of this month."""
|
||||
def __init__(self):
|
||||
today: date = date.today()
|
||||
year: int = today.year
|
||||
month: int = today.month - 1
|
||||
if month < 1:
|
||||
year = year - 1
|
||||
month = 12
|
||||
start: date = date(year, month, 1)
|
||||
super().__init__(start, None)
|
||||
self.is_since_last_month = True
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
self.spec = "since-last-month"
|
||||
self.desc = gettext("Since last month")
|
||||
self.is_type_month = True
|
||||
|
||||
|
||||
class ThisYear(Period):
|
||||
"""The period of this year."""
|
||||
def __init__(self):
|
||||
year: int = date.today().year
|
||||
start: date = date(year, 1, 1)
|
||||
end: date = date(year, 12, 31)
|
||||
super().__init__(start, end)
|
||||
self.is_this_year = True
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
self.spec = "this-year"
|
||||
self.desc = gettext("This year")
|
||||
self.is_a_year = True
|
||||
|
||||
|
||||
class LastYear(Period):
|
||||
"""The period of last year."""
|
||||
def __init__(self):
|
||||
year: int = date.today().year
|
||||
start: date = date(year - 1, 1, 1)
|
||||
end: date = date(year - 1, 12, 31)
|
||||
super().__init__(start, end)
|
||||
self.is_last_year = True
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
self.spec = "last-year"
|
||||
self.desc = gettext("Last year")
|
||||
self.is_a_year = True
|
||||
|
||||
|
||||
class Today(Period):
|
||||
"""The period of today."""
|
||||
def __init__(self):
|
||||
today: date = date.today()
|
||||
super().__init__(today, today)
|
||||
self.is_today = True
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
self.spec = "today"
|
||||
self.desc = gettext("Today")
|
||||
self.is_a_day = True
|
||||
|
||||
|
||||
class Yesterday(Period):
|
||||
"""The period of yesterday."""
|
||||
def __init__(self):
|
||||
yesterday: date = date.today() - timedelta(days=1)
|
||||
super().__init__(yesterday, yesterday)
|
||||
self.is_yesterday = True
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
self.spec = "yesterday"
|
||||
self.desc = gettext("Yesterday")
|
||||
self.is_a_day = True
|
||||
|
||||
|
||||
class AllTime(Period):
|
||||
"""The period of all time."""
|
||||
def __init__(self):
|
||||
super().__init__(None, None)
|
||||
self.is_all = True
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
self.spec = "all-time"
|
||||
self.desc = gettext("All")
|
||||
|
||||
|
||||
class TemplatePeriod(Period):
|
||||
"""The period template."""
|
||||
def __init__(self):
|
||||
super().__init__(None, None)
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
self.spec = "PERIOD"
|
||||
|
||||
|
||||
class YearPeriod(Period):
|
||||
"""A year period."""
|
||||
|
||||
def __init__(self, year: int):
|
||||
"""Constructs a year period.
|
||||
|
||||
:param year: The year.
|
||||
"""
|
||||
start: date = date(year, 1, 1)
|
||||
end: date = date(year, 12, 31)
|
||||
super().__init__(start, end)
|
120
src/accounting/report/period/specification.py
Normal file
120
src/accounting/report/period/specification.py
Normal file
@ -0,0 +1,120 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The period specification composer.
|
||||
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
|
||||
|
||||
def get_spec(start: date | None, end: date | None) -> str:
|
||||
"""Returns the period specification.
|
||||
|
||||
:param start: The start of the period.
|
||||
:param end: The end of the period.
|
||||
: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:
|
||||
return __get_until_spec(end)
|
||||
try:
|
||||
return __get_year_spec(start, end)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return __get_month_spec(start, end)
|
||||
except ValueError:
|
||||
pass
|
||||
return __get_day_spec(start, end)
|
||||
|
||||
|
||||
def __get_since_spec(start: date) -> str:
|
||||
"""Returns the period specification without the end day.
|
||||
|
||||
:param start: The start of the period.
|
||||
:return: The period specification without the end day
|
||||
"""
|
||||
if start.month == 1 and start.day == 1:
|
||||
return start.strftime("%Y-")
|
||||
if start.day == 1:
|
||||
return start.strftime("%Y-%m-")
|
||||
return start.strftime("%Y-%m-%d-")
|
||||
|
||||
|
||||
def __get_until_spec(end: date) -> str:
|
||||
"""Returns the period specification without the start day.
|
||||
|
||||
:param end: The end of the period.
|
||||
:return: The period specification without the start day
|
||||
"""
|
||||
if end.month == 12 and end.day == 31:
|
||||
return end.strftime("-%Y")
|
||||
if (end + timedelta(days=1)).day == 1:
|
||||
return end.strftime("-%Y-%m")
|
||||
return end.strftime("-%Y-%m-%d")
|
||||
|
||||
|
||||
def __get_year_spec(start: date, end: date) -> str:
|
||||
"""Returns the period specification as a year range.
|
||||
|
||||
:param start: The start of the period.
|
||||
:param end: The end of the period.
|
||||
:return: The period specification as a year range.
|
||||
:raise ValueError: The period is not a year range.
|
||||
"""
|
||||
if start.month != 1 or start.day != 1 \
|
||||
or end.month != 12 or end.day != 31:
|
||||
raise ValueError
|
||||
start_spec: str = start.strftime("%Y")
|
||||
if start.year == end.year:
|
||||
return start_spec
|
||||
end_spec: str = end.strftime("%Y")
|
||||
return f"{start_spec}-{end_spec}"
|
||||
|
||||
|
||||
def __get_month_spec(start: date, end: date) -> str:
|
||||
"""Returns the period specification as a month range.
|
||||
|
||||
:param start: The start of the period.
|
||||
:param end: The end of the period.
|
||||
:return: The period specification as a month range.
|
||||
:raise ValueError: The period is not a month range.
|
||||
"""
|
||||
if start.day != 1 or (end + timedelta(days=1)).day != 1:
|
||||
raise ValueError
|
||||
start_spec: str = start.strftime("%Y-%m")
|
||||
if start.year == end.year and start.month == end.month:
|
||||
return start_spec
|
||||
end_spec: str = end.strftime("%Y-%m")
|
||||
return f"{start_spec}-{end_spec}"
|
||||
|
||||
|
||||
def __get_day_spec(start: date, end: date) -> str:
|
||||
"""Returns the period specification as a day range.
|
||||
|
||||
:param start: The start of the period.
|
||||
:param end: The end of the period.
|
||||
:return: The period specification as a day range.
|
||||
:raise ValueError: The period is a month or year range.
|
||||
"""
|
||||
start_spec: str = start.strftime("%Y-%m-%d")
|
||||
if start == end:
|
||||
return start_spec
|
||||
end_spec: str = end.strftime("%Y-%m-%d")
|
||||
return f"{start_spec}-{end_spec}"
|
26
src/accounting/report/reports/__init__.py
Normal file
26
src/accounting/report/reports/__init__.py
Normal file
@ -0,0 +1,26 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The reports.
|
||||
|
||||
"""
|
||||
from .balance_sheet import BalanceSheet
|
||||
from .income_expenses import IncomeExpenses
|
||||
from .income_statement import IncomeStatement
|
||||
from .journal import Journal
|
||||
from .ledger import Ledger
|
||||
from .search import Search
|
||||
from .trial_balance import TrialBalance
|
474
src/accounting/report/reports/balance_sheet.py
Normal file
474
src/accounting/report/reports/balance_sheet.py
Normal file
@ -0,0 +1,474 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The balance sheet.
|
||||
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import render_template, Response
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, BaseAccount, Account, Transaction, \
|
||||
JournalEntry
|
||||
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:
|
||||
"""An account in the report."""
|
||||
|
||||
def __init__(self, account: Account, amount: Decimal, url: str):
|
||||
"""Constructs an account in the report.
|
||||
|
||||
:param account: The account.
|
||||
:param amount: The amount.
|
||||
:param url: The URL to the ledger of the account.
|
||||
"""
|
||||
self.account: Account = account
|
||||
"""The account."""
|
||||
self.amount: Decimal = amount
|
||||
"""The amount of the account."""
|
||||
self.url: str = url
|
||||
"""The URL to the ledger of the account."""
|
||||
|
||||
|
||||
class Subsection:
|
||||
"""A subsection."""
|
||||
|
||||
def __init__(self, title: BaseAccount):
|
||||
"""Constructs a subsection.
|
||||
|
||||
:param title: The title account.
|
||||
"""
|
||||
self.title: BaseAccount = title
|
||||
"""The title account."""
|
||||
self.accounts: list[ReportAccount] = []
|
||||
"""The accounts in the subsection."""
|
||||
|
||||
@property
|
||||
def total(self) -> Decimal:
|
||||
"""Returns the total of the subsection.
|
||||
|
||||
:return: The total of the subsection.
|
||||
"""
|
||||
return sum([x.amount for x in self.accounts])
|
||||
|
||||
|
||||
class Section:
|
||||
"""A section."""
|
||||
|
||||
def __init__(self, title: BaseAccount):
|
||||
"""Constructs a section.
|
||||
|
||||
:param title: The title account.
|
||||
"""
|
||||
self.title: BaseAccount = title
|
||||
"""The title account."""
|
||||
self.subsections: list[Subsection] = []
|
||||
"""The subsections in the section."""
|
||||
|
||||
@property
|
||||
def total(self) -> Decimal:
|
||||
"""Returns the total of the section.
|
||||
|
||||
:return: The total of the section.
|
||||
"""
|
||||
return sum([x.total for x in self.subsections])
|
||||
|
||||
|
||||
class AccountCollector:
|
||||
"""The balance sheet account collector."""
|
||||
|
||||
def __init__(self, currency: Currency, period: Period):
|
||||
"""Constructs the balance sheet account collector.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
"""
|
||||
self.__currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.__period: Period = period
|
||||
"""The period."""
|
||||
self.accounts: list[ReportAccount] = self.__query_balances()
|
||||
"""The balance sheet accounts."""
|
||||
|
||||
def __query_balances(self) -> list[ReportAccount]:
|
||||
"""Queries and returns the balances.
|
||||
|
||||
:return: The balances.
|
||||
"""
|
||||
sub_conditions: list[sa.BinaryExpression] \
|
||||
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
= [JournalEntry.currency_code == self.__currency.code,
|
||||
sa.or_(*sub_conditions)]
|
||||
if self.__period.end is not None:
|
||||
conditions.append(Transaction.date <= self.__period.end)
|
||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||
(JournalEntry.is_debit, JournalEntry.amount),
|
||||
else_=-JournalEntry.amount)).label("balance")
|
||||
select_balance: sa.Select \
|
||||
= sa.select(Account.id, Account.base_code, Account.no,
|
||||
balance_func)\
|
||||
.join(Transaction).join(Account)\
|
||||
.filter(*conditions)\
|
||||
.group_by(Account.id, Account.base_code, Account.no)\
|
||||
.order_by(Account.base_code, Account.no)
|
||||
account_balances: list[sa.Row] \
|
||||
= db.session.execute(select_balance).all()
|
||||
self.__all_accounts: list[Account] = Account.query\
|
||||
.filter(sa.or_(Account.id.in_({x.id for x in account_balances}),
|
||||
Account.base_code == "3351",
|
||||
Account.base_code == "3353")).all()
|
||||
account_by_id: dict[int, Account] \
|
||||
= {x.id: x for x in self.__all_accounts}
|
||||
self.accounts: list[ReportAccount] \
|
||||
= [ReportAccount(account=account_by_id[x.id],
|
||||
amount=x.balance,
|
||||
url=ledger_url(self.__currency,
|
||||
account_by_id[x.id],
|
||||
self.__period))
|
||||
for x in account_balances]
|
||||
self.__add_accumulated()
|
||||
self.__add_current_period()
|
||||
self.accounts.sort(key=lambda x: (x.account.base_code, x.account.no))
|
||||
for balance in self.accounts:
|
||||
if not balance.account.base_code.startswith("1"):
|
||||
balance.amount = -balance.amount
|
||||
return self.accounts
|
||||
|
||||
def __add_accumulated(self) -> None:
|
||||
"""Adds the accumulated profit or loss to the balances.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.__add_owner_s_equity(Account.ACCUMULATED_CHANGE_CODE,
|
||||
self.__query_accumulated(),
|
||||
self.__period)
|
||||
|
||||
def __query_accumulated(self) -> Decimal | None:
|
||||
"""Queries and returns the accumulated profit or loss.
|
||||
|
||||
:return: The accumulated profit or loss.
|
||||
"""
|
||||
if self.__period.start is None:
|
||||
return None
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
= [JournalEntry.currency_code == self.__currency.code,
|
||||
Transaction.date < self.__period.start]
|
||||
return self.__query_balance(conditions)
|
||||
|
||||
def __add_current_period(self) -> None:
|
||||
"""Adds the accumulated profit or loss to the balances.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.__add_owner_s_equity(Account.NET_CHANGE_CODE,
|
||||
self.__query_currency_period(),
|
||||
self.__period)
|
||||
|
||||
def __query_currency_period(self) -> Decimal | None:
|
||||
"""Queries and returns the net income or loss for current period.
|
||||
|
||||
:return: The net income or loss for current period.
|
||||
"""
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
= [JournalEntry.currency_code == self.__currency.code]
|
||||
if self.__period.start is not None:
|
||||
conditions.append(Transaction.date >= self.__period.start)
|
||||
if self.__period.end is not None:
|
||||
conditions.append(Transaction.date <= self.__period.end)
|
||||
return self.__query_balance(conditions)
|
||||
|
||||
@staticmethod
|
||||
def __query_balance(conditions: list[sa.BinaryExpression])\
|
||||
-> Decimal:
|
||||
"""Queries the balance.
|
||||
|
||||
:param conditions: The SQL conditions for the balance.
|
||||
:return: The balance.
|
||||
"""
|
||||
conditions.extend([sa.not_(Account.base_code.startswith(x))
|
||||
for x in {"1", "2"}])
|
||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||
(JournalEntry.is_debit, JournalEntry.amount),
|
||||
else_=-JournalEntry.amount))
|
||||
select_balance: sa.Select = sa.select(balance_func)\
|
||||
.join(Transaction).join(Account).filter(*conditions)
|
||||
return db.session.scalar(select_balance)
|
||||
|
||||
def __add_owner_s_equity(self, code: str, amount: Decimal | None,
|
||||
period: Period) -> None:
|
||||
"""Adds an owner's equity balance.
|
||||
|
||||
:param code: The code of the account to add.
|
||||
:param amount: The amount.
|
||||
:param period: The period.
|
||||
:return: None.
|
||||
"""
|
||||
if amount is None:
|
||||
return
|
||||
url: str = income_statement_url(self.__currency, period)
|
||||
# There is an existing balance.
|
||||
account_balance_by_code: dict[str, ReportAccount] \
|
||||
= {x.account.code: x for x in self.accounts}
|
||||
if code in account_balance_by_code:
|
||||
balance: ReportAccount = account_balance_by_code[code]
|
||||
balance.amount = balance.amount + amount
|
||||
balance.url = url
|
||||
return
|
||||
# Add a new balance
|
||||
account_by_code: dict[str, Account] \
|
||||
= {x.code: x for x in self.__all_accounts}
|
||||
self.accounts.append(ReportAccount(account=account_by_code[code],
|
||||
amount=amount,
|
||||
url=url))
|
||||
|
||||
|
||||
class CSVHalfRow:
|
||||
"""A half row in the CSV."""
|
||||
|
||||
def __init__(self, title: str | None, amount: Decimal | None):
|
||||
"""The constructs a half row in the CSV.
|
||||
|
||||
:param title: The title.
|
||||
:param amount: The amount.
|
||||
"""
|
||||
self.title: str | None = title
|
||||
"""The title."""
|
||||
self.amount: Decimal | None = amount
|
||||
"""The amount."""
|
||||
|
||||
|
||||
class CSVRow(BaseCSVRow):
|
||||
"""A row in the CSV."""
|
||||
|
||||
def __init__(self):
|
||||
"""Constructs a row in the CSV."""
|
||||
self.asset_title: str | None = None
|
||||
"""The title of the asset."""
|
||||
self.asset_amount: Decimal | None = None
|
||||
"""The amount of the asset."""
|
||||
self.liability_title: str | None = None
|
||||
"""The title of the liability."""
|
||||
self.liability_amount: Decimal | None = None
|
||||
"""The amount of the liability."""
|
||||
|
||||
@property
|
||||
def values(self) -> list[str | Decimal | None]:
|
||||
"""Returns the values of the row.
|
||||
|
||||
:return: The values of the row.
|
||||
"""
|
||||
return [self.asset_title, self.asset_amount,
|
||||
self.liability_title, self.liability_amount]
|
||||
|
||||
|
||||
class PageParams(BasePageParams):
|
||||
"""The HTML page parameters."""
|
||||
|
||||
def __init__(self, currency: Currency,
|
||||
period: Period,
|
||||
has_data: bool,
|
||||
assets: Section,
|
||||
liabilities: Section,
|
||||
owner_s_equity: Section):
|
||||
"""Constructs the HTML page parameters.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
:param has_data: True if there is any data, or False otherwise.
|
||||
:param assets: The assets.
|
||||
:param liabilities: The liabilities.
|
||||
:param owner_s_equity: The owner's equity.
|
||||
"""
|
||||
self.currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.period: Period = period
|
||||
"""The period."""
|
||||
self.__has_data: bool = has_data
|
||||
"""True if there is any data, or False otherwise."""
|
||||
self.assets: Section = assets
|
||||
"""The assets."""
|
||||
self.liabilities: Section = liabilities
|
||||
"""The liabilities."""
|
||||
self.owner_s_equity: Section = owner_s_equity
|
||||
"""The owner's equity."""
|
||||
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||
lambda x: balance_sheet_url(currency, x))
|
||||
"""The period chooser."""
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
"""Returns whether there is any data on the page.
|
||||
|
||||
:return: True if there is any data, or False otherwise.
|
||||
"""
|
||||
return self.__has_data
|
||||
|
||||
@property
|
||||
def report_chooser(self) -> ReportChooser:
|
||||
"""Returns the report chooser.
|
||||
|
||||
:return: The report chooser.
|
||||
"""
|
||||
return ReportChooser(ReportType.BALANCE_SHEET,
|
||||
currency=self.currency,
|
||||
period=self.period)
|
||||
|
||||
@property
|
||||
def currency_options(self) -> list[OptionLink]:
|
||||
"""Returns the currency options.
|
||||
|
||||
:return: The currency options.
|
||||
"""
|
||||
return self._get_currency_options(
|
||||
lambda x: balance_sheet_url(x, self.period), self.currency)
|
||||
|
||||
|
||||
class BalanceSheet(BaseReport):
|
||||
"""The balance sheet."""
|
||||
|
||||
def __init__(self, currency: Currency, period: Period):
|
||||
"""Constructs a balance sheet.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
"""
|
||||
self.__currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.__period: Period = period
|
||||
"""The period."""
|
||||
self.__has_data: bool
|
||||
"""True if there is any data, or False otherwise."""
|
||||
self.__assets: Section
|
||||
"""The assets."""
|
||||
self.__liabilities: Section
|
||||
"""The liabilities."""
|
||||
self.__owner_s_equity: Section
|
||||
"""The owner's equity."""
|
||||
self.__set_data()
|
||||
|
||||
def __set_data(self) -> None:
|
||||
"""Queries and sets assets, the liabilities, and the owner's equity
|
||||
sections in the balance sheet.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
balances: list[ReportAccount] = AccountCollector(
|
||||
self.__currency, self.__period).accounts
|
||||
|
||||
titles: list[BaseAccount] = BaseAccount.query\
|
||||
.filter(BaseAccount.code.in_({"1", "2", "3"})).all()
|
||||
subtitles: list[BaseAccount] = BaseAccount.query\
|
||||
.filter(BaseAccount.code.in_({x.account.base_code[:2]
|
||||
for x in balances})).all()
|
||||
|
||||
sections: dict[str, Section] = {x.code: Section(x) for x in titles}
|
||||
subsections: dict[str, Subsection] = {x.code: Subsection(x)
|
||||
for x in subtitles}
|
||||
for subsection in subsections.values():
|
||||
sections[subsection.title.code[0]].subsections.append(subsection)
|
||||
for balance in balances:
|
||||
subsections[balance.account.base_code[:2]].accounts.append(balance)
|
||||
|
||||
self.__has_data = len(balances) > 0
|
||||
self.__assets = sections["1"]
|
||||
self.__liabilities = sections["2"]
|
||||
self.__owner_s_equity = sections["3"]
|
||||
|
||||
def csv(self) -> Response:
|
||||
"""Returns the report as CSV for download.
|
||||
|
||||
:return: The response of the report for download.
|
||||
"""
|
||||
filename: str = "balance-sheet-{currency}-{period}.csv"\
|
||||
.format(currency=self.__currency.code,
|
||||
period=period_spec(self.__period))
|
||||
return csv_download(filename, self.__get_csv_rows())
|
||||
|
||||
def __get_csv_rows(self) -> list[CSVRow]:
|
||||
"""Composes and returns the CSV rows.
|
||||
|
||||
:return: The CSV rows.
|
||||
"""
|
||||
asset_rows: list[CSVHalfRow] = self.__section_csv_rows(self.__assets)
|
||||
liability_rows: list[CSVHalfRow] = []
|
||||
liability_rows.extend(self.__section_csv_rows(self.__liabilities))
|
||||
liability_rows.append(CSVHalfRow(gettext("Total"),
|
||||
self.__liabilities.total))
|
||||
liability_rows.append(CSVHalfRow(None, None))
|
||||
liability_rows.extend(self.__section_csv_rows(self.__owner_s_equity))
|
||||
liability_rows.append(CSVHalfRow(gettext("Total"),
|
||||
self.__owner_s_equity.total))
|
||||
rows: list[CSVRow] = [CSVRow() for _ in
|
||||
range(max(len(asset_rows), len(liability_rows)))]
|
||||
for i in range(len(rows)):
|
||||
if i < len(asset_rows):
|
||||
rows[i].asset_title = asset_rows[i].title
|
||||
rows[i].asset_amount = asset_rows[i].amount
|
||||
if i < len(liability_rows) and liability_rows[i].title is not None:
|
||||
rows[i].liability_title = liability_rows[i].title
|
||||
rows[i].liability_amount = liability_rows[i].amount
|
||||
total: CSVRow = CSVRow()
|
||||
total.asset_title = gettext("Total")
|
||||
total.asset_amount = self.__assets.total
|
||||
total.liability_title = gettext("Total")
|
||||
total.liability_amount \
|
||||
= self.__liabilities.total + self.__owner_s_equity.total
|
||||
rows.append(total)
|
||||
return rows
|
||||
|
||||
@staticmethod
|
||||
def __section_csv_rows(section: Section) -> list[CSVHalfRow]:
|
||||
"""Gathers the CSV rows for a section.
|
||||
|
||||
:param section: The section.
|
||||
:return: The CSV rows for the section.
|
||||
"""
|
||||
rows: list[CSVHalfRow] \
|
||||
= [CSVHalfRow(section.title.title.title(), None)]
|
||||
for subsection in section.subsections:
|
||||
rows.append(CSVHalfRow(f" {subsection.title.title.title()}", None))
|
||||
for account in subsection.accounts:
|
||||
rows.append(CSVHalfRow(f" {str(account.account).title()}",
|
||||
account.amount))
|
||||
return rows
|
||||
|
||||
def html(self) -> str:
|
||||
"""Composes and returns the report as HTML.
|
||||
|
||||
:return: The report as HTML.
|
||||
"""
|
||||
params: PageParams = PageParams(currency=self.__currency,
|
||||
period=self.__period,
|
||||
has_data=self.__has_data,
|
||||
assets=self.__assets,
|
||||
liabilities=self.__liabilities,
|
||||
owner_s_equity=self.__owner_s_equity)
|
||||
return render_template("accounting/report/balance-sheet.html",
|
||||
report=params)
|
456
src/accounting/report/reports/income_expenses.py
Normal file
456
src/accounting/report/reports/income_expenses.py
Normal file
@ -0,0 +1,456 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The income and expenses log.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import url_for, render_template, Response
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, Account, Transaction, JournalEntry
|
||||
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.ie_account import IncomeExpensesAccount
|
||||
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 income_expenses_url
|
||||
from accounting.utils.pagination import Pagination
|
||||
|
||||
|
||||
class ReportEntry:
|
||||
"""An entry in the report."""
|
||||
|
||||
def __init__(self, entry: JournalEntry | None = None):
|
||||
"""Constructs the entry in the report.
|
||||
|
||||
:param entry: The journal entry.
|
||||
"""
|
||||
self.is_brought_forward: bool = False
|
||||
"""Whether this is the brought-forward entry."""
|
||||
self.is_total: bool = False
|
||||
"""Whether this is the total entry."""
|
||||
self.date: date | None = None
|
||||
"""The date."""
|
||||
self.account: Account | None = None
|
||||
"""The account."""
|
||||
self.summary: str | None = None
|
||||
"""The summary."""
|
||||
self.income: Decimal | None = None
|
||||
"""The income amount."""
|
||||
self.expense: Decimal | None = None
|
||||
"""The expense amount."""
|
||||
self.balance: Decimal | None = None
|
||||
"""The balance."""
|
||||
self.note: str | None = None
|
||||
"""The note."""
|
||||
self.url: str | None = None
|
||||
"""The URL to the journal entry."""
|
||||
if entry is not None:
|
||||
self.date = entry.transaction.date
|
||||
self.account = entry.account
|
||||
self.summary = entry.summary
|
||||
self.income = None if entry.is_debit else entry.amount
|
||||
self.expense = entry.amount if entry.is_debit else None
|
||||
self.note = entry.transaction.note
|
||||
self.url = url_for("accounting.transaction.detail",
|
||||
txn=entry.transaction)
|
||||
|
||||
|
||||
class EntryCollector:
|
||||
"""The report entry collector."""
|
||||
|
||||
def __init__(self, currency: Currency, account: IncomeExpensesAccount,
|
||||
period: Period):
|
||||
"""Constructs the report entry collector.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:param period: The period.
|
||||
"""
|
||||
self.__currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.__account: IncomeExpensesAccount = account
|
||||
"""The account."""
|
||||
self.__period: Period = period
|
||||
"""The period"""
|
||||
self.brought_forward: ReportEntry | None
|
||||
"""The brought-forward entry."""
|
||||
self.entries: list[ReportEntry]
|
||||
"""The log entries."""
|
||||
self.total: ReportEntry | None
|
||||
"""The total entry."""
|
||||
self.brought_forward = self.__get_brought_forward_entry()
|
||||
self.entries = self.__query_entries()
|
||||
self.total = self.__get_total_entry()
|
||||
self.__populate_balance()
|
||||
|
||||
def __get_brought_forward_entry(self) -> ReportEntry | None:
|
||||
"""Queries, composes and returns the brought-forward entry.
|
||||
|
||||
:return: The brought-forward entry, or None if the period starts from
|
||||
the beginning.
|
||||
"""
|
||||
if self.__period.start is None:
|
||||
return None
|
||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||
(JournalEntry.is_debit, JournalEntry.amount),
|
||||
else_=-JournalEntry.amount))
|
||||
select: sa.Select = sa.Select(balance_func)\
|
||||
.join(Transaction).join(Account)\
|
||||
.filter(JournalEntry.currency_code == self.__currency.code,
|
||||
self.__account_condition,
|
||||
Transaction.date < self.__period.start)
|
||||
balance: int | None = db.session.scalar(select)
|
||||
if balance is None:
|
||||
return None
|
||||
entry: ReportEntry = ReportEntry()
|
||||
entry.is_brought_forward = True
|
||||
entry.date = self.__period.start
|
||||
entry.account = Account.accumulated_change()
|
||||
entry.summary = gettext("Brought forward")
|
||||
if balance > 0:
|
||||
entry.income = balance
|
||||
elif balance < 0:
|
||||
entry.expense = -balance
|
||||
entry.balance = balance
|
||||
return entry
|
||||
|
||||
def __query_entries(self) -> list[ReportEntry]:
|
||||
"""Queries and returns the log entries.
|
||||
|
||||
:return: The log entries.
|
||||
"""
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
= [JournalEntry.currency_code == self.__currency.code,
|
||||
self.__account_condition]
|
||||
if self.__period.start is not None:
|
||||
conditions.append(Transaction.date >= self.__period.start)
|
||||
if self.__period.end is not None:
|
||||
conditions.append(Transaction.date <= self.__period.end)
|
||||
txn_with_account: sa.Select = sa.Select(Transaction.id).\
|
||||
join(JournalEntry).join(Account).filter(*conditions)
|
||||
|
||||
return [ReportEntry(x)
|
||||
for x in JournalEntry.query.join(Transaction).join(Account)
|
||||
.filter(JournalEntry.transaction_id.in_(txn_with_account),
|
||||
JournalEntry.currency_code == self.__currency.code,
|
||||
sa.not_(self.__account_condition))
|
||||
.order_by(Transaction.date,
|
||||
JournalEntry.is_debit,
|
||||
JournalEntry.no)
|
||||
.options(selectinload(JournalEntry.account),
|
||||
selectinload(JournalEntry.transaction))]
|
||||
|
||||
@property
|
||||
def __account_condition(self) -> sa.BinaryExpression:
|
||||
if self.__account.code == IncomeExpensesAccount.CURRENT_AL_CODE:
|
||||
return sa.or_(Account.base_code.startswith("11"),
|
||||
Account.base_code.startswith("12"),
|
||||
Account.base_code.startswith("21"),
|
||||
Account.base_code.startswith("22"))
|
||||
return Account.id == self.__account.id
|
||||
|
||||
def __get_total_entry(self) -> ReportEntry | None:
|
||||
"""Composes the total entry.
|
||||
|
||||
:return: The total entry, or None if there is no data.
|
||||
"""
|
||||
if self.brought_forward is None and len(self.entries) == 0:
|
||||
return None
|
||||
entry: ReportEntry = ReportEntry()
|
||||
entry.is_total = True
|
||||
entry.summary = gettext("Total")
|
||||
entry.income = sum([x.income for x in self.entries
|
||||
if x.income is not None])
|
||||
entry.expense = sum([x.expense for x in self.entries
|
||||
if x.expense is not None])
|
||||
entry.balance = entry.income - entry.expense
|
||||
if self.brought_forward is not None:
|
||||
entry.balance = self.brought_forward.balance + entry.balance
|
||||
return entry
|
||||
|
||||
def __populate_balance(self) -> None:
|
||||
"""Populates the balance of the entries.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
balance: Decimal = 0 if self.brought_forward is None \
|
||||
else self.brought_forward.balance
|
||||
for entry in self.entries:
|
||||
if entry.income is not None:
|
||||
balance = balance + entry.income
|
||||
if entry.expense is not None:
|
||||
balance = balance - entry.expense
|
||||
entry.balance = balance
|
||||
|
||||
|
||||
class CSVRow(BaseCSVRow):
|
||||
"""A row in the CSV."""
|
||||
|
||||
def __init__(self, txn_date: date | str | None,
|
||||
account: str | None,
|
||||
summary: str | None,
|
||||
income: str | Decimal | None,
|
||||
expense: str | Decimal | None,
|
||||
balance: str | Decimal | None,
|
||||
note: str | None):
|
||||
"""Constructs a row in the CSV.
|
||||
|
||||
:param txn_date: The transaction date.
|
||||
:param account: The account.
|
||||
:param summary: The summary.
|
||||
:param income: The income.
|
||||
:param expense: The expense.
|
||||
:param balance: The balance.
|
||||
:param note: The note.
|
||||
"""
|
||||
self.date: date | str | None = txn_date
|
||||
"""The date."""
|
||||
self.account: str | None = account
|
||||
"""The account."""
|
||||
self.summary: str | None = summary
|
||||
"""The summary."""
|
||||
self.income: str | Decimal | None = income
|
||||
"""The income."""
|
||||
self.expense: str | Decimal | None = expense
|
||||
"""The expense."""
|
||||
self.balance: str | Decimal | None = balance
|
||||
"""The balance."""
|
||||
self.note: str | None = note
|
||||
"""The note."""
|
||||
|
||||
@property
|
||||
def values(self) -> list[str | Decimal | None]:
|
||||
"""Returns the values of the row.
|
||||
|
||||
:return: The values of the row.
|
||||
"""
|
||||
return [self.date, self.account, self.summary,
|
||||
self.income, self.expense, self.balance, self.note]
|
||||
|
||||
|
||||
class PageParams(BasePageParams):
|
||||
"""The HTML page parameters."""
|
||||
|
||||
def __init__(self, currency: Currency,
|
||||
account: IncomeExpensesAccount,
|
||||
period: Period,
|
||||
has_data: bool,
|
||||
pagination: Pagination[ReportEntry],
|
||||
brought_forward: ReportEntry | None,
|
||||
entries: list[ReportEntry],
|
||||
total: ReportEntry | None):
|
||||
"""Constructs the HTML page parameters.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:param period: The period.
|
||||
:param has_data: True if there is any data, or False otherwise.
|
||||
:param brought_forward: The brought-forward entry.
|
||||
:param entries: The log entries.
|
||||
:param total: The total entry.
|
||||
"""
|
||||
self.currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.account: IncomeExpensesAccount = account
|
||||
"""The account."""
|
||||
self.period: Period = period
|
||||
"""The period."""
|
||||
self.__has_data: bool = has_data
|
||||
"""True if there is any data, or False otherwise."""
|
||||
self.pagination: Pagination[ReportEntry] = pagination
|
||||
"""The pagination."""
|
||||
self.brought_forward: ReportEntry | None = brought_forward
|
||||
"""The brought-forward entry."""
|
||||
self.entries: list[ReportEntry] = entries
|
||||
"""The report entries."""
|
||||
self.total: ReportEntry | None = total
|
||||
"""The total entry."""
|
||||
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||
lambda x: income_expenses_url(currency, account, x))
|
||||
"""The period chooser."""
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
"""Returns whether there is any data on the page.
|
||||
|
||||
:return: True if there is any data, or False otherwise.
|
||||
"""
|
||||
return self.__has_data
|
||||
|
||||
@property
|
||||
def report_chooser(self) -> ReportChooser:
|
||||
"""Returns the report chooser.
|
||||
|
||||
:return: The report chooser.
|
||||
"""
|
||||
if self.account.account is None:
|
||||
return ReportChooser(ReportType.INCOME_EXPENSES,
|
||||
currency=self.currency,
|
||||
account=Account.cash(),
|
||||
period=self.period)
|
||||
return ReportChooser(ReportType.INCOME_EXPENSES,
|
||||
currency=self.currency,
|
||||
account=self.account.account,
|
||||
period=self.period)
|
||||
|
||||
@property
|
||||
def currency_options(self) -> list[OptionLink]:
|
||||
"""Returns the currency options.
|
||||
|
||||
:return: The currency options.
|
||||
"""
|
||||
return self._get_currency_options(
|
||||
lambda x: income_expenses_url(x, self.account, self.period),
|
||||
self.currency)
|
||||
|
||||
@property
|
||||
def account_options(self) -> list[OptionLink]:
|
||||
"""Returns the account options.
|
||||
|
||||
:return: The account options.
|
||||
"""
|
||||
current_al: IncomeExpensesAccount \
|
||||
= IncomeExpensesAccount.current_assets_and_liabilities()
|
||||
options: list[OptionLink] \
|
||||
= [OptionLink(str(current_al),
|
||||
income_expenses_url(self.currency, current_al,
|
||||
self.period),
|
||||
self.account.id == 0)]
|
||||
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
|
||||
.join(Account)\
|
||||
.filter(JournalEntry.currency_code == self.currency.code,
|
||||
sa.or_(Account.base_code.startswith("11"),
|
||||
Account.base_code.startswith("12"),
|
||||
Account.base_code.startswith("21"),
|
||||
Account.base_code.startswith("22")))\
|
||||
.group_by(JournalEntry.account_id)
|
||||
options.extend([OptionLink(str(x),
|
||||
income_expenses_url(
|
||||
self.currency,
|
||||
IncomeExpensesAccount(x),
|
||||
self.period),
|
||||
x.id == self.account.id)
|
||||
for x in Account.query.filter(Account.id.in_(in_use))
|
||||
.order_by(Account.base_code, Account.no).all()])
|
||||
return options
|
||||
|
||||
|
||||
class IncomeExpenses(BaseReport):
|
||||
"""The income and expenses log."""
|
||||
|
||||
def __init__(self, currency: Currency, account: IncomeExpensesAccount,
|
||||
period: Period):
|
||||
"""Constructs an income and expenses log.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:param period: The period.
|
||||
"""
|
||||
self.__currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.__account: IncomeExpensesAccount = account
|
||||
"""The account."""
|
||||
self.__period: Period = period
|
||||
"""The period."""
|
||||
collector: EntryCollector = EntryCollector(
|
||||
self.__currency, self.__account, self.__period)
|
||||
self.__brought_forward: ReportEntry | None = collector.brought_forward
|
||||
"""The brought-forward entry."""
|
||||
self.__entries: list[ReportEntry] = collector.entries
|
||||
"""The report entries."""
|
||||
self.__total: ReportEntry | None = collector.total
|
||||
"""The total entry."""
|
||||
|
||||
def csv(self) -> Response:
|
||||
"""Returns the report as CSV for download.
|
||||
|
||||
:return: The response of the report for download.
|
||||
"""
|
||||
filename: str = "income-expenses-{currency}-{account}-{period}.csv"\
|
||||
.format(currency=self.__currency.code, account=self.__account.code,
|
||||
period=period_spec(self.__period))
|
||||
return csv_download(filename, self.__get_csv_rows())
|
||||
|
||||
def __get_csv_rows(self) -> list[CSVRow]:
|
||||
"""Composes and returns the CSV rows.
|
||||
|
||||
:return: The CSV rows.
|
||||
"""
|
||||
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Account"),
|
||||
gettext("Summary"), gettext("Income"),
|
||||
gettext("Expense"), gettext("Balance"),
|
||||
gettext("Note"))]
|
||||
if self.__brought_forward is not None:
|
||||
rows.append(CSVRow(self.__brought_forward.date,
|
||||
str(self.__brought_forward.account).title(),
|
||||
self.__brought_forward.summary,
|
||||
self.__brought_forward.income,
|
||||
self.__brought_forward.expense,
|
||||
self.__brought_forward.balance,
|
||||
None))
|
||||
rows.extend([CSVRow(x.date, str(x.account).title(), x.summary,
|
||||
x.income, x.expense, x.balance, x.note)
|
||||
for x in self.__entries])
|
||||
if self.__total is not None:
|
||||
rows.append(CSVRow(gettext("Total"), None, None,
|
||||
self.__total.income, self.__total.expense,
|
||||
self.__total.balance, None))
|
||||
return rows
|
||||
|
||||
def html(self) -> str:
|
||||
"""Composes and returns the report as HTML.
|
||||
|
||||
:return: The report as HTML.
|
||||
"""
|
||||
all_entries: list[ReportEntry] = []
|
||||
if self.__brought_forward is not None:
|
||||
all_entries.append(self.__brought_forward)
|
||||
all_entries.extend(self.__entries)
|
||||
if self.__total is not None:
|
||||
all_entries.append(self.__total)
|
||||
pagination: Pagination[ReportEntry] \
|
||||
= Pagination[ReportEntry](all_entries)
|
||||
page_entries: list[ReportEntry] = pagination.list
|
||||
has_data: bool = len(page_entries) > 0
|
||||
brought_forward: ReportEntry | None = None
|
||||
if len(page_entries) > 0 and page_entries[0].is_brought_forward:
|
||||
brought_forward = page_entries[0]
|
||||
page_entries = page_entries[1:]
|
||||
total: ReportEntry | None = None
|
||||
if len(page_entries) > 0 and page_entries[-1].is_total:
|
||||
total = page_entries[-1]
|
||||
page_entries = page_entries[:-1]
|
||||
params: PageParams = PageParams(currency=self.__currency,
|
||||
account=self.__account,
|
||||
period=self.__period,
|
||||
has_data=has_data,
|
||||
pagination=pagination,
|
||||
brought_forward=brought_forward,
|
||||
entries=page_entries,
|
||||
total=total)
|
||||
return render_template("accounting/report/income-expenses.html",
|
||||
report=params)
|
325
src/accounting/report/reports/income_statement.py
Normal file
325
src/accounting/report/reports/income_statement.py
Normal file
@ -0,0 +1,325 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The income statement.
|
||||
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import render_template, Response
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, BaseAccount, Account, Transaction, \
|
||||
JournalEntry
|
||||
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:
|
||||
"""An account in the report."""
|
||||
|
||||
def __init__(self, account: Account, amount: Decimal, url: str):
|
||||
"""Constructs an account in the report.
|
||||
|
||||
:param account: The account.
|
||||
:param amount: The amount.
|
||||
:param url: The URL to the ledger of the account.
|
||||
"""
|
||||
self.account: Account = account
|
||||
"""The account."""
|
||||
self.amount: Decimal = amount
|
||||
"""The amount of the account."""
|
||||
self.url: str = url
|
||||
"""The URL to the ledger of the account."""
|
||||
|
||||
|
||||
class AccumulatedTotal:
|
||||
"""An accumulated total."""
|
||||
|
||||
def __init__(self, title: str):
|
||||
"""Constructs an accumulated total.
|
||||
|
||||
:param title: The title.
|
||||
"""
|
||||
self.title: str = title
|
||||
"""The account."""
|
||||
self.amount: Decimal = Decimal("0")
|
||||
"""The amount of the account."""
|
||||
|
||||
|
||||
class Subsection:
|
||||
"""A subsection."""
|
||||
|
||||
def __init__(self, title: BaseAccount):
|
||||
"""Constructs a subsection.
|
||||
|
||||
:param title: The title account.
|
||||
"""
|
||||
self.title: BaseAccount = title
|
||||
"""The title account."""
|
||||
self.accounts: list[ReportAccount] = []
|
||||
"""The accounts in the subsection."""
|
||||
|
||||
@property
|
||||
def total(self) -> Decimal:
|
||||
"""Returns the total of the subsection.
|
||||
|
||||
:return: The total of the subsection.
|
||||
"""
|
||||
return sum([x.amount for x in self.accounts])
|
||||
|
||||
|
||||
class Section:
|
||||
"""A section."""
|
||||
|
||||
def __init__(self, title: BaseAccount, accumulated_title: str):
|
||||
"""Constructs a section.
|
||||
|
||||
:param title: The title account.
|
||||
:param accumulated_title: The title for the accumulated total.
|
||||
"""
|
||||
self.title: BaseAccount = title
|
||||
"""The title account."""
|
||||
self.subsections: list[Subsection] = []
|
||||
"""The subsections in the section."""
|
||||
self.accumulated: AccumulatedTotal \
|
||||
= AccumulatedTotal(accumulated_title)
|
||||
|
||||
@property
|
||||
def total(self) -> Decimal:
|
||||
"""Returns the total of the section.
|
||||
|
||||
:return: The total of the section.
|
||||
"""
|
||||
return sum([x.total for x in self.subsections])
|
||||
|
||||
|
||||
class CSVRow(BaseCSVRow):
|
||||
"""A row in the CSV."""
|
||||
|
||||
def __init__(self, text: str | None, amount: str | Decimal | None):
|
||||
"""Constructs a row in the CSV.
|
||||
|
||||
:param text: The text.
|
||||
:param amount: The amount.
|
||||
"""
|
||||
self.text: str | None = text
|
||||
"""The text."""
|
||||
self.amount: str | Decimal | None = amount
|
||||
"""The amount."""
|
||||
|
||||
@property
|
||||
def values(self) -> list[str | Decimal | None]:
|
||||
"""Returns the values of the row.
|
||||
|
||||
:return: The values of the row.
|
||||
"""
|
||||
return [self.text, self.amount]
|
||||
|
||||
|
||||
class PageParams(BasePageParams):
|
||||
"""The HTML page parameters."""
|
||||
|
||||
def __init__(self, currency: Currency,
|
||||
period: Period,
|
||||
has_data: bool,
|
||||
sections: list[Section], ):
|
||||
"""Constructs the HTML page parameters.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
:param has_data: True if there is any data, or False otherwise.
|
||||
"""
|
||||
self.currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.period: Period = period
|
||||
"""The period."""
|
||||
self.__has_data: bool = has_data
|
||||
"""True if there is any data, or False otherwise."""
|
||||
self.sections: list[Section] = sections
|
||||
"""The sections in the income statement."""
|
||||
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||
lambda x: income_statement_url(currency, x))
|
||||
"""The period chooser."""
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
"""Returns whether there is any data on the page.
|
||||
|
||||
:return: True if there is any data, or False otherwise.
|
||||
"""
|
||||
return self.__has_data
|
||||
|
||||
@property
|
||||
def report_chooser(self) -> ReportChooser:
|
||||
"""Returns the report chooser.
|
||||
|
||||
:return: The report chooser.
|
||||
"""
|
||||
return ReportChooser(ReportType.INCOME_STATEMENT,
|
||||
currency=self.currency,
|
||||
period=self.period)
|
||||
|
||||
@property
|
||||
def currency_options(self) -> list[OptionLink]:
|
||||
"""Returns the currency options.
|
||||
|
||||
:return: The currency options.
|
||||
"""
|
||||
return self._get_currency_options(
|
||||
lambda x: income_statement_url(x, self.period), self.currency)
|
||||
|
||||
|
||||
class IncomeStatement(BaseReport):
|
||||
"""The income statement."""
|
||||
|
||||
def __init__(self, currency: Currency, period: Period):
|
||||
"""Constructs an income statement.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
"""
|
||||
self.__currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.__period: Period = period
|
||||
"""The period."""
|
||||
self.__has_data: bool
|
||||
"""True if there is any data, or False otherwise."""
|
||||
self.__sections: list[Section]
|
||||
"""The sections."""
|
||||
self.__set_data()
|
||||
|
||||
def __set_data(self) -> None:
|
||||
"""Queries and sets data sections in the income statement.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
balances: list[ReportAccount] = self.__query_balances()
|
||||
|
||||
titles: list[BaseAccount] = BaseAccount.query\
|
||||
.filter(BaseAccount.code.in_({"4", "5", "6", "7", "8", "9"})).all()
|
||||
subtitles: list[BaseAccount] = BaseAccount.query\
|
||||
.filter(BaseAccount.code.in_({x.account.base_code[:2]
|
||||
for x in balances})).all()
|
||||
|
||||
total_titles: dict[str, str] \
|
||||
= {"4": gettext("total operating revenue"),
|
||||
"5": gettext("gross income"),
|
||||
"6": gettext("operating income"),
|
||||
"7": gettext("before tax income"),
|
||||
"8": gettext("after tax income"),
|
||||
"9": gettext("net income or loss for current period")}
|
||||
|
||||
sections: dict[str, Section] \
|
||||
= {x.code: Section(x, total_titles[x.code]) for x in titles}
|
||||
subsections: dict[str, Subsection] \
|
||||
= {x.code: Subsection(x) for x in subtitles}
|
||||
for subsection in subsections.values():
|
||||
sections[subsection.title.code[0]].subsections.append(subsection)
|
||||
for balance in balances:
|
||||
subsections[balance.account.base_code[:2]].accounts.append(balance)
|
||||
|
||||
self.__has_data = len(balances) > 0
|
||||
self.__sections = sorted(sections.values(), key=lambda x: x.title.code)
|
||||
total: Decimal = Decimal("0")
|
||||
for section in self.__sections:
|
||||
total = total + section.total
|
||||
section.accumulated.amount = total
|
||||
|
||||
def __query_balances(self) -> list[ReportAccount]:
|
||||
"""Queries and returns the balances.
|
||||
|
||||
:return: The balances.
|
||||
"""
|
||||
sub_conditions: list[sa.BinaryExpression] \
|
||||
= [Account.base_code.startswith(str(x)) for x in range(4, 10)]
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
= [JournalEntry.currency_code == self.__currency.code,
|
||||
sa.or_(*sub_conditions)]
|
||||
if self.__period.start is not None:
|
||||
conditions.append(Transaction.date >= self.__period.start)
|
||||
if self.__period.end is not None:
|
||||
conditions.append(Transaction.date <= self.__period.end)
|
||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||
(JournalEntry.is_debit, -JournalEntry.amount),
|
||||
else_=JournalEntry.amount)).label("balance")
|
||||
select_balances: sa.Select = sa.select(Account.id, balance_func)\
|
||||
.join(Transaction).join(Account)\
|
||||
.filter(*conditions)\
|
||||
.group_by(Account.id)\
|
||||
.order_by(Account.base_code, Account.no)
|
||||
balances: list[sa.Row] = db.session.execute(select_balances).all()
|
||||
accounts: dict[int, Account] \
|
||||
= {x.id: x for x in Account.query
|
||||
.filter(Account.id.in_([x.id for x in balances])).all()}
|
||||
return [ReportAccount(account=accounts[x.id],
|
||||
amount=x.balance,
|
||||
url=ledger_url(self.__currency,
|
||||
accounts[x.id],
|
||||
self.__period))
|
||||
for x in balances]
|
||||
|
||||
def csv(self) -> Response:
|
||||
"""Returns the report as CSV for download.
|
||||
|
||||
:return: The response of the report for download.
|
||||
"""
|
||||
filename: str = "income-statement-{currency}-{period}.csv"\
|
||||
.format(currency=self.__currency.code,
|
||||
period=period_spec(self.__period))
|
||||
return csv_download(filename, self.__get_csv_rows())
|
||||
|
||||
def __get_csv_rows(self) -> list[CSVRow]:
|
||||
"""Composes and returns the CSV rows.
|
||||
|
||||
:return: The CSV rows.
|
||||
"""
|
||||
total_str: str = gettext("Total")
|
||||
rows: list[CSVRow] = [CSVRow(None, gettext("Amount"))]
|
||||
for section in self.__sections:
|
||||
rows.append(CSVRow(str(section.title).title(), None))
|
||||
for subsection in section.subsections:
|
||||
rows.append(CSVRow(f" {str(subsection.title).title()}", None))
|
||||
for account in subsection.accounts:
|
||||
rows.append(CSVRow(f" {str(account.account).title()}",
|
||||
account.amount))
|
||||
rows.append(CSVRow(f" {total_str}", subsection.total))
|
||||
rows.append(CSVRow(section.accumulated.title.title(),
|
||||
section.accumulated.amount))
|
||||
rows.append(CSVRow(None, None))
|
||||
rows = rows[:-1]
|
||||
return rows
|
||||
|
||||
def html(self) -> str:
|
||||
"""Composes and returns the report as HTML.
|
||||
|
||||
:return: The report as HTML.
|
||||
"""
|
||||
params: PageParams = PageParams(currency=self.__currency,
|
||||
period=self.__period,
|
||||
has_data=self.__has_data,
|
||||
sections=self.__sections)
|
||||
return render_template("accounting/report/income-statement.html",
|
||||
report=params)
|
216
src/accounting/report/reports/journal.py
Normal file
216
src/accounting/report/reports/journal.py
Normal file
@ -0,0 +1,216 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The journal.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import render_template, Response
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, Account, Transaction, JournalEntry
|
||||
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.report_chooser import ReportChooser
|
||||
from accounting.report.utils.report_type import ReportType
|
||||
from accounting.report.utils.urls import journal_url
|
||||
from accounting.utils.pagination import Pagination
|
||||
|
||||
|
||||
class ReportEntry:
|
||||
"""An entry in the report."""
|
||||
|
||||
def __init__(self, entry: JournalEntry):
|
||||
"""Constructs the entry in the report.
|
||||
|
||||
:param entry: The journal entry.
|
||||
"""
|
||||
self.entry: JournalEntry = entry
|
||||
"""The journal entry."""
|
||||
self.transaction: Transaction = entry.transaction
|
||||
"""The transaction."""
|
||||
self.currency: Currency = entry.currency
|
||||
"""The account."""
|
||||
self.account: Account = entry.account
|
||||
"""The account."""
|
||||
self.summary: str | None = entry.summary
|
||||
"""The summary."""
|
||||
self.debit: Decimal | None = entry.debit
|
||||
"""The debit amount."""
|
||||
self.credit: Decimal | None = entry.credit
|
||||
"""The credit amount."""
|
||||
self.amount: Decimal = entry.amount
|
||||
"""The amount."""
|
||||
|
||||
|
||||
class CSVRow(BaseCSVRow):
|
||||
"""A row in the CSV."""
|
||||
|
||||
def __init__(self, txn_date: str | date,
|
||||
currency: str,
|
||||
account: str,
|
||||
summary: str | None,
|
||||
debit: str | Decimal | None,
|
||||
credit: str | Decimal | None,
|
||||
note: str | None):
|
||||
"""Constructs a row in the CSV.
|
||||
|
||||
:param txn_date: The transaction date.
|
||||
:param summary: The summary.
|
||||
:param debit: The debit amount.
|
||||
:param credit: The credit amount.
|
||||
:param note: The note.
|
||||
"""
|
||||
self.date: str | date = txn_date
|
||||
"""The date."""
|
||||
self.currency: str = currency
|
||||
"""The currency."""
|
||||
self.account: str = account
|
||||
"""The account."""
|
||||
self.summary: str | None = summary
|
||||
"""The summary."""
|
||||
self.debit: str | Decimal | None = debit
|
||||
"""The debit amount."""
|
||||
self.credit: str | Decimal | None = credit
|
||||
"""The credit amount."""
|
||||
self.note: str | None = note
|
||||
"""The note."""
|
||||
|
||||
@property
|
||||
def values(self) -> list[str | Decimal | None]:
|
||||
"""Returns the values of the row.
|
||||
|
||||
:return: The values of the row.
|
||||
"""
|
||||
return [self.date, self.currency, self.account, self.summary,
|
||||
self.debit, self.credit, self.note]
|
||||
|
||||
|
||||
class PageParams(BasePageParams):
|
||||
"""The HTML page parameters."""
|
||||
|
||||
def __init__(self, period: Period,
|
||||
pagination: Pagination[JournalEntry],
|
||||
entries: list[JournalEntry]):
|
||||
"""Constructs the HTML page parameters.
|
||||
|
||||
:param period: The period.
|
||||
:param entries: The journal entries.
|
||||
"""
|
||||
self.period: Period = period
|
||||
"""The period."""
|
||||
self.pagination: Pagination[JournalEntry] = pagination
|
||||
"""The pagination."""
|
||||
self.entries: list[JournalEntry] = entries
|
||||
"""The entries."""
|
||||
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||
lambda x: journal_url(x))
|
||||
"""The period chooser."""
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
"""Returns whether there is any data on the page.
|
||||
|
||||
:return: True if there is any data, or False otherwise.
|
||||
"""
|
||||
return len(self.entries) > 0
|
||||
|
||||
@property
|
||||
def report_chooser(self) -> ReportChooser:
|
||||
"""Returns the report chooser.
|
||||
|
||||
:return: The report chooser.
|
||||
"""
|
||||
return ReportChooser(ReportType.JOURNAL,
|
||||
period=self.period)
|
||||
|
||||
|
||||
def get_csv_rows(entries: list[JournalEntry]) -> list[CSVRow]:
|
||||
"""Composes and returns the CSV rows from the report entries.
|
||||
|
||||
:param entries: The report entries.
|
||||
:return: The CSV rows.
|
||||
"""
|
||||
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
|
||||
gettext("Account"), gettext("Summary"),
|
||||
gettext("Debit"), gettext("Credit"),
|
||||
gettext("Note"))]
|
||||
rows.extend([CSVRow(x.transaction.date, x.currency.code,
|
||||
str(x.account).title(), x.summary,
|
||||
x.debit, x.credit, x.transaction.note)
|
||||
for x in entries])
|
||||
return rows
|
||||
|
||||
|
||||
class Journal(BaseReport):
|
||||
"""The journal."""
|
||||
|
||||
def __init__(self, period: Period):
|
||||
"""Constructs a journal.
|
||||
|
||||
:param period: The period.
|
||||
"""
|
||||
self.__period: Period = period
|
||||
"""The period."""
|
||||
self.__entries: list[JournalEntry] = self.__query_entries()
|
||||
"""The journal entries."""
|
||||
|
||||
def __query_entries(self) -> list[JournalEntry]:
|
||||
"""Queries and returns the journal entries.
|
||||
|
||||
:return: The journal entries.
|
||||
"""
|
||||
conditions: list[sa.BinaryExpression] = []
|
||||
if self.__period.start is not None:
|
||||
conditions.append(Transaction.date >= self.__period.start)
|
||||
if self.__period.end is not None:
|
||||
conditions.append(Transaction.date <= self.__period.end)
|
||||
return JournalEntry.query.join(Transaction)\
|
||||
.filter(*conditions)\
|
||||
.order_by(Transaction.date,
|
||||
JournalEntry.is_debit.desc(),
|
||||
JournalEntry.no)\
|
||||
.options(selectinload(JournalEntry.account),
|
||||
selectinload(JournalEntry.currency),
|
||||
selectinload(JournalEntry.transaction)).all()
|
||||
|
||||
def csv(self) -> Response:
|
||||
"""Returns the report as CSV for download.
|
||||
|
||||
:return: The response of the report for download.
|
||||
"""
|
||||
filename: str = f"journal-{period_spec(self.__period)}.csv"
|
||||
return csv_download(filename, get_csv_rows(self.__entries))
|
||||
|
||||
def html(self) -> str:
|
||||
"""Composes and returns the report as HTML.
|
||||
|
||||
:return: The report as HTML.
|
||||
"""
|
||||
pagination: Pagination[JournalEntry] \
|
||||
= Pagination[JournalEntry](self.__entries)
|
||||
params: PageParams = PageParams(period=self.__period,
|
||||
pagination=pagination,
|
||||
entries=pagination.list)
|
||||
return render_template("accounting/report/journal.html",
|
||||
report=params)
|
405
src/accounting/report/reports/ledger.py
Normal file
405
src/accounting/report/reports/ledger.py
Normal file
@ -0,0 +1,405 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The ledger.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import url_for, render_template, Response
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, Account, Transaction, JournalEntry
|
||||
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
|
||||
from accounting.utils.pagination import Pagination
|
||||
|
||||
|
||||
class ReportEntry:
|
||||
"""An entry in the report."""
|
||||
|
||||
def __init__(self, entry: JournalEntry | None = None):
|
||||
"""Constructs the entry in the report.
|
||||
|
||||
:param entry: The journal entry.
|
||||
"""
|
||||
self.is_brought_forward: bool = False
|
||||
"""Whether this is the brought-forward entry."""
|
||||
self.is_total: bool = False
|
||||
"""Whether this is the total entry."""
|
||||
self.date: date | None = None
|
||||
"""The date."""
|
||||
self.summary: str | None = None
|
||||
"""The summary."""
|
||||
self.debit: Decimal | None = None
|
||||
"""The debit amount."""
|
||||
self.credit: Decimal | None = None
|
||||
"""The credit amount."""
|
||||
self.balance: Decimal | None = None
|
||||
"""The balance."""
|
||||
self.note: str | None = None
|
||||
"""The note."""
|
||||
self.url: str | None = None
|
||||
"""The URL to the journal entry."""
|
||||
if entry is not None:
|
||||
self.date = entry.transaction.date
|
||||
self.summary = entry.summary
|
||||
self.debit = entry.amount if entry.is_debit else None
|
||||
self.credit = None if entry.is_debit else entry.amount
|
||||
self.note = entry.transaction.note
|
||||
self.url = url_for("accounting.transaction.detail",
|
||||
txn=entry.transaction)
|
||||
|
||||
|
||||
class EntryCollector:
|
||||
"""The report entry collector."""
|
||||
|
||||
def __init__(self, currency: Currency, account: Account, period: Period):
|
||||
"""Constructs the report entry collector.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:param period: The period.
|
||||
"""
|
||||
self.__currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.__account: Account = account
|
||||
"""The account."""
|
||||
self.__period: Period = period
|
||||
"""The period"""
|
||||
self.brought_forward: ReportEntry | None
|
||||
"""The brought-forward entry."""
|
||||
self.entries: list[ReportEntry]
|
||||
"""The report entries."""
|
||||
self.total: ReportEntry | None
|
||||
"""The total entry."""
|
||||
self.brought_forward = self.__get_brought_forward_entry()
|
||||
self.entries = self.__query_entries()
|
||||
self.total = self.__get_total_entry()
|
||||
self.__populate_balance()
|
||||
|
||||
def __get_brought_forward_entry(self) -> ReportEntry | None:
|
||||
"""Queries, composes and returns the brought-forward entry.
|
||||
|
||||
:return: The brought-forward entry, or None if the report starts from
|
||||
the beginning.
|
||||
"""
|
||||
if self.__period.start is None:
|
||||
return None
|
||||
if self.__account.base_code[0] not in {"1", "2", "3"}:
|
||||
return None
|
||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||
(JournalEntry.is_debit, JournalEntry.amount),
|
||||
else_=-JournalEntry.amount))
|
||||
select: sa.Select = sa.Select(balance_func).join(Transaction)\
|
||||
.filter(JournalEntry.currency_code == self.__currency.code,
|
||||
JournalEntry.account_id == self.__account.id,
|
||||
Transaction.date < self.__period.start)
|
||||
balance: int | None = db.session.scalar(select)
|
||||
if balance is None:
|
||||
return None
|
||||
entry: ReportEntry = ReportEntry()
|
||||
entry.is_brought_forward = True
|
||||
entry.date = self.__period.start
|
||||
entry.summary = gettext("Brought forward")
|
||||
if balance > 0:
|
||||
entry.debit = balance
|
||||
elif balance < 0:
|
||||
entry.credit = -balance
|
||||
entry.balance = balance
|
||||
return entry
|
||||
|
||||
def __query_entries(self) -> list[ReportEntry]:
|
||||
"""Queries and returns the report entries.
|
||||
|
||||
:return: The report entries.
|
||||
"""
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
= [JournalEntry.currency_code == self.__currency.code,
|
||||
JournalEntry.account_id == self.__account.id]
|
||||
if self.__period.start is not None:
|
||||
conditions.append(Transaction.date >= self.__period.start)
|
||||
if self.__period.end is not None:
|
||||
conditions.append(Transaction.date <= self.__period.end)
|
||||
return [ReportEntry(x) for x in JournalEntry.query.join(Transaction)
|
||||
.filter(*conditions)
|
||||
.order_by(Transaction.date,
|
||||
JournalEntry.is_debit.desc(),
|
||||
JournalEntry.no)
|
||||
.options(selectinload(JournalEntry.transaction)).all()]
|
||||
|
||||
def __get_total_entry(self) -> ReportEntry | None:
|
||||
"""Composes the total entry.
|
||||
|
||||
:return: The total entry, or None if there is no data.
|
||||
"""
|
||||
if self.brought_forward is None and len(self.entries) == 0:
|
||||
return None
|
||||
entry: ReportEntry = ReportEntry()
|
||||
entry.is_total = True
|
||||
entry.summary = gettext("Total")
|
||||
entry.debit = sum([x.debit for x in self.entries
|
||||
if x.debit is not None])
|
||||
entry.credit = sum([x.credit for x in self.entries
|
||||
if x.credit is not None])
|
||||
entry.balance = entry.debit - entry.credit
|
||||
if self.brought_forward is not None:
|
||||
entry.balance = self.brought_forward.balance + entry.balance
|
||||
return entry
|
||||
|
||||
def __populate_balance(self) -> None:
|
||||
"""Populates the balance of the entries.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
balance: Decimal = 0 if self.brought_forward is None \
|
||||
else self.brought_forward.balance
|
||||
for entry in self.entries:
|
||||
if entry.debit is not None:
|
||||
balance = balance + entry.debit
|
||||
if entry.credit is not None:
|
||||
balance = balance - entry.credit
|
||||
entry.balance = balance
|
||||
|
||||
|
||||
class CSVRow(BaseCSVRow):
|
||||
"""A row in the CSV."""
|
||||
|
||||
def __init__(self, txn_date: date | str | None,
|
||||
summary: str | None,
|
||||
debit: str | Decimal | None,
|
||||
credit: str | Decimal | None,
|
||||
balance: str | Decimal | None,
|
||||
note: str | None):
|
||||
"""Constructs a row in the CSV.
|
||||
|
||||
:param txn_date: The transaction date.
|
||||
:param summary: The summary.
|
||||
:param debit: The debit amount.
|
||||
:param credit: The credit amount.
|
||||
:param balance: The balance.
|
||||
:param note: The note.
|
||||
"""
|
||||
self.date: date | str | None = txn_date
|
||||
"""The date."""
|
||||
self.summary: str | None = summary
|
||||
"""The summary."""
|
||||
self.debit: str | Decimal | None = debit
|
||||
"""The debit amount."""
|
||||
self.credit: str | Decimal | None = credit
|
||||
"""The credit amount."""
|
||||
self.balance: str | Decimal | None = balance
|
||||
"""The balance."""
|
||||
self.note: str | None = note
|
||||
"""The note."""
|
||||
|
||||
@property
|
||||
def values(self) -> list[str | Decimal | None]:
|
||||
"""Returns the values of the row.
|
||||
|
||||
:return: The values of the row.
|
||||
"""
|
||||
return [self.date, self.summary,
|
||||
self.debit, self.credit, self.balance, self.note]
|
||||
|
||||
|
||||
class PageParams(BasePageParams):
|
||||
"""The HTML page parameters."""
|
||||
|
||||
def __init__(self, currency: Currency,
|
||||
account: Account,
|
||||
period: Period,
|
||||
has_data: bool,
|
||||
pagination: Pagination[ReportEntry],
|
||||
brought_forward: ReportEntry | None,
|
||||
entries: list[ReportEntry],
|
||||
total: ReportEntry | None):
|
||||
"""Constructs the HTML page parameters.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:param period: The period.
|
||||
:param has_data: True if there is any data, or False otherwise.
|
||||
:param brought_forward: The brought-forward entry.
|
||||
:param entries: The report entries.
|
||||
:param total: The total entry.
|
||||
"""
|
||||
self.currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.account: Account = account
|
||||
"""The account."""
|
||||
self.period: Period = period
|
||||
"""The period."""
|
||||
self.__has_data: bool = has_data
|
||||
"""True if there is any data, or False otherwise."""
|
||||
self.pagination: Pagination[ReportEntry] = pagination
|
||||
"""The pagination."""
|
||||
self.brought_forward: ReportEntry | None = brought_forward
|
||||
"""The brought-forward entry."""
|
||||
self.entries: list[ReportEntry] = entries
|
||||
"""The entries."""
|
||||
self.total: ReportEntry | None = total
|
||||
"""The total entry."""
|
||||
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||
lambda x: ledger_url(currency, account, x))
|
||||
"""The period chooser."""
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
"""Returns whether there is any data on the page.
|
||||
|
||||
:return: True if there is any data, or False otherwise.
|
||||
"""
|
||||
return self.__has_data
|
||||
|
||||
@property
|
||||
def report_chooser(self) -> ReportChooser:
|
||||
"""Returns the report chooser.
|
||||
|
||||
:return: The report chooser.
|
||||
"""
|
||||
return ReportChooser(ReportType.LEDGER,
|
||||
currency=self.currency,
|
||||
account=self.account,
|
||||
period=self.period)
|
||||
|
||||
@property
|
||||
def currency_options(self) -> list[OptionLink]:
|
||||
"""Returns the currency options.
|
||||
|
||||
:return: The currency options.
|
||||
"""
|
||||
return self._get_currency_options(
|
||||
lambda x: ledger_url(x, self.account, self.period), self.currency)
|
||||
|
||||
@property
|
||||
def account_options(self) -> list[OptionLink]:
|
||||
"""Returns the account options.
|
||||
|
||||
:return: The account options.
|
||||
"""
|
||||
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
|
||||
.filter(JournalEntry.currency_code == self.currency.code)\
|
||||
.group_by(JournalEntry.account_id)
|
||||
return [OptionLink(str(x), ledger_url(self.currency, x, self.period),
|
||||
x.id == self.account.id)
|
||||
for x in Account.query.filter(Account.id.in_(in_use))
|
||||
.order_by(Account.base_code, Account.no).all()]
|
||||
|
||||
|
||||
class Ledger(BaseReport):
|
||||
"""The ledger."""
|
||||
|
||||
def __init__(self, currency: Currency, account: Account, period: Period):
|
||||
"""Constructs a ledger.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:param period: The period.
|
||||
"""
|
||||
self.__currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.__account: Account = account
|
||||
"""The account."""
|
||||
self.__period: Period = period
|
||||
"""The period."""
|
||||
collector: EntryCollector = EntryCollector(
|
||||
self.__currency, self.__account, self.__period)
|
||||
self.__brought_forward: ReportEntry | None = collector.brought_forward
|
||||
"""The brought-forward entry."""
|
||||
self.__entries: list[ReportEntry] = collector.entries
|
||||
"""The report entries."""
|
||||
self.__total: ReportEntry | None = collector.total
|
||||
"""The total entry."""
|
||||
|
||||
def csv(self) -> Response:
|
||||
"""Returns the report as CSV for download.
|
||||
|
||||
:return: The response of the report for download.
|
||||
"""
|
||||
filename: str = "ledger-{currency}-{account}-{period}.csv"\
|
||||
.format(currency=self.__currency.code, account=self.__account.code,
|
||||
period=period_spec(self.__period))
|
||||
return csv_download(filename, self.__get_csv_rows())
|
||||
|
||||
def __get_csv_rows(self) -> list[CSVRow]:
|
||||
"""Composes and returns the CSV rows.
|
||||
|
||||
:return: The CSV rows.
|
||||
"""
|
||||
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Summary"),
|
||||
gettext("Debit"), gettext("Credit"),
|
||||
gettext("Balance"), gettext("Note"))]
|
||||
if self.__brought_forward is not None:
|
||||
rows.append(CSVRow(self.__brought_forward.date,
|
||||
self.__brought_forward.summary,
|
||||
self.__brought_forward.debit,
|
||||
self.__brought_forward.credit,
|
||||
self.__brought_forward.balance,
|
||||
None))
|
||||
rows.extend([CSVRow(x.date, x.summary,
|
||||
x.debit, x.credit, x.balance, x.note)
|
||||
for x in self.__entries])
|
||||
if self.__total is not None:
|
||||
rows.append(CSVRow(gettext("Total"), None,
|
||||
self.__total.debit, self.__total.credit,
|
||||
self.__total.balance, None))
|
||||
return rows
|
||||
|
||||
def html(self) -> str:
|
||||
"""Composes and returns the report as HTML.
|
||||
|
||||
:return: The report as HTML.
|
||||
"""
|
||||
all_entries: list[ReportEntry] = []
|
||||
if self.__brought_forward is not None:
|
||||
all_entries.append(self.__brought_forward)
|
||||
all_entries.extend(self.__entries)
|
||||
if self.__total is not None:
|
||||
all_entries.append(self.__total)
|
||||
pagination: Pagination[ReportEntry] \
|
||||
= Pagination[ReportEntry](all_entries)
|
||||
page_entries: list[ReportEntry] = pagination.list
|
||||
has_data: bool = len(page_entries) > 0
|
||||
brought_forward: ReportEntry | None = None
|
||||
if len(page_entries) > 0 and page_entries[0].is_brought_forward:
|
||||
brought_forward = page_entries[0]
|
||||
page_entries = page_entries[1:]
|
||||
total: ReportEntry | None = None
|
||||
if len(page_entries) > 0 and page_entries[-1].is_total:
|
||||
total = page_entries[-1]
|
||||
page_entries = page_entries[:-1]
|
||||
params: PageParams = PageParams(currency=self.__currency,
|
||||
account=self.__account,
|
||||
period=self.__period,
|
||||
has_data=has_data,
|
||||
pagination=pagination,
|
||||
brought_forward=brought_forward,
|
||||
entries=page_entries,
|
||||
total=total)
|
||||
return render_template("accounting/report/ledger.html",
|
||||
report=params)
|
201
src/accounting/report/reports/search.py
Normal file
201
src/accounting/report/reports/search.py
Normal file
@ -0,0 +1,201 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The search.
|
||||
|
||||
"""
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import Response, render_template, request
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
|
||||
Transaction, JournalEntry
|
||||
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
|
||||
|
||||
|
||||
class EntryCollector:
|
||||
"""The report entry collector."""
|
||||
|
||||
def __init__(self):
|
||||
"""Constructs the report entry collector."""
|
||||
self.entries: list[JournalEntry] = self.__query_entries()
|
||||
"""The report entries."""
|
||||
|
||||
def __query_entries(self) -> list[JournalEntry]:
|
||||
"""Queries and returns the journal entries.
|
||||
|
||||
:return: The journal entries.
|
||||
"""
|
||||
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
||||
if len(keywords) == 0:
|
||||
return []
|
||||
conditions: list[sa.BinaryExpression] = []
|
||||
for k in keywords:
|
||||
sub_conditions: list[sa.BinaryExpression] \
|
||||
= [JournalEntry.summary.contains(k),
|
||||
JournalEntry.account_id.in_(
|
||||
self.__get_account_condition(k)),
|
||||
JournalEntry.currency_code.in_(
|
||||
self.__get_currency_condition(k)),
|
||||
JournalEntry.transaction_id.in_(
|
||||
self.__get_transaction_condition(k))]
|
||||
try:
|
||||
sub_conditions.append(JournalEntry.amount == Decimal(k))
|
||||
except ArithmeticError:
|
||||
pass
|
||||
conditions.append(sa.or_(*sub_conditions))
|
||||
return JournalEntry.query.filter(*conditions)\
|
||||
.options(selectinload(JournalEntry.account),
|
||||
selectinload(JournalEntry.currency),
|
||||
selectinload(JournalEntry.transaction)).all()
|
||||
|
||||
@staticmethod
|
||||
def __get_account_condition(k: str) -> sa.Select:
|
||||
"""Composes and returns the condition to filter the account.
|
||||
|
||||
:param k: The keyword.
|
||||
:return: The condition to filter the account.
|
||||
"""
|
||||
code: sa.BinaryExpression = Account.base_code + "-" \
|
||||
+ sa.func.substr("000" + sa.cast(Account.no, sa.String),
|
||||
sa.func.char_length(sa.cast(Account.no,
|
||||
sa.String)) + 1)
|
||||
select_l10n: sa.Select = sa.select(AccountL10n.account_id)\
|
||||
.filter(AccountL10n.title.contains(k))
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
= [Account.base_code.contains(k),
|
||||
Account.title_l10n.contains(k),
|
||||
code.contains(k),
|
||||
Account.id.in_(select_l10n)]
|
||||
if k in gettext("Need offset"):
|
||||
conditions.append(Account.is_offset_needed)
|
||||
return sa.select(Account.id).filter(sa.or_(*conditions))
|
||||
|
||||
@staticmethod
|
||||
def __get_currency_condition(k: str) -> sa.Select:
|
||||
"""Composes and returns the condition to filter the currency.
|
||||
|
||||
:param k: The keyword.
|
||||
:return: The condition to filter the currency.
|
||||
"""
|
||||
select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\
|
||||
.filter(CurrencyL10n.name.contains(k))
|
||||
return sa.select(Currency.code).filter(
|
||||
sa.or_(Currency.code.contains(k),
|
||||
Currency.name_l10n.contains(k),
|
||||
Currency.code.in_(select_l10n)))
|
||||
|
||||
@staticmethod
|
||||
def __get_transaction_condition(k: str) -> sa.Select:
|
||||
"""Composes and returns the condition to filter the transaction.
|
||||
|
||||
:param k: The keyword.
|
||||
:return: The condition to filter the transaction.
|
||||
"""
|
||||
conditions: list[sa.BinaryExpression] = [Transaction.note.contains(k)]
|
||||
txn_date: datetime
|
||||
try:
|
||||
txn_date = datetime.strptime(k, "%Y")
|
||||
conditions.append(
|
||||
sa.extract("year", Transaction.date) == txn_date.year)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
txn_date = datetime.strptime(k, "%Y/%m")
|
||||
conditions.append(sa.and_(
|
||||
sa.extract("year", Transaction.date) == txn_date.year,
|
||||
sa.extract("month", Transaction.date) == txn_date.month))
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
txn_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
|
||||
conditions.append(sa.and_(
|
||||
sa.extract("month", Transaction.date) == txn_date.month,
|
||||
sa.extract("day", Transaction.date) == txn_date.day))
|
||||
except ValueError:
|
||||
pass
|
||||
return sa.select(Transaction.id).filter(sa.or_(*conditions))
|
||||
|
||||
|
||||
class PageParams(BasePageParams):
|
||||
"""The HTML page parameters."""
|
||||
|
||||
def __init__(self, pagination: Pagination[JournalEntry],
|
||||
entries: list[JournalEntry]):
|
||||
"""Constructs the HTML page parameters.
|
||||
|
||||
:param entries: The search result entries.
|
||||
"""
|
||||
self.pagination: Pagination[JournalEntry] = pagination
|
||||
"""The pagination."""
|
||||
self.entries: list[JournalEntry] = entries
|
||||
"""The entries."""
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
"""Returns whether there is any data on the page.
|
||||
|
||||
:return: True if there is any data, or False otherwise.
|
||||
"""
|
||||
return len(self.entries) > 0
|
||||
|
||||
@property
|
||||
def report_chooser(self) -> ReportChooser:
|
||||
"""Returns the report chooser.
|
||||
|
||||
:return: The report chooser.
|
||||
"""
|
||||
return ReportChooser(ReportType.SEARCH)
|
||||
|
||||
|
||||
class Search(BaseReport):
|
||||
"""The search."""
|
||||
|
||||
def __init__(self):
|
||||
"""Constructs a search."""
|
||||
self.__entries: list[JournalEntry] = EntryCollector().entries
|
||||
"""The journal entries."""
|
||||
|
||||
def csv(self) -> Response:
|
||||
"""Returns the report as CSV for download.
|
||||
|
||||
:return: The response of the report for download.
|
||||
"""
|
||||
filename: str = "search-{q}.csv".format(q=request.args["q"])
|
||||
return csv_download(filename, get_csv_rows(self.__entries))
|
||||
|
||||
def html(self) -> str:
|
||||
"""Composes and returns the report as HTML.
|
||||
|
||||
:return: The report as HTML.
|
||||
"""
|
||||
pagination: Pagination[JournalEntry] \
|
||||
= Pagination[JournalEntry](self.__entries)
|
||||
params: PageParams = PageParams(pagination=pagination,
|
||||
entries=pagination.list)
|
||||
return render_template("accounting/report/search.html",
|
||||
report=params)
|
241
src/accounting/report/reports/trial_balance.py
Normal file
241
src/accounting/report/reports/trial_balance.py
Normal file
@ -0,0 +1,241 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The trial balance.
|
||||
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import Response, render_template
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, Account, Transaction, JournalEntry
|
||||
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, trial_balance_url
|
||||
|
||||
|
||||
class ReportAccount:
|
||||
"""An account in the report."""
|
||||
|
||||
def __init__(self, account: Account, amount: Decimal, url: str):
|
||||
"""Constructs an account in the report.
|
||||
|
||||
:param account: The account.
|
||||
:param amount: The amount.
|
||||
:param url: The URL to the ledger of the account.
|
||||
"""
|
||||
self.account: Account = account
|
||||
"""The account."""
|
||||
self.debit: Decimal | None = amount if amount > 0 else None
|
||||
"""The debit amount."""
|
||||
self.credit: Decimal | None = -amount if amount < 0 else None
|
||||
"""The credit amount."""
|
||||
self.url: str = url
|
||||
"""The URL to the ledger of the account."""
|
||||
|
||||
|
||||
class Total:
|
||||
"""The totals."""
|
||||
|
||||
def __init__(self, debit: Decimal, credit: Decimal):
|
||||
"""Constructs the total in the trial balance.
|
||||
|
||||
:param debit: The debit amount.
|
||||
:param credit: The credit amount.
|
||||
"""
|
||||
self.debit: Decimal | None = debit
|
||||
"""The debit amount."""
|
||||
self.credit: Decimal | None = credit
|
||||
"""The credit amount."""
|
||||
|
||||
|
||||
class CSVRow(BaseCSVRow):
|
||||
"""A row in the CSV."""
|
||||
|
||||
def __init__(self, text: str | None,
|
||||
debit: str | Decimal | None,
|
||||
credit: str | Decimal | None):
|
||||
"""Constructs a row in the CSV.
|
||||
|
||||
:param text: The text.
|
||||
:param debit: The debit amount.
|
||||
:param credit: The credit amount.
|
||||
"""
|
||||
self.text: str | None = text
|
||||
"""The text."""
|
||||
self.debit: str | Decimal | None = debit
|
||||
"""The debit amount."""
|
||||
self.credit: str | Decimal | None = credit
|
||||
"""The credit amount."""
|
||||
|
||||
@property
|
||||
def values(self) -> list[str | Decimal | None]:
|
||||
"""Returns the values of the row.
|
||||
|
||||
:return: The values of the row.
|
||||
"""
|
||||
return [self.text, self.debit, self.credit]
|
||||
|
||||
|
||||
class PageParams(BasePageParams):
|
||||
"""The HTML page parameters."""
|
||||
|
||||
def __init__(self, currency: Currency,
|
||||
period: Period,
|
||||
accounts: list[ReportAccount],
|
||||
total: Total):
|
||||
"""Constructs the HTML page parameters.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
:param accounts: The accounts in the trial balance.
|
||||
:param total: The total of the trial balance.
|
||||
"""
|
||||
self.currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.period: Period = period
|
||||
"""The period."""
|
||||
self.accounts: list[ReportAccount] = accounts
|
||||
"""The accounts in the trial balance."""
|
||||
self.total: Total = total
|
||||
"""The total of the trial balance."""
|
||||
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||
lambda x: trial_balance_url(currency, x))
|
||||
"""The period chooser."""
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
"""Returns whether there is any data on the page.
|
||||
|
||||
:return: True if there is any data, or False otherwise.
|
||||
"""
|
||||
return len(self.accounts) > 0
|
||||
|
||||
@property
|
||||
def report_chooser(self) -> ReportChooser:
|
||||
"""Returns the report chooser.
|
||||
|
||||
:return: The report chooser.
|
||||
"""
|
||||
return ReportChooser(ReportType.TRIAL_BALANCE,
|
||||
currency=self.currency,
|
||||
period=self.period)
|
||||
|
||||
@property
|
||||
def currency_options(self) -> list[OptionLink]:
|
||||
"""Returns the currency options.
|
||||
|
||||
:return: The currency options.
|
||||
"""
|
||||
return self._get_currency_options(
|
||||
lambda x: trial_balance_url(x, self.period), self.currency)
|
||||
|
||||
|
||||
class TrialBalance(BaseReport):
|
||||
"""The trial balance."""
|
||||
|
||||
def __init__(self, currency: Currency, period: Period):
|
||||
"""Constructs a trial balance.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
"""
|
||||
self.__currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.__period: Period = period
|
||||
"""The period."""
|
||||
self.__accounts: list[ReportAccount]
|
||||
"""The accounts in the trial balance."""
|
||||
self.__total: Total
|
||||
"""The total of the trial balance."""
|
||||
self.__set_data()
|
||||
|
||||
def __set_data(self) -> None:
|
||||
"""Queries and sets data sections in the trial balance.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
= [JournalEntry.currency_code == self.__currency.code]
|
||||
if self.__period.start is not None:
|
||||
conditions.append(Transaction.date >= self.__period.start)
|
||||
if self.__period.end is not None:
|
||||
conditions.append(Transaction.date <= self.__period.end)
|
||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||
(JournalEntry.is_debit, JournalEntry.amount),
|
||||
else_=-JournalEntry.amount)).label("balance")
|
||||
select_balances: sa.Select = sa.select(Account.id, balance_func)\
|
||||
.join(Transaction).join(Account)\
|
||||
.filter(*conditions)\
|
||||
.group_by(Account.id)\
|
||||
.order_by(Account.base_code, Account.no)
|
||||
balances: list[sa.Row] = db.session.execute(select_balances).all()
|
||||
accounts: dict[int, Account] \
|
||||
= {x.id: x for x in Account.query
|
||||
.filter(Account.id.in_([x.id for x in balances])).all()}
|
||||
self.__accounts = [ReportAccount(account=accounts[x.id],
|
||||
amount=x.balance,
|
||||
url=ledger_url(self.__currency,
|
||||
accounts[x.id],
|
||||
self.__period))
|
||||
for x in balances]
|
||||
self.__total = Total(
|
||||
sum([x.debit for x in self.__accounts if x.debit is not None]),
|
||||
sum([x.credit for x in self.__accounts if x.credit is not None]))
|
||||
|
||||
def csv(self) -> Response:
|
||||
"""Returns the report as CSV for download.
|
||||
|
||||
:return: The response of the report for download.
|
||||
"""
|
||||
filename: str = "trial-balance-{currency}-{period}.csv"\
|
||||
.format(currency=self.__currency.code,
|
||||
period=period_spec(self.__period))
|
||||
return csv_download(filename, self.__get_csv_rows())
|
||||
|
||||
def __get_csv_rows(self) -> list[CSVRow]:
|
||||
"""Composes and returns the CSV rows.
|
||||
|
||||
:return: The CSV rows.
|
||||
"""
|
||||
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Debit"),
|
||||
gettext("Credit"))]
|
||||
rows.extend([CSVRow(str(x.account).title(), x.debit, x.credit)
|
||||
for x in self.__accounts])
|
||||
rows.append(CSVRow(gettext("Total"), self.__total.debit,
|
||||
self.__total.credit))
|
||||
return rows
|
||||
|
||||
def html(self) -> str:
|
||||
"""Composes and returns the report as HTML.
|
||||
|
||||
:return: The report as HTML.
|
||||
"""
|
||||
params: PageParams = PageParams(currency=self.__currency,
|
||||
period=self.__period,
|
||||
accounts=self.__accounts,
|
||||
total=self.__total)
|
||||
return render_template("accounting/report/trial-balance.html",
|
||||
report=params)
|
37
src/accounting/report/template_filters.py
Normal file
37
src/accounting/report/template_filters.py
Normal file
@ -0,0 +1,37 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The template filters for the reports.
|
||||
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
from accounting.template_filters import format_amount as core_format_amount
|
||||
|
||||
|
||||
def format_amount(value: Decimal | None) -> str | None:
|
||||
"""Formats an amount for the report.
|
||||
|
||||
:param value: The amount.
|
||||
:return: The formatted amount text.
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
is_negative: bool = value < 0
|
||||
formatted: str = core_format_amount(abs(value))
|
||||
if is_negative:
|
||||
formatted = f"({formatted})"
|
||||
return formatted
|
19
src/accounting/report/utils/__init__.py
Normal file
19
src/accounting/report/utils/__init__.py
Normal file
@ -0,0 +1,19 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The utilities for the reports.
|
||||
|
||||
"""
|
88
src/accounting/report/utils/base_page_params.py
Normal file
88
src/accounting/report/utils/base_page_params.py
Normal file
@ -0,0 +1,88 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/6
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The page parameters of a report.
|
||||
|
||||
"""
|
||||
import typing as t
|
||||
from abc import ABC, abstractmethod
|
||||
from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \
|
||||
urlunparse
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import request
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import Currency, JournalEntry
|
||||
from accounting.utils.txn_types import TransactionType
|
||||
from .option_link import OptionLink
|
||||
from .report_chooser import ReportChooser
|
||||
|
||||
|
||||
class BasePageParams(ABC):
|
||||
"""The base HTML page parameters class."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def has_data(self) -> bool:
|
||||
"""Returns whether there is any data on the page.
|
||||
|
||||
:return: True if there is any data, or False otherwise.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def report_chooser(self) -> ReportChooser:
|
||||
"""Returns the report chooser.
|
||||
|
||||
:return: The report chooser.
|
||||
"""
|
||||
|
||||
@property
|
||||
def txn_types(self) -> t.Type[TransactionType]:
|
||||
"""Returns the transaction types.
|
||||
|
||||
:return: The transaction types.
|
||||
"""
|
||||
return TransactionType
|
||||
|
||||
@property
|
||||
def csv_uri(self) -> str:
|
||||
uri: str = request.full_path if request.query_string \
|
||||
else request.path
|
||||
uri_p: ParseResult = urlparse(uri)
|
||||
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
|
||||
params = [x for x in params if x[0] != "as"]
|
||||
params.append(("as", "csv"))
|
||||
parts: list[str] = list(uri_p)
|
||||
parts[4] = urlencode(params)
|
||||
return urlunparse(parts)
|
||||
|
||||
@staticmethod
|
||||
def _get_currency_options(get_url: t.Callable[[Currency], str],
|
||||
active_currency: Currency) -> list[OptionLink]:
|
||||
"""Returns the currency options.
|
||||
|
||||
:param get_url: The callback to return the URL of a currency.
|
||||
:param active_currency: The active currency.
|
||||
:return: The currency options.
|
||||
"""
|
||||
in_use: set[str] = set(db.session.scalars(
|
||||
sa.select(JournalEntry.currency_code)
|
||||
.group_by(JournalEntry.currency_code)).all())
|
||||
return [OptionLink(str(x), get_url(x), x.code == active_currency.code)
|
||||
for x in Currency.query.filter(Currency.code.in_(in_use))
|
||||
.order_by(Currency.code).all()]
|
40
src/accounting/report/utils/base_report.py
Normal file
40
src/accounting/report/utils/base_report.py
Normal file
@ -0,0 +1,40 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The base report.
|
||||
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from flask import Response
|
||||
|
||||
|
||||
class BaseReport(ABC):
|
||||
"""The base report class."""
|
||||
|
||||
@abstractmethod
|
||||
def csv(self) -> Response:
|
||||
"""Returns the report as CSV for download.
|
||||
|
||||
:return: The response of the report for download.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def html(self) -> str:
|
||||
"""Composes and returns the report as HTML.
|
||||
|
||||
:return: The report as HTML.
|
||||
"""
|
108
src/accounting/report/utils/csv_export.py
Normal file
108
src/accounting/report/utils/csv_export.py
Normal file
@ -0,0 +1,108 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The utilities to export the report as CSV for download.
|
||||
|
||||
"""
|
||||
import csv
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import timedelta, date
|
||||
from decimal import Decimal
|
||||
from io import StringIO
|
||||
|
||||
from flask import Response
|
||||
|
||||
from accounting.report.period import Period
|
||||
|
||||
|
||||
class BaseCSVRow(ABC):
|
||||
"""The base CSV row."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def values(self) -> list[str | Decimal | None]:
|
||||
"""Returns the values of the row.
|
||||
|
||||
:return: The values of the row.
|
||||
"""
|
||||
|
||||
|
||||
def csv_download(filename: str, rows: list[BaseCSVRow]) -> Response:
|
||||
"""Exports the data rows as a CSV file for download.
|
||||
|
||||
:param filename: The download file name.
|
||||
:param rows: The data rows.
|
||||
:return: The response for download the CSV file.
|
||||
"""
|
||||
with StringIO() as fp:
|
||||
writer = csv.writer(fp)
|
||||
writer.writerows([x.values for x in rows])
|
||||
fp.seek(0)
|
||||
response: Response = Response(fp.read(), mimetype="text/csv")
|
||||
response.headers["Content-Disposition"] \
|
||||
= f"attachment; filename={filename}"
|
||||
return response
|
||||
|
||||
|
||||
def period_spec(period: Period) -> str:
|
||||
"""Constructs the period specification to be used in the filename.
|
||||
|
||||
:param period: The period.
|
||||
:return: The period specification to be used in the filename.
|
||||
"""
|
||||
start: str | None = __get_start_str(period.start)
|
||||
end: str | None = __get_end_str(period.end)
|
||||
if period.start is None and period.end is None:
|
||||
return "all-time"
|
||||
if start == end:
|
||||
return start
|
||||
if period.start is None:
|
||||
return f"until-{end}"
|
||||
if period.end is None:
|
||||
return f"since-{start}"
|
||||
return f"{start}-{end}"
|
||||
|
||||
|
||||
def __get_start_str(start: date | None) -> str | None:
|
||||
"""Returns the string representation of the start date.
|
||||
|
||||
:param start: The start date.
|
||||
:return: The string representation of the start date, or None if the start
|
||||
date is None.
|
||||
"""
|
||||
if start is None:
|
||||
return None
|
||||
if start.month == 1 and start.day == 1:
|
||||
return str(start.year)
|
||||
if start.day == 1:
|
||||
return start.strftime("%Y%m")
|
||||
return start.strftime("%Y%m%d")
|
||||
|
||||
|
||||
def __get_end_str(end: date | None) -> str | None:
|
||||
"""Returns the string representation of the end date.
|
||||
|
||||
:param end: The end date.
|
||||
:return: The string representation of the end date, or None if the end
|
||||
date is None.
|
||||
"""
|
||||
if end is None:
|
||||
return None
|
||||
if end.month == 12 and end.day == 31:
|
||||
return str(end.year)
|
||||
if (end + timedelta(days=1)).day == 1:
|
||||
return end.strftime("%Y%m")
|
||||
return end.strftime("%Y%m%d")
|
86
src/accounting/report/utils/ie_account.py
Normal file
86
src/accounting/report/utils/ie_account.py
Normal file
@ -0,0 +1,86 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The pseudo account for the income and expenses log.
|
||||
|
||||
"""
|
||||
import typing as t
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Account
|
||||
|
||||
|
||||
class IncomeExpensesAccount:
|
||||
"""The pseudo account for the income and expenses log."""
|
||||
CURRENT_AL_CODE: str = "0000-000"
|
||||
"""The account code for the current assets and liabilities."""
|
||||
|
||||
def __init__(self, account: Account | None = None):
|
||||
"""Constructs the pseudo account for the income and expenses log.
|
||||
|
||||
:param account: The actual account.
|
||||
"""
|
||||
self.account: Account | None = account
|
||||
self.id: int = -1 if account is None else account.id
|
||||
"""The ID."""
|
||||
self.code: str = "" if account is None else account.code
|
||||
"""The code."""
|
||||
self.title: str = "" if account is None else account.title
|
||||
"""The title."""
|
||||
self.str: str = "" if account is None else str(account)
|
||||
"""The string representation of the account."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Returns the string representation of the account.
|
||||
|
||||
:return: The string representation of the account.
|
||||
"""
|
||||
return self.str
|
||||
|
||||
@classmethod
|
||||
def current_assets_and_liabilities(cls) -> t.Self:
|
||||
"""Returns the pseudo account for current assets and liabilities.
|
||||
|
||||
:return: The pseudo account for current assets and liabilities.
|
||||
"""
|
||||
account: cls = cls()
|
||||
account.id = 0
|
||||
account.code = cls.CURRENT_AL_CODE
|
||||
account.title = gettext("current assets and liabilities")
|
||||
account.str = account.title
|
||||
return account
|
||||
|
||||
|
||||
def default_ie_account_code() -> str:
|
||||
"""Returns the default account code for the income and expenses log.
|
||||
|
||||
:return: The default account code for the income and expenses log.
|
||||
"""
|
||||
with current_app.app_context():
|
||||
return current_app.config.get("DEFAULT_IE_ACCOUNT", Account.CASH_CODE)
|
||||
|
||||
|
||||
def default_ie_account() -> IncomeExpensesAccount:
|
||||
"""Returns the default account for the income and expenses log.
|
||||
|
||||
:return: The default account for the income and expenses log.
|
||||
"""
|
||||
code: str = default_ie_account_code()
|
||||
if code == IncomeExpensesAccount.CURRENT_AL_CODE:
|
||||
return IncomeExpensesAccount.current_assets_and_liabilities()
|
||||
return IncomeExpensesAccount(Account.find_by_code(code))
|
36
src/accounting/report/utils/option_link.py
Normal file
36
src/accounting/report/utils/option_link.py
Normal file
@ -0,0 +1,36 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/5
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The option link.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
class OptionLink:
|
||||
"""An option link."""
|
||||
|
||||
def __init__(self, title: str, url: str, is_active: bool,
|
||||
fa_icon: str | None = None):
|
||||
"""Constructs an option link.
|
||||
|
||||
:param title: The title.
|
||||
:param url: The URI.
|
||||
:param is_active: True if active, or False otherwise
|
||||
"""
|
||||
self.title: str = title
|
||||
self.url: str = url
|
||||
self.is_active: bool = is_active
|
||||
self.fa_icon: str | None = fa_icon
|
159
src/accounting/report/utils/report_chooser.py
Normal file
159
src/accounting/report/utils/report_chooser.py
Normal file
@ -0,0 +1,159 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The report chooser.
|
||||
|
||||
This file is largely taken from the NanoParma ERP project, first written in
|
||||
2021/9/16 by imacat (imacat@nanoparma.com).
|
||||
|
||||
"""
|
||||
import re
|
||||
import typing as t
|
||||
|
||||
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 .ie_account import IncomeExpensesAccount
|
||||
from .option_link import OptionLink
|
||||
from .report_type import ReportType
|
||||
from .urls import journal_url, ledger_url, income_expenses_url, \
|
||||
trial_balance_url, income_statement_url, balance_sheet_url
|
||||
|
||||
|
||||
class ReportChooser:
|
||||
"""The report chooser."""
|
||||
|
||||
def __init__(self, active_report: ReportType,
|
||||
period: Period | None = None,
|
||||
currency: Currency | None = None,
|
||||
account: Account | None = None):
|
||||
"""Constructs the report chooser.
|
||||
|
||||
:param active_report: The active report.
|
||||
:param period: The period.
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
"""
|
||||
self.__active_report: ReportType = active_report
|
||||
"""The currently active report."""
|
||||
self.__period: Period = get_period() if period is None else period
|
||||
"""The period."""
|
||||
self.__currency: Currency = db.session.get(
|
||||
Currency, default_currency_code()) \
|
||||
if currency is None else currency
|
||||
"""The currency."""
|
||||
self.__account: Account = Account.cash() if account is None \
|
||||
else account
|
||||
"""The currency."""
|
||||
self.__reports: list[OptionLink] = []
|
||||
"""The links to the reports."""
|
||||
self.current_report: str | LazyString = ""
|
||||
"""The title of the current report."""
|
||||
self.is_search: bool = active_report == ReportType.SEARCH
|
||||
"""Whether the current report is the search page."""
|
||||
self.__reports.append(self.__journal)
|
||||
self.__reports.append(self.__ledger)
|
||||
self.__reports.append(self.__income_expenses)
|
||||
self.__reports.append(self.__trial_balance)
|
||||
self.__reports.append(self.__income_statement)
|
||||
self.__reports.append(self.__balance_sheet)
|
||||
for report in self.__reports:
|
||||
if report.is_active:
|
||||
self.current_report = report.title
|
||||
if self.is_search:
|
||||
self.current_report = gettext("Search")
|
||||
|
||||
@property
|
||||
def __journal(self) -> OptionLink:
|
||||
"""Returns the journal.
|
||||
|
||||
:return: The journal.
|
||||
"""
|
||||
return OptionLink(gettext("Journal"), journal_url(self.__period),
|
||||
self.__active_report == ReportType.JOURNAL,
|
||||
fa_icon="fa-solid fa-book")
|
||||
|
||||
@property
|
||||
def __ledger(self) -> OptionLink:
|
||||
"""Returns the ledger.
|
||||
|
||||
:return: The ledger.
|
||||
"""
|
||||
return OptionLink(gettext("Ledger"),
|
||||
ledger_url(self.__currency, self.__account,
|
||||
self.__period),
|
||||
self.__active_report == ReportType.LEDGER,
|
||||
fa_icon="fa-solid fa-clipboard")
|
||||
|
||||
@property
|
||||
def __income_expenses(self) -> OptionLink:
|
||||
"""Returns the income and expenses log.
|
||||
|
||||
:return: The income and expenses log.
|
||||
"""
|
||||
account: Account = self.__account
|
||||
if not re.match(r"[12][12]", account.base_code):
|
||||
account: Account = Account.cash()
|
||||
return OptionLink(gettext("Income and Expenses Log"),
|
||||
income_expenses_url(self.__currency,
|
||||
IncomeExpensesAccount(account),
|
||||
self.__period),
|
||||
self.__active_report == ReportType.INCOME_EXPENSES,
|
||||
fa_icon="fa-solid fa-money-bill-wave")
|
||||
|
||||
@property
|
||||
def __trial_balance(self) -> OptionLink:
|
||||
"""Returns the trial balance.
|
||||
|
||||
:return: The trial balance.
|
||||
"""
|
||||
return OptionLink(gettext("Trial Balance"),
|
||||
trial_balance_url(self.__currency, self.__period),
|
||||
self.__active_report == ReportType.TRIAL_BALANCE,
|
||||
fa_icon="fa-solid fa-scale-unbalanced")
|
||||
|
||||
@property
|
||||
def __income_statement(self) -> OptionLink:
|
||||
"""Returns the income statement.
|
||||
|
||||
:return: The income statement.
|
||||
"""
|
||||
return OptionLink(gettext("Income Statement"),
|
||||
income_statement_url(self.__currency, self.__period),
|
||||
self.__active_report == ReportType.INCOME_STATEMENT,
|
||||
fa_icon="fa-solid fa-file-invoice-dollar")
|
||||
|
||||
@property
|
||||
def __balance_sheet(self) -> OptionLink:
|
||||
"""Returns the balance sheet.
|
||||
|
||||
:return: The balance sheet.
|
||||
"""
|
||||
return OptionLink(gettext("Balance Sheet"),
|
||||
balance_sheet_url(self.__currency, self.__period),
|
||||
self.__active_report == ReportType.BALANCE_SHEET,
|
||||
fa_icon="fa-solid fa-scale-balanced")
|
||||
|
||||
def __iter__(self) -> t.Iterator[OptionLink]:
|
||||
"""Returns the iteration of the reports.
|
||||
|
||||
:return: The iteration of the reports.
|
||||
"""
|
||||
return iter(self.__reports)
|
38
src/accounting/report/utils/report_type.py
Normal file
38
src/accounting/report/utils/report_type.py
Normal file
@ -0,0 +1,38 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The report types.
|
||||
|
||||
"""
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ReportType(Enum):
|
||||
"""The report types."""
|
||||
JOURNAL: str = "journal"
|
||||
"""The journal."""
|
||||
LEDGER: str = "ledger"
|
||||
"""The ledger."""
|
||||
INCOME_EXPENSES: str = "income-expenses"
|
||||
"""The income and expenses log."""
|
||||
TRIAL_BALANCE: str = "trial-balance"
|
||||
"""The trial balance."""
|
||||
INCOME_STATEMENT: str = "income-statement"
|
||||
"""The income statement."""
|
||||
BALANCE_SHEET: str = "balance-sheet"
|
||||
"""The balance sheet."""
|
||||
SEARCH: str = "search"
|
||||
"""The search."""
|
117
src/accounting/report/utils/urls.py
Normal file
117
src/accounting/report/utils/urls.py
Normal file
@ -0,0 +1,117 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The utilities to get the ledger URL.
|
||||
|
||||
"""
|
||||
from flask import url_for
|
||||
|
||||
from accounting.models import Currency, Account
|
||||
from accounting.report.period import Period
|
||||
from accounting.template_globals import default_currency_code
|
||||
from .ie_account import IncomeExpensesAccount, default_ie_account_code
|
||||
|
||||
|
||||
def journal_url(period: Period) \
|
||||
-> str:
|
||||
"""Returns the URL of a journal.
|
||||
|
||||
:param period: The period.
|
||||
:return: The URL of the journal.
|
||||
"""
|
||||
if period.is_default:
|
||||
return url_for("accounting.report.journal-default")
|
||||
return url_for("accounting.report.journal", period=period)
|
||||
|
||||
|
||||
def ledger_url(currency: Currency, account: Account, period: Period) \
|
||||
-> str:
|
||||
"""Returns the URL of a ledger.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:param period: The period.
|
||||
:return: The URL of the ledger.
|
||||
"""
|
||||
if period.is_default:
|
||||
return url_for("accounting.report.ledger-default",
|
||||
currency=currency, account=account)
|
||||
return url_for("accounting.report.ledger",
|
||||
currency=currency, account=account,
|
||||
period=period)
|
||||
|
||||
|
||||
def income_expenses_url(currency: Currency, account: IncomeExpensesAccount,
|
||||
period: Period) -> str:
|
||||
"""Returns the URL of an income and expenses log.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:param period: The period.
|
||||
:return: The URL of the income and expenses log.
|
||||
"""
|
||||
if currency.code == default_currency_code() \
|
||||
and account.code == default_ie_account_code() \
|
||||
and period.is_default:
|
||||
return url_for("accounting.report.default")
|
||||
if period.is_default:
|
||||
return url_for("accounting.report.income-expenses-default",
|
||||
currency=currency, account=account)
|
||||
return url_for("accounting.report.income-expenses",
|
||||
currency=currency, account=account,
|
||||
period=period)
|
||||
|
||||
|
||||
def trial_balance_url(currency: Currency, period: Period) -> str:
|
||||
"""Returns the URL of a trial balance.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
:return: The URL of the trial balance.
|
||||
"""
|
||||
if period.is_default:
|
||||
return url_for("accounting.report.trial-balance-default",
|
||||
currency=currency)
|
||||
return url_for("accounting.report.trial-balance",
|
||||
currency=currency, period=period)
|
||||
|
||||
|
||||
def income_statement_url(currency: Currency, period: Period) -> str:
|
||||
"""Returns the URL of an income statement.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
:return: The URL of the income statement.
|
||||
"""
|
||||
if period.is_default:
|
||||
return url_for("accounting.report.income-statement-default",
|
||||
currency=currency)
|
||||
return url_for("accounting.report.income-statement",
|
||||
currency=currency, period=period)
|
||||
|
||||
|
||||
def balance_sheet_url(currency: Currency, period: Period) -> str:
|
||||
"""Returns the URL of a balance sheet.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
:return: The URL of the balance sheet.
|
||||
"""
|
||||
if period.is_default:
|
||||
return url_for("accounting.report.balance-sheet-default",
|
||||
currency=currency)
|
||||
return url_for("accounting.report.balance-sheet",
|
||||
currency=currency, period=period)
|
299
src/accounting/report/views.py
Normal file
299
src/accounting/report/views.py
Normal file
@ -0,0 +1,299 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The views for the report management.
|
||||
|
||||
"""
|
||||
from flask import Blueprint, request, Response
|
||||
|
||||
from accounting import db
|
||||
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.permission import has_permission, can_view
|
||||
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
|
||||
IncomeStatement, BalanceSheet, Search
|
||||
from .template_filters import format_amount
|
||||
from .utils.ie_account import IncomeExpensesAccount, default_ie_account
|
||||
|
||||
bp: Blueprint = Blueprint("report", __name__)
|
||||
"""The view blueprint for the reports."""
|
||||
bp.add_app_template_filter(format_amount, "accounting_report_format_amount")
|
||||
|
||||
|
||||
@bp.get("", endpoint="default")
|
||||
@has_permission(can_view)
|
||||
def get_default_report() -> str | Response:
|
||||
"""Returns the income and expenses log in the default period.
|
||||
|
||||
:return: The income and expenses log in the default period.
|
||||
"""
|
||||
return __get_income_expenses(
|
||||
db.session.get(Currency, default_currency_code()),
|
||||
default_ie_account(),
|
||||
get_period())
|
||||
|
||||
|
||||
@bp.get("journal", endpoint="journal-default")
|
||||
@has_permission(can_view)
|
||||
def get_default_journal() -> str | Response:
|
||||
"""Returns the journal in the default period.
|
||||
|
||||
:return: The journal in the default period.
|
||||
"""
|
||||
return __get_journal(get_period())
|
||||
|
||||
|
||||
@bp.get("journal/<period:period>", endpoint="journal")
|
||||
@has_permission(can_view)
|
||||
def get_journal(period: Period) -> str | Response:
|
||||
"""Returns the journal.
|
||||
|
||||
:param period: The period.
|
||||
:return: The journal in the period.
|
||||
"""
|
||||
return __get_journal(period)
|
||||
|
||||
|
||||
def __get_journal(period: Period) -> str | Response:
|
||||
"""Returns the journal.
|
||||
|
||||
:param period: The period.
|
||||
:return: The journal in the period.
|
||||
"""
|
||||
report: Journal = Journal(period)
|
||||
if "as" in request.args and request.args["as"] == "csv":
|
||||
return report.csv()
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.get("ledger/<currency:currency>/<account:account>",
|
||||
endpoint="ledger-default")
|
||||
@has_permission(can_view)
|
||||
def get_default_ledger(currency: Currency, account: Account) -> str | Response:
|
||||
"""Returns the ledger in the default period.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:return: The ledger in the default period.
|
||||
"""
|
||||
return __get_ledger(currency, account, get_period())
|
||||
|
||||
|
||||
@bp.get("ledger/<currency:currency>/<account:account>/<period:period>",
|
||||
endpoint="ledger")
|
||||
@has_permission(can_view)
|
||||
def get_ledger(currency: Currency, account: Account, period: Period) \
|
||||
-> str | Response:
|
||||
"""Returns the ledger.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:param period: The period.
|
||||
:return: The ledger in the period.
|
||||
"""
|
||||
return __get_ledger(currency, account, period)
|
||||
|
||||
|
||||
def __get_ledger(currency: Currency, account: Account, period: Period) \
|
||||
-> str | Response:
|
||||
"""Returns the ledger.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:param period: The period.
|
||||
:return: The ledger in the period.
|
||||
"""
|
||||
report: Ledger = Ledger(currency, account, period)
|
||||
if "as" in request.args and request.args["as"] == "csv":
|
||||
return report.csv()
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.get("income-expenses/<currency:currency>/<ieAccount:account>",
|
||||
endpoint="income-expenses-default")
|
||||
@has_permission(can_view)
|
||||
def get_default_income_expenses(currency: Currency,
|
||||
account: IncomeExpensesAccount) \
|
||||
-> str | Response:
|
||||
"""Returns the income and expenses log in the default period.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:return: The income and expenses log in the default period.
|
||||
"""
|
||||
return __get_income_expenses(currency, account, get_period())
|
||||
|
||||
|
||||
@bp.get(
|
||||
"income-expenses/<currency:currency>/<ieAccount:account>/<period:period>",
|
||||
endpoint="income-expenses")
|
||||
@has_permission(can_view)
|
||||
def get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
|
||||
period: Period) -> str | Response:
|
||||
"""Returns the income and expenses log.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:param period: The period.
|
||||
:return: The income and expenses log in the period.
|
||||
"""
|
||||
return __get_income_expenses(currency, account, period)
|
||||
|
||||
|
||||
def __get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
|
||||
period: Period) -> str | Response:
|
||||
"""Returns the income and expenses log.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:param period: The period.
|
||||
:return: The income and expenses log in the period.
|
||||
"""
|
||||
report: IncomeExpenses = IncomeExpenses(currency, account, period)
|
||||
if "as" in request.args and request.args["as"] == "csv":
|
||||
return report.csv()
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.get("trial-balance/<currency:currency>",
|
||||
endpoint="trial-balance-default")
|
||||
@has_permission(can_view)
|
||||
def get_default_trial_balance(currency: Currency) -> str | Response:
|
||||
"""Returns the trial balance in the default period.
|
||||
|
||||
:param currency: The currency.
|
||||
:return: The trial balance in the default period.
|
||||
"""
|
||||
return __get_trial_balance(currency, get_period())
|
||||
|
||||
|
||||
@bp.get("trial-balance/<currency:currency>/<period:period>",
|
||||
endpoint="trial-balance")
|
||||
@has_permission(can_view)
|
||||
def get_trial_balance(currency: Currency, period: Period) -> str | Response:
|
||||
"""Returns the trial balance.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
:return: The trial balance in the period.
|
||||
"""
|
||||
return __get_trial_balance(currency, period)
|
||||
|
||||
|
||||
def __get_trial_balance(currency: Currency, period: Period) -> str | Response:
|
||||
"""Returns the trial balance.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
:return: The trial balance in the period.
|
||||
"""
|
||||
report: TrialBalance = TrialBalance(currency, period)
|
||||
if "as" in request.args and request.args["as"] == "csv":
|
||||
return report.csv()
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.get("income-statement/<currency:currency>",
|
||||
endpoint="income-statement-default")
|
||||
@has_permission(can_view)
|
||||
def get_default_income_statement(currency: Currency) -> str | Response:
|
||||
"""Returns the income statement in the default period.
|
||||
|
||||
:param currency: The currency.
|
||||
:return: The income statement in the default period.
|
||||
"""
|
||||
return __get_income_statement(currency, get_period())
|
||||
|
||||
|
||||
@bp.get("income-statement/<currency:currency>/<period:period>",
|
||||
endpoint="income-statement")
|
||||
@has_permission(can_view)
|
||||
def get_income_statement(currency: Currency, period: Period) -> str | Response:
|
||||
"""Returns the income statement.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
:return: The income statement in the period.
|
||||
"""
|
||||
return __get_income_statement(currency, period)
|
||||
|
||||
|
||||
def __get_income_statement(currency: Currency, period: Period) \
|
||||
-> str | Response:
|
||||
"""Returns the income statement.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
:return: The income statement in the period.
|
||||
"""
|
||||
report: IncomeStatement = IncomeStatement(currency, period)
|
||||
if "as" in request.args and request.args["as"] == "csv":
|
||||
return report.csv()
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.get("balance-sheet/<currency:currency>",
|
||||
endpoint="balance-sheet-default")
|
||||
@has_permission(can_view)
|
||||
def get_default_balance_sheet(currency: Currency) -> str | Response:
|
||||
"""Returns the balance sheet in the default period.
|
||||
|
||||
:param currency: The currency.
|
||||
:return: The balance sheet in the default period.
|
||||
"""
|
||||
return __get_balance_sheet(currency, get_period())
|
||||
|
||||
|
||||
@bp.get("balance-sheet/<currency:currency>/<period:period>",
|
||||
endpoint="balance-sheet")
|
||||
@has_permission(can_view)
|
||||
def get_balance_sheet(currency: Currency, period: Period) \
|
||||
-> str | Response:
|
||||
"""Returns the balance sheet.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
:return: The balance sheet in the period.
|
||||
"""
|
||||
return __get_balance_sheet(currency, period)
|
||||
|
||||
|
||||
def __get_balance_sheet(currency: Currency, period: Period) \
|
||||
-> str | Response:
|
||||
"""Returns the balance sheet.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
:return: The balance sheet in the period.
|
||||
"""
|
||||
report: BalanceSheet = BalanceSheet(currency, period)
|
||||
if "as" in request.args and request.args["as"] == "csv":
|
||||
return report.csv()
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.get("search", endpoint="search")
|
||||
@has_permission(can_view)
|
||||
def search() -> str | Response:
|
||||
"""Returns the search result.
|
||||
|
||||
:return: The search result.
|
||||
"""
|
||||
report: Search = Search()
|
||||
if "as" in request.args and request.args["as"] == "csv":
|
||||
return report.csv()
|
||||
return report.html()
|
@ -24,16 +24,6 @@
|
||||
.accounting-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-group .btn .accounting-search-input {
|
||||
min-height: calc(1em + .5rem + 2px);
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
.btn-group .btn .accounting-search-label button {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
padding-right: 0;
|
||||
}
|
||||
.form-floating > textarea.form-control {
|
||||
height: 6rem;
|
||||
}
|
||||
@ -42,6 +32,61 @@
|
||||
background-color: #D3D3D4;
|
||||
}
|
||||
|
||||
/** The toolbar */
|
||||
.accounting-toolbar {
|
||||
display: flex;
|
||||
}
|
||||
.accounting-toolbar .input-group > .input-group-text {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
border: 0;
|
||||
}
|
||||
.accounting-toolbar .input-group > .input-group-text > button {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
border: 0;
|
||||
}
|
||||
.accounting-toolbar form.btn > .form-control {
|
||||
min-height: calc(1.5em + 2px);
|
||||
padding-top: 0.1rem;
|
||||
padding-bottom: 0.1rem;
|
||||
}
|
||||
@media(min-width: 768px) {
|
||||
.accounting-toolbar > .btn, .accounting-toolbar > .btn-group > .btn {
|
||||
border-radius: 0;
|
||||
}
|
||||
.accounting-toolbar > .btn:first-child, .accounting-toolbar > .btn-group:first-child > .btn {
|
||||
border-top-left-radius: 0.375rem;
|
||||
border-bottom-left-radius: 0.375rem;
|
||||
}
|
||||
.accounting-toolbar > .btn:last-child, .accounting-toolbar > .btn-group:last-child > .btn {
|
||||
border-top-right-radius: 0.375rem;
|
||||
border-bottom-right-radius: 0.375rem;
|
||||
}
|
||||
.accounting-toolbar .btn.input-group {
|
||||
width: 16rem;
|
||||
}
|
||||
}
|
||||
@media(max-width:767px) {
|
||||
.accounting-toolbar > .btn:not(form), .accounting-toolbar > .btn-group > .btn {
|
||||
height: 3.2rem;
|
||||
width: 3.2rem;
|
||||
border-radius: 50%;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
.accounting-toolbar > a.btn, .accounting-toolbar > .btn-group > a.btn {
|
||||
padding-top: 0.7rem;
|
||||
}
|
||||
.accounting-toolbar > form.btn {
|
||||
width: 12rem;
|
||||
height: 2.6rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-top: 0.3rem;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/** The card layout */
|
||||
.accounting-card {
|
||||
padding: 2em 1.5em;
|
||||
@ -58,6 +103,15 @@
|
||||
font-size: 1.4rem;
|
||||
color: #373b3e;
|
||||
}
|
||||
.accounting-sheet {
|
||||
padding: 2em 1.5em;
|
||||
margin: 1em;
|
||||
background-color: #F8F9FA;
|
||||
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
||||
}
|
||||
.accounting-sheet h2 {
|
||||
border-bottom: thick double slategray;
|
||||
}
|
||||
|
||||
/** The option selector */
|
||||
.accounting-selector-list {
|
||||
@ -79,15 +133,6 @@
|
||||
.accounting-entry-control {
|
||||
border-color: transparent;
|
||||
}
|
||||
.accounting-transaction-card {
|
||||
padding: 2em 1.5em;
|
||||
margin: 1em;
|
||||
background-color: #F8F9FA;
|
||||
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
||||
}
|
||||
.accounting-transaction-card h2 {
|
||||
border-bottom: thick double slategray;
|
||||
}
|
||||
.accounting-list-group-stripped .list-group-item:nth-child(2n+1) {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
@ -109,6 +154,134 @@
|
||||
border-top: thick double slategray;
|
||||
}
|
||||
|
||||
/* The report table */
|
||||
.accounting-report-table-header, .accounting-report-table-footer {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bolder;
|
||||
}
|
||||
.accounting-report-table-header {
|
||||
border-bottom: thin solid slategray;
|
||||
}
|
||||
.accounting-report-table-footer {
|
||||
font-style: italic;
|
||||
border-top: thin solid slategray;
|
||||
}
|
||||
.accounting-report-table-row {
|
||||
display: grid;
|
||||
}
|
||||
a.accounting-report-table-row {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.accounting-report-table-row > div {
|
||||
padding: .5rem;
|
||||
}
|
||||
.accounting-report-table .accounting-amount {
|
||||
text-align: right;
|
||||
}
|
||||
.accounting-report-table-body .accounting-amount {
|
||||
font-style: italic;
|
||||
}
|
||||
.accounting-report-table-body .accounting-report-table-row:nth-child(2n+1) {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
.accounting-report-table-body .accounting-report-table-row:hover {
|
||||
background-color: rgba(0, 0, 0, 0.075);
|
||||
}
|
||||
.accounting-journal-table .accounting-report-table-row {
|
||||
grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr;
|
||||
}
|
||||
.accounting-ledger-table .accounting-report-table-row {
|
||||
grid-template-columns: 1fr 4fr 1fr 1fr 1fr;
|
||||
}
|
||||
.accounting-ledger-table .accounting-report-table-footer .accounting-report-table-row {
|
||||
grid-template-columns: 5fr 1fr 1fr 1fr;
|
||||
}
|
||||
.accounting-income-expenses-table .accounting-report-table-row {
|
||||
grid-template-columns: 1fr 2fr 4fr 1fr 1fr 1fr;
|
||||
}
|
||||
.accounting-income-expenses-table .accounting-report-table-footer .accounting-report-table-row {
|
||||
grid-template-columns: 7fr 1fr 1fr 1fr;
|
||||
}
|
||||
.accounting-trial-balance-table .accounting-report-table-header {
|
||||
border-bottom: thick double slategray;
|
||||
}
|
||||
.accounting-trial-balance-table .accounting-report-table-footer {
|
||||
border-top: thick double slategray;
|
||||
}
|
||||
.accounting-trial-balance-table .accounting-report-table-row {
|
||||
grid-template-columns: 3fr 1fr 1fr;
|
||||
}
|
||||
.accounting-income-statement-table .accounting-report-table-body {
|
||||
border-top: thick double slategray;
|
||||
border-bottom: thick double slategray;
|
||||
}
|
||||
.accounting-income-statement-table .accounting-report-table-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.accounting-income-statement-table .accounting-report-table-header .accounting-report-table-row {
|
||||
display: block;
|
||||
}
|
||||
.accounting-income-statement-section, .accounting-income-statement-total {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bolder;
|
||||
}
|
||||
.accounting-income-statement-subsection, .accounting-income-statement-subtotal {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.accounting-income-statement-subtotal {
|
||||
border-top: thin solid darkslategray;
|
||||
}
|
||||
/* Indents */
|
||||
.accounting-income-statement-subsection {
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
.accounting-income-statement-account, .accounting-income-statement-subtotal {
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
/* A visual blank line between categories */
|
||||
.accounting-income-statement-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.accounting-income-statement-section:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.accounting-income-statement-total {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.accounting-income-statement-total:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.accounting-balance-sheet-section, .accounting-balance-sheet-total {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bolder;
|
||||
}
|
||||
.accounting-balance-sheet-section {
|
||||
border-bottom: thick double darkslategray;
|
||||
}
|
||||
.accounting-balance-sheet-total {
|
||||
border-top: thick double darkslategray;
|
||||
}
|
||||
.accounting-balance-sheet-subtotal {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bolder;
|
||||
border-top: thick double darkslategray;
|
||||
}
|
||||
.accounting-balance-sheet-account {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.accounting-balance-sheet-total .accounting-amount, .accounting-balance-sheet-subtotal, .accounting-amount {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* The accounting report */
|
||||
.accounting-mobile-journal-credit {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
/* The Material Design text field (floating form control in Bootstrap) */
|
||||
.accounting-material-text-field {
|
||||
position: relative;
|
||||
|
@ -20,9 +20,10 @@
|
||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
* First written: 2023/2/1
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initializeBaseAccountSelector();
|
||||
document.getElementById("accounting-base-code")
|
||||
.onchange = validateBase;
|
||||
@ -44,7 +45,7 @@ function initializeBaseAccountSelector() {
|
||||
const baseContent = document.getElementById("accounting-base-content");
|
||||
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
|
||||
const btnClear = document.getElementById("accounting-btn-clear-base");
|
||||
selector.addEventListener("show.bs.modal", function () {
|
||||
selector.addEventListener("show.bs.modal", () => {
|
||||
base.classList.add("accounting-not-empty");
|
||||
for (const option of options) {
|
||||
option.classList.remove("active");
|
||||
@ -54,13 +55,13 @@ function initializeBaseAccountSelector() {
|
||||
selected.classList.add("active");
|
||||
}
|
||||
});
|
||||
selector.addEventListener("hidden.bs.modal", function () {
|
||||
selector.addEventListener("hidden.bs.modal", () => {
|
||||
if (baseCode.value === "") {
|
||||
base.classList.remove("accounting-not-empty");
|
||||
}
|
||||
});
|
||||
for (const option of options) {
|
||||
option.onclick = function () {
|
||||
option.onclick = () => {
|
||||
baseCode.value = option.dataset.code;
|
||||
baseContent.innerText = option.dataset.content;
|
||||
btnClear.classList.add("btn-danger");
|
||||
@ -70,7 +71,7 @@ function initializeBaseAccountSelector() {
|
||||
bootstrap.Modal.getInstance(selector).hide();
|
||||
};
|
||||
}
|
||||
btnClear.onclick = function () {
|
||||
btnClear.onclick = () => {
|
||||
baseCode.value = "";
|
||||
baseContent.innerText = "";
|
||||
btnClear.classList.add("btn-secondary")
|
||||
@ -92,7 +93,7 @@ function initializeBaseAccountQuery() {
|
||||
const optionList = document.getElementById("accounting-base-option-list");
|
||||
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
|
||||
const queryNoResult = document.getElementById("accounting-base-option-no-result");
|
||||
query.addEventListener("input", function () {
|
||||
query.addEventListener("input", () => {
|
||||
if (query.value === "") {
|
||||
for (const option of options) {
|
||||
option.classList.remove("d-none");
|
||||
|
@ -20,12 +20,13 @@
|
||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
* First written: 2023/2/2
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const list = document.getElementById("accounting-order-list");
|
||||
if (list !== null) {
|
||||
const onReorder = function () {
|
||||
const onReorder = () => {
|
||||
const accounts = Array.from(list.children);
|
||||
for (let i = 0; i < accounts.length; i++) {
|
||||
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
|
||||
|
@ -20,9 +20,10 @@
|
||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
* First written: 2023/2/28
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
AccountSelector.initialize();
|
||||
});
|
||||
|
||||
@ -65,13 +66,12 @@ class AccountSelector {
|
||||
const more = document.getElementById(this.#prefix + "-more");
|
||||
const btnClear = document.getElementById(this.#prefix + "-btn-clear");
|
||||
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
|
||||
const selector1 = this
|
||||
more.onclick = function () {
|
||||
more.onclick = () => {
|
||||
more.classList.add("d-none");
|
||||
selector1.#filterAccountOptions();
|
||||
this.#filterAccountOptions();
|
||||
};
|
||||
this.#initializeAccountQuery();
|
||||
btnClear.onclick = function () {
|
||||
btnClear.onclick = () => {
|
||||
formAccountControl.classList.remove("accounting-not-empty");
|
||||
formAccount.innerText = "";
|
||||
formAccount.dataset.code = "";
|
||||
@ -79,7 +79,7 @@ class AccountSelector {
|
||||
validateJournalEntryAccount();
|
||||
};
|
||||
for (const option of options) {
|
||||
option.onclick = function () {
|
||||
option.onclick = () => {
|
||||
formAccountControl.classList.add("accounting-not-empty");
|
||||
formAccount.innerText = option.dataset.content;
|
||||
formAccount.dataset.code = option.dataset.code;
|
||||
@ -95,9 +95,8 @@ class AccountSelector {
|
||||
*/
|
||||
#initializeAccountQuery() {
|
||||
const query = document.getElementById(this.#prefix + "-query");
|
||||
const helper = this;
|
||||
query.addEventListener("input", function () {
|
||||
helper.#filterAccountOptions();
|
||||
query.addEventListener("input", () => {
|
||||
this.#filterAccountOptions();
|
||||
});
|
||||
}
|
||||
|
||||
@ -159,7 +158,7 @@ class AccountSelector {
|
||||
* @return {boolean} true if the account option should show, or false otherwise
|
||||
*/
|
||||
#shouldAccountOptionShow(option, more, inUse, query) {
|
||||
const isQueryMatched = function () {
|
||||
const isQueryMatched = () => {
|
||||
if (query.value === "") {
|
||||
return true;
|
||||
}
|
||||
@ -171,7 +170,7 @@ class AccountSelector {
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const isMoreMatched = function () {
|
||||
const isMoreMatched = () => {
|
||||
if (more.classList.contains("d-none")) {
|
||||
return true;
|
||||
}
|
||||
@ -237,10 +236,7 @@ class AccountSelector {
|
||||
static #initializeTransactionForm() {
|
||||
const entryForm = document.getElementById("accounting-entry-form");
|
||||
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
|
||||
const selectors = this.#selectors;
|
||||
formAccountControl.onclick = function () {
|
||||
selectors[entryForm.dataset.entryType].initShow();
|
||||
};
|
||||
formAccountControl.onclick = () => this.#selectors[entryForm.dataset.entryType].initShow();
|
||||
}
|
||||
/**
|
||||
* Initializes the account selector for the journal entry form.
|
||||
|
@ -20,9 +20,10 @@
|
||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
* First written: 2023/2/6
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.getElementById("accounting-code")
|
||||
.onchange = validateCode;
|
||||
document.getElementById("accounting-name")
|
||||
|
@ -20,6 +20,7 @@
|
||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
* First written: 2023/2/3
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Initializes the drag-and-drop reordering on a list.
|
||||
@ -44,15 +45,15 @@ function initializeMouseDragAndDropReordering(list, onReorder) {
|
||||
let dragged = null;
|
||||
for (const item of items) {
|
||||
item.draggable = true;
|
||||
item.addEventListener("dragstart", function () {
|
||||
item.addEventListener("dragstart", () => {
|
||||
dragged = item;
|
||||
dragged.classList.add("accounting-dragged");
|
||||
});
|
||||
item.addEventListener("dragover", function () {
|
||||
item.addEventListener("dragover", () => {
|
||||
onDragOver(dragged, item);
|
||||
onReorder();
|
||||
});
|
||||
item.addEventListener("dragend", function () {
|
||||
item.addEventListener("dragend", () => {
|
||||
dragged.classList.remove("accounting-dragged");
|
||||
dragged = null;
|
||||
});
|
||||
@ -69,16 +70,16 @@ function initializeMouseDragAndDropReordering(list, onReorder) {
|
||||
function initializeTouchDragAndDropReordering(list, onReorder) {
|
||||
const items = Array.from(list.children);
|
||||
for (const item of items) {
|
||||
item.addEventListener("touchstart", function () {
|
||||
item.addEventListener("touchstart", () => {
|
||||
item.classList.add("accounting-dragged");
|
||||
});
|
||||
item.addEventListener("touchmove", function (event) {
|
||||
item.addEventListener("touchmove", (event) => {
|
||||
const touch = event.targetTouches[0];
|
||||
const target = document.elementFromPoint(touch.pageX, touch.pageY);
|
||||
onDragOver(item, target);
|
||||
onReorder();
|
||||
});
|
||||
item.addEventListener("touchend", function () {
|
||||
item.addEventListener("touchend", () => {
|
||||
item.classList.remove("accounting-dragged");
|
||||
});
|
||||
}
|
||||
|
@ -20,9 +20,10 @@
|
||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
* First written: 2023/2/25
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initializeMaterialFabSpeedDial();
|
||||
});
|
||||
|
||||
@ -34,7 +35,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
function initializeMaterialFabSpeedDial() {
|
||||
const btnFab = document.getElementById("accounting-btn-material-fab-speed-dial");
|
||||
const fab = document.getElementById(btnFab.dataset.target);
|
||||
btnFab.onclick = function () {
|
||||
btnFab.onclick = () => {
|
||||
if (fab.classList.contains("show")) {
|
||||
fab.classList.remove("show");
|
||||
} else {
|
||||
|
426
src/accounting/static/js/period-chooser.js
Normal file
426
src/accounting/static/js/period-chooser.js
Normal file
@ -0,0 +1,426 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
* period-chooser.js: The JavaScript for the period chooser
|
||||
*/
|
||||
|
||||
/* Copyright (c) 2023 imacat.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
* First written: 2023/3/4
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
PeriodChooser.initialize();
|
||||
});
|
||||
|
||||
/**
|
||||
* The period chooser.
|
||||
*
|
||||
*/
|
||||
class PeriodChooser {
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* @type {string}
|
||||
*/
|
||||
prefix;
|
||||
|
||||
/**
|
||||
* The modal of the period chooser
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
modal;
|
||||
|
||||
/**
|
||||
* The tab planes
|
||||
* @type {{month: MonthTab, year: YearTab, day: DayTab, custom: CustomTab}}
|
||||
*/
|
||||
tabPlanes = {};
|
||||
|
||||
/**
|
||||
* Constructs the period chooser.
|
||||
*
|
||||
*/
|
||||
constructor() {
|
||||
this.prefix = "accounting-period-chooser";
|
||||
this.modal = document.getElementById(this.prefix + "-modal");
|
||||
for (const cls of [MonthTab, YearTab, DayTab, CustomTab]) {
|
||||
const tab = new cls(this);
|
||||
this.tabPlanes[tab.tabId()] = tab;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The period chooser.
|
||||
* @type {PeriodChooser}
|
||||
*/
|
||||
static #chooser;
|
||||
|
||||
/**
|
||||
* Initializes the period chooser.
|
||||
*
|
||||
*/
|
||||
static initialize() {
|
||||
this.#chooser = new PeriodChooser();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tab plane.
|
||||
*
|
||||
* @abstract
|
||||
* @private
|
||||
*/
|
||||
class TabPlane {
|
||||
|
||||
/**
|
||||
* The period chooser
|
||||
* @type {PeriodChooser}
|
||||
*/
|
||||
chooser;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* @type {string}
|
||||
*/
|
||||
prefix;
|
||||
|
||||
/**
|
||||
* The tab
|
||||
* @type {HTMLSpanElement}
|
||||
*/
|
||||
#tab;
|
||||
|
||||
/**
|
||||
* The page
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#page;
|
||||
|
||||
/**
|
||||
* Constructs a tab plane.
|
||||
*
|
||||
* @param chooser {PeriodChooser} the period chooser
|
||||
*/
|
||||
constructor(chooser) {
|
||||
this.chooser = chooser;
|
||||
this.prefix = "accounting-period-chooser-" + this.tabId();
|
||||
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.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class MonthTab extends TabPlane {
|
||||
|
||||
/**
|
||||
* The month chooser.
|
||||
* @type {tempusDominus.TempusDominus}
|
||||
*/
|
||||
#monthChooser
|
||||
|
||||
/**
|
||||
* Constructs a tab plane.
|
||||
*
|
||||
* @param chooser {PeriodChooser} the period chooser
|
||||
*/
|
||||
constructor(chooser) {
|
||||
super(chooser);
|
||||
const monthChooser = document.getElementById(this.prefix + "-chooser");
|
||||
if (monthChooser !== null) {
|
||||
let start = monthChooser.dataset.start;
|
||||
this.#monthChooser = new tempusDominus.TempusDominus(monthChooser, {
|
||||
restrictions: {
|
||||
minDate: start,
|
||||
},
|
||||
display: {
|
||||
inline: true,
|
||||
components: {
|
||||
date: false,
|
||||
clock: false,
|
||||
},
|
||||
},
|
||||
defaultDate: monthChooser.dataset.default,
|
||||
});
|
||||
monthChooser.addEventListener(tempusDominus.Namespace.events.change, (e) => {
|
||||
const date = e.detail.date;
|
||||
const year = date.year;
|
||||
const month = date.month + 1;
|
||||
const period = month < 10? year + "-0" + month: year + "-" + month;
|
||||
window.location = chooser.modal.dataset.urlTemplate
|
||||
.replaceAll("PERIOD", period);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab ID
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
tabId() {
|
||||
return "month";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The year tab plane.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class YearTab extends TabPlane {
|
||||
|
||||
/**
|
||||
* The tab ID
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
tabId() {
|
||||
return "year";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The day tab plane.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class DayTab extends TabPlane {
|
||||
|
||||
/**
|
||||
* The day input
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#date;
|
||||
|
||||
/**
|
||||
* The error of the date input
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#dateError;
|
||||
|
||||
/**
|
||||
* Constructs a tab plane.
|
||||
*
|
||||
* @param chooser {PeriodChooser} the period chooser
|
||||
*/
|
||||
constructor(chooser) {
|
||||
super(chooser);
|
||||
this.#date = document.getElementById(this.prefix + "-date");
|
||||
this.#dateError = document.getElementById(this.prefix + "-date-error");
|
||||
if (this.#date !== null) {
|
||||
this.#date.onchange = () => {
|
||||
if (this.#validateDate()) {
|
||||
window.location = chooser.modal.dataset.urlTemplate
|
||||
.replaceAll("PERIOD", this.#date.value);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the date.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validateDate() {
|
||||
if (this.#date.value === "") {
|
||||
this.#date.classList.add("is-invalid");
|
||||
this.#dateError.innerText = A_("Please fill in the date.");
|
||||
return false;
|
||||
}
|
||||
if (this.#date.value < this.#date.min) {
|
||||
this.#date.classList.add("is-invalid");
|
||||
this.#dateError.innerText = A_("The date is too early.");
|
||||
return false;
|
||||
}
|
||||
this.#date.classList.remove("is-invalid");
|
||||
this.#dateError.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab ID
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
tabId() {
|
||||
return "day";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The custom tab plane.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class CustomTab extends TabPlane {
|
||||
|
||||
/**
|
||||
* The start of the period
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#start;
|
||||
|
||||
/**
|
||||
* The error of the start
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#startError;
|
||||
|
||||
/**
|
||||
* The end of the period
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#end;
|
||||
|
||||
/**
|
||||
* The error of the end
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#endError;
|
||||
|
||||
/**
|
||||
* The confirm button
|
||||
* @type {HTMLButtonElement}
|
||||
*/
|
||||
#conform;
|
||||
|
||||
/**
|
||||
* Constructs a tab plane.
|
||||
*
|
||||
* @param chooser {PeriodChooser} the period chooser
|
||||
*/
|
||||
constructor(chooser) {
|
||||
super(chooser);
|
||||
this.#start = document.getElementById(this.prefix + "-start");
|
||||
this.#startError = document.getElementById(this.prefix + "-start-error");
|
||||
this.#end = document.getElementById(this.prefix + "-end");
|
||||
this.#endError = document.getElementById(this.prefix + "-end-error");
|
||||
this.#conform = document.getElementById(this.prefix + "-confirm");
|
||||
if (this.#start !== null) {
|
||||
this.#start.onchange = () => {
|
||||
if (this.#validateStart()) {
|
||||
this.#end.min = this.#start.value;
|
||||
}
|
||||
};
|
||||
this.#end.onchange = () => {
|
||||
if (this.#validateEnd()) {
|
||||
this.#start.max = this.#end.value;
|
||||
}
|
||||
};
|
||||
this.#conform.onclick = () => {
|
||||
let isValid = true;
|
||||
isValid = this.#validateStart() && isValid;
|
||||
isValid = this.#validateEnd() && isValid;
|
||||
if (isValid) {
|
||||
window.location = chooser.modal.dataset.urlTemplate
|
||||
.replaceAll("PERIOD", this.#start.value + "-" + this.#end.value);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the start of the period.
|
||||
*
|
||||
* @returns {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
#validateStart() {
|
||||
if (this.#start.value === "") {
|
||||
this.#start.classList.add("is-invalid");
|
||||
this.#startError.innerText = A_("Please fill in the start date.");
|
||||
return false;
|
||||
}
|
||||
if (this.#start.value < this.#start.min) {
|
||||
this.#start.classList.add("is-invalid");
|
||||
this.#startError.innerText = A_("The start date is too early.");
|
||||
return false;
|
||||
}
|
||||
if (this.#start.value > this.#start.max) {
|
||||
this.#start.classList.add("is-invalid");
|
||||
this.#startError.innerText = A_("The start date cannot be beyond the end date.");
|
||||
return false;
|
||||
}
|
||||
this.#start.classList.remove("is-invalid");
|
||||
this.#startError.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the end of the period.
|
||||
*
|
||||
* @returns {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
#validateEnd() {
|
||||
this.#end.value = this.#end.value.trim();
|
||||
if (this.#end.value === "") {
|
||||
this.#end.classList.add("is-invalid");
|
||||
this.#endError.innerText = A_("Please fill in the end date.");
|
||||
return false;
|
||||
}
|
||||
if (this.#end.value < this.#end.min) {
|
||||
this.#end.classList.add("is-invalid");
|
||||
this.#endError.innerText = A_("The end date cannot be beyond the start date.");
|
||||
return false;
|
||||
}
|
||||
this.#end.classList.remove("is-invalid");
|
||||
this.#endError.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab ID
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
tabId() {
|
||||
return "custom";
|
||||
}
|
||||
}
|
1199
src/accounting/static/js/summary-editor.js
Normal file
1199
src/accounting/static/js/summary-editor.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,827 +0,0 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
* summary-helper.js: The JavaScript for the summary helper
|
||||
*/
|
||||
|
||||
/* Copyright (c) 2023 imacat.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
* First written: 2023/2/28
|
||||
*/
|
||||
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
SummaryHelper.initialize();
|
||||
});
|
||||
|
||||
/**
|
||||
* A summary helper.
|
||||
*
|
||||
*/
|
||||
class SummaryHelper {
|
||||
|
||||
/**
|
||||
* The entry type, either "debit" or "credit"
|
||||
* @type {string}
|
||||
*/
|
||||
#entryType;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* @type {string}
|
||||
*/
|
||||
#prefix;
|
||||
|
||||
/**
|
||||
* The default tab ID
|
||||
* @type {string}
|
||||
*/
|
||||
#defaultTabId;
|
||||
|
||||
/**
|
||||
* Constructs a summary helper.
|
||||
*
|
||||
* @param form {HTMLFormElement} the summary helper form
|
||||
*/
|
||||
constructor(form) {
|
||||
this.#entryType = form.dataset.entryType;
|
||||
this.#prefix = "accounting-summary-helper-" + form.dataset.entryType;
|
||||
this.#defaultTabId = form.dataset.defaultTabId;
|
||||
this.#init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the summary helper.
|
||||
*
|
||||
*/
|
||||
#init() {
|
||||
const helper = this;
|
||||
const summary = document.getElementById(this.#prefix + "-summary");
|
||||
const tabs = Array.from(document.getElementsByClassName(this.#prefix + "-tab"));
|
||||
for (const tab of tabs) {
|
||||
tab.onclick = function () {
|
||||
helper.#switchToTab(tab.dataset.tabId);
|
||||
}
|
||||
}
|
||||
this.#initializeGeneralTagHelper();
|
||||
this.#initializeGeneralTripHelper();
|
||||
this.#initializeBusTripHelper();
|
||||
this.#initializeNumberHelper();
|
||||
this.#initializeSuggestedAccounts();
|
||||
this.#initializeSubmission();
|
||||
summary.onchange = function () {
|
||||
summary.value = summary.value.trim();
|
||||
helper.#parseAndPopulate();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to a tab.
|
||||
*
|
||||
* @param tabId {string} the tab ID.
|
||||
*/
|
||||
#switchToTab(tabId) {
|
||||
const tabs = Array.from(document.getElementsByClassName(this.#prefix + "-tab"));
|
||||
const pages = Array.from(document.getElementsByClassName(this.#prefix + "-page"));
|
||||
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-" + tabId + "-btn-tag"));
|
||||
for (const tab of tabs) {
|
||||
if (tab.dataset.tabId === tabId) {
|
||||
tab.classList.add("active");
|
||||
tab.ariaCurrent = "page";
|
||||
} else {
|
||||
tab.classList.remove("active");
|
||||
tab.ariaCurrent = "false";
|
||||
}
|
||||
}
|
||||
for (const page of pages) {
|
||||
if (page.dataset.tabId === tabId) {
|
||||
page.classList.remove("d-none");
|
||||
page.ariaCurrent = "page";
|
||||
} else {
|
||||
page.classList.add("d-none");
|
||||
page.ariaCurrent = "false";
|
||||
}
|
||||
}
|
||||
let selectedBtnTag = null;
|
||||
for (const tagButton of tagButtons) {
|
||||
if (tagButton.classList.contains("btn-primary")) {
|
||||
selectedBtnTag = tagButton;
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.#filterSuggestedAccounts(selectedBtnTag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the general tag helper.
|
||||
*
|
||||
*/
|
||||
#initializeGeneralTagHelper() {
|
||||
const summary = document.getElementById(this.#prefix + "-summary");
|
||||
const tag = document.getElementById(this.#prefix + "-general-tag");
|
||||
const helper = this;
|
||||
const updateSummary = function () {
|
||||
const pos = summary.value.indexOf("—");
|
||||
const prefix = tag.value === ""? "": tag.value + "—";
|
||||
if (pos === -1) {
|
||||
summary.value = prefix + summary.value;
|
||||
} else {
|
||||
summary.value = prefix + summary.value.substring(pos + 1);
|
||||
}
|
||||
}
|
||||
this.#initializeTagButtons("general", tag, updateSummary);
|
||||
tag.onchange = function () {
|
||||
helper.#onTagInputChange("general", tag, updateSummary);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the general trip helper.
|
||||
*
|
||||
*/
|
||||
#initializeGeneralTripHelper() {
|
||||
const summary = document.getElementById(this.#prefix + "-summary");
|
||||
const tag = document.getElementById(this.#prefix + "-travel-tag");
|
||||
const from = document.getElementById(this.#prefix + "-travel-from");
|
||||
const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction"))
|
||||
const to = document.getElementById(this.#prefix + "-travel-to");
|
||||
const helper = this;
|
||||
const updateSummary = function () {
|
||||
let direction;
|
||||
for (const directionButton of directionButtons) {
|
||||
if (directionButton.classList.contains("btn-primary")) {
|
||||
direction = directionButton.dataset.arrow;
|
||||
break;
|
||||
}
|
||||
}
|
||||
summary.value = tag.value + "—" + from.value + direction + to.value;
|
||||
};
|
||||
this.#initializeTagButtons("travel", tag, updateSummary);
|
||||
tag.onchange = function () {
|
||||
helper.#onTagInputChange("travel", tag, updateSummary);
|
||||
helper.#validateGeneralTripTag();
|
||||
};
|
||||
from.onchange = function () {
|
||||
updateSummary();
|
||||
helper.#validateGeneralTripFrom();
|
||||
};
|
||||
for (const directionButton of directionButtons) {
|
||||
directionButton.onclick = function () {
|
||||
for (const otherButton of directionButtons) {
|
||||
otherButton.classList.remove("btn-primary");
|
||||
otherButton.classList.add("btn-outline-primary");
|
||||
}
|
||||
directionButton.classList.remove("btn-outline-primary");
|
||||
directionButton.classList.add("btn-primary");
|
||||
updateSummary();
|
||||
};
|
||||
}
|
||||
to.onchange = function () {
|
||||
updateSummary();
|
||||
helper.#validateGeneralTripTo();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the bus trip helper.
|
||||
*
|
||||
*/
|
||||
#initializeBusTripHelper() {
|
||||
const summary = document.getElementById(this.#prefix + "-summary");
|
||||
const tag = document.getElementById(this.#prefix + "-bus-tag");
|
||||
const route = document.getElementById(this.#prefix + "-bus-route");
|
||||
const from = document.getElementById(this.#prefix + "-bus-from");
|
||||
const to = document.getElementById(this.#prefix + "-bus-to");
|
||||
const helper = this;
|
||||
const updateSummary = function () {
|
||||
summary.value = tag.value + "—" + route.value + "—" + from.value + "→" + to.value;
|
||||
};
|
||||
this.#initializeTagButtons("bus", tag, updateSummary);
|
||||
tag.onchange = function () {
|
||||
helper.#onTagInputChange("bus", tag, updateSummary);
|
||||
helper.#validateBusTripTag();
|
||||
};
|
||||
route.onchange = function () {
|
||||
updateSummary();
|
||||
helper.#validateBusTripRoute();
|
||||
};
|
||||
from.onchange = function () {
|
||||
updateSummary();
|
||||
helper.#validateBusTripFrom();
|
||||
};
|
||||
to.onchange = function () {
|
||||
updateSummary();
|
||||
helper.#validateBusTripTo();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the tag buttons.
|
||||
*
|
||||
* @param tabId {string} the tab ID
|
||||
* @param tag {HTMLInputElement} the tag input
|
||||
* @param updateSummary {function(): void} the callback to update the summary
|
||||
*/
|
||||
#initializeTagButtons(tabId, tag, updateSummary) {
|
||||
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-" + tabId + "-btn-tag"));
|
||||
const helper = this;
|
||||
for (const tagButton of tagButtons) {
|
||||
tagButton.onclick = function () {
|
||||
for (const otherButton of tagButtons) {
|
||||
otherButton.classList.remove("btn-primary");
|
||||
otherButton.classList.add("btn-outline-primary");
|
||||
}
|
||||
tagButton.classList.remove("btn-outline-primary");
|
||||
tagButton.classList.add("btn-primary");
|
||||
tag.value = tagButton.dataset.value;
|
||||
helper.#filterSuggestedAccounts(tagButton);
|
||||
updateSummary();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback when the tag input is changed.
|
||||
*
|
||||
* @param tabId {string} the tab ID
|
||||
* @param tag {HTMLInputElement} the tag input
|
||||
* @param updateSummary {function(): void} the callback to update the summary
|
||||
*/
|
||||
#onTagInputChange(tabId, tag, updateSummary) {
|
||||
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-" + tabId + "-btn-tag"));
|
||||
let isMatched = false;
|
||||
for (const tagButton of tagButtons) {
|
||||
if (tagButton.dataset.value === tag.value) {
|
||||
tagButton.classList.remove("btn-outline-primary");
|
||||
tagButton.classList.add("btn-primary");
|
||||
this.#filterSuggestedAccounts(tagButton);
|
||||
isMatched = true;
|
||||
} else {
|
||||
tagButton.classList.remove("btn-primary");
|
||||
tagButton.classList.add("btn-outline-primary");
|
||||
}
|
||||
}
|
||||
if (!isMatched) {
|
||||
this.#filterSuggestedAccounts(null);
|
||||
}
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the suggested accounts.
|
||||
*
|
||||
* @param tagButton {HTMLButtonElement|null} the tag button
|
||||
*/
|
||||
#filterSuggestedAccounts(tagButton) {
|
||||
const accountButtons = Array.from(document.getElementsByClassName(this.#prefix + "-account"));
|
||||
if (tagButton === null) {
|
||||
for (const accountButton of accountButtons) {
|
||||
accountButton.classList.add("d-none");
|
||||
accountButton.classList.remove("btn-primary");
|
||||
accountButton.classList.add("btn-outline-primary");
|
||||
this.#selectAccount(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const suggested = JSON.parse(tagButton.dataset.accounts);
|
||||
for (const accountButton of accountButtons) {
|
||||
if (suggested.includes(accountButton.dataset.code)) {
|
||||
accountButton.classList.remove("d-none");
|
||||
} else {
|
||||
accountButton.classList.add("d-none");
|
||||
}
|
||||
this.#selectAccount(suggested[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the number helper.
|
||||
*
|
||||
*/
|
||||
#initializeNumberHelper() {
|
||||
const summary = document.getElementById(this.#prefix + "-summary");
|
||||
const number = document.getElementById(this.#prefix + "-number");
|
||||
number.onchange = function () {
|
||||
const found = summary.value.match(/^(.+)×(\d+)$/);
|
||||
if (found !== null) {
|
||||
summary.value = found[1];
|
||||
}
|
||||
if (number.value > 1) {
|
||||
summary.value = summary.value + "×" + String(number.value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the suggested accounts.
|
||||
*
|
||||
*/
|
||||
#initializeSuggestedAccounts() {
|
||||
const accountButtons = Array.from(document.getElementsByClassName(this.#prefix + "-account"));
|
||||
const helper = this;
|
||||
for (const accountButton of accountButtons) {
|
||||
accountButton.onclick = function () {
|
||||
helper.#selectAccount(accountButton.dataset.code);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a suggested account.
|
||||
*
|
||||
* @param selectedCode {string|null} the account code, or null to deselect the account
|
||||
*/
|
||||
#selectAccount(selectedCode) {
|
||||
const form = document.getElementById(this.#prefix);
|
||||
if (selectedCode === null) {
|
||||
form.dataset.selectedAccountCode = "";
|
||||
form.dataset.selectedAccountText = "";
|
||||
return;
|
||||
}
|
||||
const accountButtons = Array.from(document.getElementsByClassName(this.#prefix + "-account"));
|
||||
for (const accountButton of accountButtons) {
|
||||
if (accountButton.dataset.code === selectedCode) {
|
||||
accountButton.classList.remove("btn-outline-primary");
|
||||
accountButton.classList.add("btn-primary");
|
||||
form.dataset.selectedAccountCode = accountButton.dataset.code;
|
||||
form.dataset.selectedAccountText = accountButton.dataset.text;
|
||||
} else {
|
||||
accountButton.classList.remove("btn-primary");
|
||||
accountButton.classList.add("btn-outline-primary");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the summary submission
|
||||
*
|
||||
*/
|
||||
#initializeSubmission() {
|
||||
const form = document.getElementById(this.#prefix);
|
||||
const helper = this;
|
||||
form.onsubmit = function () {
|
||||
if (helper.#validate()) {
|
||||
helper.#submit();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the form.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validate() {
|
||||
const tabs = Array.from(document.getElementsByClassName(this.#prefix + "-tab"));
|
||||
let isValid = true;
|
||||
for (const tab of tabs) {
|
||||
if (tab.classList.contains("active")) {
|
||||
switch (tab.dataset.tabId) {
|
||||
case "general":
|
||||
isValid = this.#validateGeneralTag() && isValid;
|
||||
break;
|
||||
case "travel":
|
||||
isValid = this.#validateGeneralTrip() && isValid;
|
||||
break;
|
||||
case "bus":
|
||||
isValid = this.#validateBusTrip() && isValid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a general tag.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validateGeneralTag() {
|
||||
const field = document.getElementById(this.#prefix + "-general-tag");
|
||||
const error = document.getElementById(this.#prefix + "-general-tag-error");
|
||||
field.value = field.value.trim();
|
||||
field.classList.remove("is-invalid");
|
||||
error.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a general trip.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validateGeneralTrip() {
|
||||
let isValid = true;
|
||||
isValid = this.#validateGeneralTripTag() && isValid;
|
||||
isValid = this.#validateGeneralTripFrom() && isValid;
|
||||
isValid = this.#validateGeneralTripTo() && isValid;
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the tag of a general trip.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validateGeneralTripTag() {
|
||||
const field = document.getElementById(this.#prefix + "-travel-tag");
|
||||
const error = document.getElementById(this.#prefix + "-travel-tag-error");
|
||||
field.value = field.value.trim();
|
||||
if (field.value === "") {
|
||||
field.classList.add("is-invalid");
|
||||
error.innerText = A_("Please fill in the tag.");
|
||||
return false;
|
||||
}
|
||||
field.classList.remove("is-invalid");
|
||||
error.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the origin of a general trip.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validateGeneralTripFrom() {
|
||||
const field = document.getElementById(this.#prefix + "-travel-from");
|
||||
const error = document.getElementById(this.#prefix + "-travel-from-error");
|
||||
field.value = field.value.trim();
|
||||
if (field.value === "") {
|
||||
field.classList.add("is-invalid");
|
||||
error.innerText = A_("Please fill in the origin.");
|
||||
return false;
|
||||
}
|
||||
field.classList.remove("is-invalid");
|
||||
error.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the destination of a general trip.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validateGeneralTripTo() {
|
||||
const field = document.getElementById(this.#prefix + "-travel-to");
|
||||
const error = document.getElementById(this.#prefix + "-travel-to-error");
|
||||
field.value = field.value.trim();
|
||||
if (field.value === "") {
|
||||
field.classList.add("is-invalid");
|
||||
error.innerText = A_("Please fill in the destination.");
|
||||
return false;
|
||||
}
|
||||
field.classList.remove("is-invalid");
|
||||
error.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a bus trip.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validateBusTrip() {
|
||||
let isValid = true;
|
||||
isValid = this.#validateBusTripTag() && isValid;
|
||||
isValid = this.#validateBusTripRoute() && isValid;
|
||||
isValid = this.#validateBusTripFrom() && isValid;
|
||||
isValid = this.#validateBusTripTo() && isValid;
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the tag of a bus trip.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validateBusTripTag() {
|
||||
const field = document.getElementById(this.#prefix + "-bus-tag");
|
||||
const error = document.getElementById(this.#prefix + "-bus-tag-error");
|
||||
field.value = field.value.trim();
|
||||
if (field.value === "") {
|
||||
field.classList.add("is-invalid");
|
||||
error.innerText = A_("Please fill in the tag.");
|
||||
return false;
|
||||
}
|
||||
field.classList.remove("is-invalid");
|
||||
error.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the route of a bus trip.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validateBusTripRoute() {
|
||||
const field = document.getElementById(this.#prefix + "-bus-route");
|
||||
const error = document.getElementById(this.#prefix + "-bus-route-error");
|
||||
field.value = field.value.trim();
|
||||
if (field.value === "") {
|
||||
field.classList.add("is-invalid");
|
||||
error.innerText = A_("Please fill in the route.");
|
||||
return false;
|
||||
}
|
||||
field.classList.remove("is-invalid");
|
||||
error.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the origin of a bus trip.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validateBusTripFrom() {
|
||||
const field = document.getElementById(this.#prefix + "-bus-from");
|
||||
const error = document.getElementById(this.#prefix + "-bus-from-error");
|
||||
field.value = field.value.trim();
|
||||
if (field.value === "") {
|
||||
field.classList.add("is-invalid");
|
||||
error.innerText = A_("Please fill in the origin.");
|
||||
return false;
|
||||
}
|
||||
field.classList.remove("is-invalid");
|
||||
error.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the destination of a bus trip.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validateBusTripTo() {
|
||||
const field = document.getElementById(this.#prefix + "-bus-to");
|
||||
const error = document.getElementById(this.#prefix + "-bus-to-error");
|
||||
field.value = field.value.trim();
|
||||
if (field.value === "") {
|
||||
field.classList.add("is-invalid");
|
||||
error.innerText = A_("Please fill in the destination.");
|
||||
return false;
|
||||
}
|
||||
field.classList.remove("is-invalid");
|
||||
error.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the summary.
|
||||
*
|
||||
*/
|
||||
#submit() {
|
||||
const form = document.getElementById(this.#prefix);
|
||||
const summary = document.getElementById(this.#prefix + "-summary");
|
||||
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
|
||||
const formSummary = document.getElementById("accounting-entry-form-summary");
|
||||
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
|
||||
const formAccount = document.getElementById("accounting-entry-form-account");
|
||||
const helperModal = document.getElementById(this.#prefix + "-modal");
|
||||
const entryModal = document.getElementById("accounting-entry-form-modal");
|
||||
if (summary.value === "") {
|
||||
formSummaryControl.classList.remove("accounting-not-empty");
|
||||
} else {
|
||||
formSummaryControl.classList.add("accounting-not-empty");
|
||||
}
|
||||
if (form.dataset.selectedAccountCode !== "") {
|
||||
formAccountControl.classList.add("accounting-not-empty");
|
||||
formAccount.dataset.code = form.dataset.selectedAccountCode;
|
||||
formAccount.dataset.text = form.dataset.selectedAccountText;
|
||||
formAccount.innerText = form.dataset.selectedAccountText;
|
||||
}
|
||||
formSummary.dataset.value = summary.value;
|
||||
formSummary.innerText = summary.value;
|
||||
bootstrap.Modal.getInstance(helperModal).hide();
|
||||
bootstrap.Modal.getOrCreateInstance(entryModal).show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the summary helper when it is shown.
|
||||
*
|
||||
* @param isNew {boolean} true for adding a new journal entry, or false otherwise
|
||||
*/
|
||||
initShow(isNew) {
|
||||
const formSummary = document.getElementById("accounting-entry-form-summary");
|
||||
const summary = document.getElementById(this.#prefix + "-summary");
|
||||
const closeButtons = Array.from(document.getElementsByClassName(this.#prefix + "-close"));
|
||||
for (const closeButton of closeButtons) {
|
||||
if (isNew) {
|
||||
closeButton.dataset.bsTarget = "#" + this.#prefix + "-modal";
|
||||
} else {
|
||||
closeButton.dataset.bsTarget = "#accounting-entry-form-modal";
|
||||
}
|
||||
}
|
||||
this.#reset();
|
||||
if (!isNew) {
|
||||
summary.value = formSummary.dataset.value;
|
||||
this.#parseAndPopulate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the summary helper.
|
||||
*
|
||||
*/
|
||||
#reset() {
|
||||
const inputs = Array.from(document.getElementsByClassName(this.#prefix + "-input"));
|
||||
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-btn-tag"));
|
||||
const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction"));
|
||||
for (const input of inputs) {
|
||||
input.value = "";
|
||||
input.classList.remove("is-invalid");
|
||||
}
|
||||
for (const tagButton of tagButtons) {
|
||||
tagButton.classList.remove("btn-primary");
|
||||
tagButton.classList.add("btn-outline-primary");
|
||||
}
|
||||
for (const directionButton of directionButtons) {
|
||||
if (directionButton.classList.contains("accounting-default")) {
|
||||
directionButton.classList.remove("btn-outline-primary");
|
||||
directionButton.classList.add("btn-primary");
|
||||
} else {
|
||||
directionButton.classList.add("btn-outline-primary");
|
||||
directionButton.classList.remove("btn-primary");
|
||||
}
|
||||
}
|
||||
this.#filterSuggestedAccounts(null);
|
||||
this.#switchToTab(this.#defaultTabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the summary input and populates the summary helper.
|
||||
*
|
||||
*/
|
||||
#parseAndPopulate() {
|
||||
const summary = document.getElementById(this.#prefix + "-summary");
|
||||
const pos = summary.value.indexOf("—");
|
||||
if (pos === -1) {
|
||||
return;
|
||||
}
|
||||
let found;
|
||||
found = summary.value.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:×(\d+))?$/);
|
||||
if (found !== null) {
|
||||
return this.#populateBusTrip(found[1], found[2], found[3], found[4], found[5]);
|
||||
}
|
||||
found = summary.value.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:×(\d+))?$/);
|
||||
if (found !== null) {
|
||||
return this.#populateGeneralTrip(found[1], found[2], found[3], found[4], found[5]);
|
||||
}
|
||||
found = summary.value.match(/^([^—]+)—.+?(?:×(\d+)?)?$/);
|
||||
if (found !== null) {
|
||||
return this.#populateGeneralTag(found[1], found[2]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates a bus trip.
|
||||
*
|
||||
* @param tagName {string} the tag name
|
||||
* @param routeName {string} the route name or route number
|
||||
* @param fromName {string} the name of the origin
|
||||
* @param toName {string} the name of the destination
|
||||
* @param numberStr {string|undefined} the number of items, if any
|
||||
*/
|
||||
#populateBusTrip(tagName, routeName, fromName, toName, numberStr) {
|
||||
const tag = document.getElementById(this.#prefix + "-bus-tag");
|
||||
const route = document.getElementById(this.#prefix + "-bus-route");
|
||||
const from = document.getElementById(this.#prefix + "-bus-from");
|
||||
const to = document.getElementById(this.#prefix + "-bus-to");
|
||||
const number = document.getElementById(this.#prefix + "-number");
|
||||
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-bus-btn-tag"));
|
||||
tag.value = tagName;
|
||||
route.value = routeName;
|
||||
from.value = fromName;
|
||||
to.value = toName;
|
||||
if (numberStr !== undefined) {
|
||||
number.value = parseInt(numberStr);
|
||||
}
|
||||
for (const tagButton of tagButtons) {
|
||||
if (tagButton.dataset.value === tagName) {
|
||||
tagButton.classList.remove("btn-outline-primary");
|
||||
tagButton.classList.add("btn-primary");
|
||||
this.#filterSuggestedAccounts(tagButton);
|
||||
}
|
||||
}
|
||||
this.#switchToTab("bus");
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates a general trip.
|
||||
*
|
||||
* @param tagName {string} the tag name
|
||||
* @param fromName {string} the name of the origin
|
||||
* @param direction {string} the direction arrow, either "→" or "↔"
|
||||
* @param toName {string} the name of the destination
|
||||
* @param numberStr {string|undefined} the number of items, if any
|
||||
*/
|
||||
#populateGeneralTrip(tagName, fromName, direction, toName, numberStr) {
|
||||
const tag = document.getElementById(this.#prefix + "-travel-tag");
|
||||
const from = document.getElementById(this.#prefix + "-travel-from");
|
||||
const to = document.getElementById(this.#prefix + "-travel-to");
|
||||
const number = document.getElementById(this.#prefix + "-number");
|
||||
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-btn-tag"));
|
||||
const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction"));
|
||||
tag.value = tagName;
|
||||
from.value = fromName;
|
||||
for (const directionButton of directionButtons) {
|
||||
if (directionButton.dataset.arrow === direction) {
|
||||
directionButton.classList.remove("btn-outline-primary");
|
||||
directionButton.classList.add("btn-primary");
|
||||
} else {
|
||||
directionButton.classList.add("btn-outline-primary");
|
||||
directionButton.classList.remove("btn-primary");
|
||||
}
|
||||
}
|
||||
to.value = toName;
|
||||
if (numberStr !== undefined) {
|
||||
number.value = parseInt(numberStr);
|
||||
}
|
||||
for (const tagButton of tagButtons) {
|
||||
if (tagButton.dataset.value === tagName) {
|
||||
tagButton.classList.remove("btn-outline-primary");
|
||||
tagButton.classList.add("btn-primary");
|
||||
this.#filterSuggestedAccounts(tagButton);
|
||||
}
|
||||
}
|
||||
this.#switchToTab("travel");
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates a general tag.
|
||||
*
|
||||
* @param tagName {string} the tag name
|
||||
* @param numberStr {string|undefined} the number of items, if any
|
||||
*/
|
||||
#populateGeneralTag(tagName, numberStr) {
|
||||
const tag = document.getElementById(this.#prefix + "-general-tag");
|
||||
const number = document.getElementById(this.#prefix + "-number");
|
||||
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-general-btn-tag"));
|
||||
tag.value = tagName;
|
||||
if (numberStr !== undefined) {
|
||||
number.value = parseInt(numberStr);
|
||||
}
|
||||
for (const tagButton of tagButtons) {
|
||||
if (tagButton.dataset.value === tagName) {
|
||||
tagButton.classList.remove("btn-outline-primary");
|
||||
tagButton.classList.add("btn-primary");
|
||||
this.#filterSuggestedAccounts(tagButton);
|
||||
}
|
||||
}
|
||||
this.#switchToTab("general");
|
||||
}
|
||||
|
||||
/**
|
||||
* The summary helpers.
|
||||
* @type {{debit: SummaryHelper, credit: SummaryHelper}}
|
||||
*/
|
||||
static #helpers = {}
|
||||
|
||||
/**
|
||||
* Initializes the summary helpers.
|
||||
*
|
||||
*/
|
||||
static initialize() {
|
||||
const forms = Array.from(document.getElementsByClassName("accounting-summary-helper"));
|
||||
for (const form of forms) {
|
||||
const helper = new SummaryHelper(form);
|
||||
this.#helpers[helper.#entryType] = helper;
|
||||
}
|
||||
this.#initializeTransactionForm();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the transaction form.
|
||||
*
|
||||
*/
|
||||
static #initializeTransactionForm() {
|
||||
const entryForm = document.getElementById("accounting-entry-form");
|
||||
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
|
||||
const helpers = this.#helpers;
|
||||
formSummaryControl.onclick = function () {
|
||||
helpers[entryForm.dataset.entryType].initShow(false);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the summary helper for a new journal entry.
|
||||
*
|
||||
* @param entryType {string} the entry type, either "debit" or "credit"
|
||||
*/
|
||||
static initializeNewJournalEntry(entryType) {
|
||||
this.#helpers[entryType].initShow(true);
|
||||
}
|
||||
}
|
@ -20,9 +20,10 @@
|
||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
* First written: 2023/2/25
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initializeCurrencyForms();
|
||||
initializeJournalEntries();
|
||||
initializeFormValidation();
|
||||
@ -68,14 +69,14 @@ function initializeCurrencyForms() {
|
||||
const btnNew = document.getElementById("accounting-btn-new-currency");
|
||||
const currencyList = document.getElementById("accounting-currency-list");
|
||||
const deleteButtons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency"));
|
||||
const onReorder = function () {
|
||||
const onReorder = () => {
|
||||
const currencies = Array.from(currencyList.children);
|
||||
for (let i = 0; i < currencies.length; i++) {
|
||||
const no = document.getElementById(currencies[i].dataset.prefix + "-no");
|
||||
no.value = String(i + 1);
|
||||
}
|
||||
};
|
||||
btnNew.onclick = function () {
|
||||
btnNew.onclick = () => {
|
||||
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
|
||||
let maxIndex = 0;
|
||||
for (const currency of currencies) {
|
||||
@ -107,7 +108,7 @@ function initializeCurrencyForms() {
|
||||
*/
|
||||
function initializeBtnDeleteCurrency(button) {
|
||||
const target = document.getElementById(button.dataset.target);
|
||||
button.onclick = function () {
|
||||
button.onclick = () => {
|
||||
target.parentElement.removeChild(target);
|
||||
resetDeleteCurrencyButtons();
|
||||
};
|
||||
@ -161,7 +162,7 @@ function initializeNewEntryButton(button) {
|
||||
const formSummaryError = document.getElementById("accounting-entry-form-summary-error");
|
||||
const formAmount = document.getElementById("accounting-entry-form-amount");
|
||||
const formAmountError = document.getElementById("accounting-entry-form-amount-error");
|
||||
button.onclick = function () {
|
||||
button.onclick = () => {
|
||||
entryForm.dataset.currencyIndex = button.dataset.currencyIndex;
|
||||
entryForm.dataset.entryType = button.dataset.entryType;
|
||||
entryForm.dataset.entryIndex = button.dataset.entryIndex;
|
||||
@ -171,7 +172,7 @@ function initializeNewEntryButton(button) {
|
||||
formAccount.dataset.code = "";
|
||||
formAccount.dataset.text = "";
|
||||
formAccountError.innerText = "";
|
||||
formSummaryControl.dataset.bsTarget = "#accounting-summary-helper-" + button.dataset.entryType + "-modal";
|
||||
formSummaryControl.dataset.bsTarget = "#accounting-summary-editor-" + button.dataset.entryType + "-modal";
|
||||
formSummaryControl.classList.remove("accounting-not-empty");
|
||||
formSummaryControl.classList.remove("is-invalid");
|
||||
formSummary.dataset.value = "";
|
||||
@ -181,7 +182,7 @@ function initializeNewEntryButton(button) {
|
||||
formAmount.classList.remove("is-invalid");
|
||||
formAmountError.innerText = "";
|
||||
AccountSelector.initializeJournalEntryForm();
|
||||
SummaryHelper.initializeNewJournalEntry(button.dataset.entryType);
|
||||
SummaryEditor.initializeNewJournalEntry(button.dataset.entryType);
|
||||
};
|
||||
}
|
||||
|
||||
@ -191,7 +192,7 @@ function initializeNewEntryButton(button) {
|
||||
* @param entryList {HTMLUListElement} the journal entry list.
|
||||
*/
|
||||
function initializeJournalEntryListReorder(entryList) {
|
||||
initializeDragAndDropReordering(entryList, function () {
|
||||
initializeDragAndDropReordering(entryList, () => {
|
||||
const entries = Array.from(entryList.children);
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const no = document.getElementById(entries[i].dataset.prefix + "-no");
|
||||
@ -216,7 +217,7 @@ function initializeJournalEntry(entry) {
|
||||
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
|
||||
const formSummary = document.getElementById("accounting-entry-form-summary");
|
||||
const formAmount = document.getElementById("accounting-entry-form-amount");
|
||||
control.onclick = function () {
|
||||
control.onclick = () => {
|
||||
entryForm.dataset.currencyIndex = entry.dataset.currencyIndex;
|
||||
entryForm.dataset.entryType = entry.dataset.entryType;
|
||||
entryForm.dataset.entryIndex = entry.dataset.entryIndex;
|
||||
@ -228,7 +229,7 @@ function initializeJournalEntry(entry) {
|
||||
formAccount.innerText = accountCode.dataset.text;
|
||||
formAccount.dataset.code = accountCode.value;
|
||||
formAccount.dataset.text = accountCode.dataset.text;
|
||||
formSummaryControl.dataset.bsTarget = "#accounting-summary-helper-" + entry.dataset.entryType + "-modal";
|
||||
formSummaryControl.dataset.bsTarget = "#accounting-summary-editor-" + entry.dataset.entryType + "-modal";
|
||||
if (summary.value === "") {
|
||||
formSummaryControl.classList.remove("accounting-not-empty");
|
||||
} else {
|
||||
@ -252,7 +253,7 @@ function initializeJournalEntryFormModal() {
|
||||
const formAmount = document.getElementById("accounting-entry-form-amount");
|
||||
const modal = document.getElementById("accounting-entry-form-modal");
|
||||
formAmount.onchange = validateJournalEntryAmount;
|
||||
entryForm.onsubmit = function () {
|
||||
entryForm.onsubmit = () => {
|
||||
if (validateJournalEntryForm()) {
|
||||
saveJournalEntryForm();
|
||||
bootstrap.Modal.getInstance(modal).hide();
|
||||
@ -398,7 +399,7 @@ function initializeDeleteJournalEntryButton(button) {
|
||||
const currencyIndex = target.dataset.currencyIndex;
|
||||
const entryType = target.dataset.entryType;
|
||||
const currency = document.getElementById("accounting-currency-" + currencyIndex);
|
||||
button.onclick = function () {
|
||||
button.onclick = () => {
|
||||
target.parentElement.removeChild(target);
|
||||
resetDeleteJournalEntryButtons(button.dataset.sameClass);
|
||||
updateBalance(currencyIndex, entryType);
|
||||
|
@ -20,12 +20,13 @@
|
||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
* First written: 2023/2/26
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const list = document.getElementById("accounting-order-list");
|
||||
if (list !== null) {
|
||||
const onReorder = function () {
|
||||
const onReorder = () => {
|
||||
const accounts = Array.from(list.children);
|
||||
for (let i = 0; i < accounts.length; i++) {
|
||||
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
|
||||
|
81
src/accounting/template_filters.py
Normal file
81
src/accounting/template_filters.py
Normal file
@ -0,0 +1,81 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/25
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The template filters.
|
||||
|
||||
"""
|
||||
import typing as t
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from flask_babel import get_locale
|
||||
|
||||
from accounting.locale import gettext
|
||||
|
||||
|
||||
def format_amount(value: Decimal | None) -> str | None:
|
||||
"""Formats an amount for readability.
|
||||
|
||||
:param value: The amount.
|
||||
:return: The formatted amount text.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if value == 0:
|
||||
return "-"
|
||||
whole: int = int(value)
|
||||
frac: Decimal = (value - whole).normalize()
|
||||
return "{:,}".format(whole) + str(abs(frac))[1:]
|
||||
|
||||
|
||||
def format_date(value: date) -> str:
|
||||
"""Formats a date to be human-friendly.
|
||||
|
||||
:param value: The date.
|
||||
:return: The human-friendly date text.
|
||||
"""
|
||||
today: date = date.today()
|
||||
if value == today:
|
||||
return gettext("Today")
|
||||
if value == today - timedelta(days=1):
|
||||
return gettext("Yesterday")
|
||||
if value == today + timedelta(days=1):
|
||||
return gettext("Tomorrow")
|
||||
locale = str(get_locale())
|
||||
if locale == "zh" or locale.startswith("zh_"):
|
||||
if value == today - timedelta(days=2):
|
||||
return gettext("The day before yesterday")
|
||||
if value == today + timedelta(days=2):
|
||||
return gettext("The day after tomorrow")
|
||||
if locale == "zh" or locale.startswith("zh_"):
|
||||
weekdays = ["一", "二", "三", "四", "五", "六", "日"]
|
||||
weekday = weekdays[value.weekday()]
|
||||
else:
|
||||
weekday = value.strftime("%a")
|
||||
if value.year != today.year:
|
||||
return "{}/{}/{}({})".format(
|
||||
value.year, value.month, value.day, weekday)
|
||||
return "{}/{}({})".format(value.month, value.day, weekday)
|
||||
|
||||
|
||||
def default(value: t.Any, default_value: t.Any = "") -> t.Any:
|
||||
"""Returns the default value if the given value is None.
|
||||
|
||||
:param value: The value.
|
||||
:param default_value: The default value when the given value is None.
|
||||
:return: The value, or the default value if the given value is None.
|
||||
"""
|
||||
return default_value if value is None else value
|
39
src/accounting/template_globals.py
Normal file
39
src/accounting/template_globals.py
Normal file
@ -0,0 +1,39 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The template globals for the transaction management.
|
||||
|
||||
"""
|
||||
from flask import current_app
|
||||
|
||||
from accounting.models import Currency
|
||||
|
||||
|
||||
def currency_options() -> str:
|
||||
"""Returns the currency options.
|
||||
|
||||
:return: The currency options.
|
||||
"""
|
||||
return Currency.query.order_by(Currency.code).all()
|
||||
|
||||
|
||||
def default_currency_code() -> str:
|
||||
"""Returns the default currency code.
|
||||
|
||||
:return: The default currency code.
|
||||
"""
|
||||
with current_app.app_context():
|
||||
return current_app.config.get("DEFAULT_CURRENCY", "USD")
|
@ -59,8 +59,8 @@ First written: 2023/1/31
|
||||
{% if accounting_can_edit() %}
|
||||
<form action="{{ url_for("accounting.account.delete", account=obj) }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% if "next" in request.args %}
|
||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
||||
{% if request.args.next %}
|
||||
<input type="hidden" name="next" value="{{ request.args.next }}">
|
||||
{% endif %}
|
||||
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
@ -85,9 +85,9 @@ First written: 2023/1/31
|
||||
<div class="accounting-card col-sm-6">
|
||||
<div class="accounting-card-title">{{ obj.title }}</div>
|
||||
<div class="accounting-card-code">{{ obj.code }}</div>
|
||||
{% if obj.is_pay_off_needed %}
|
||||
{% if obj.is_offset_needed %}
|
||||
<div>
|
||||
<span class="badge rounded-pill bg-info">{{ A_("Pay-off needed") }}</span>
|
||||
<span class="badge rounded-pill bg-info">{{ A_("Need offset") }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="small text-secondary fst-italic">
|
||||
|
@ -27,7 +27,7 @@ First written: 2023/2/1
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="btn-group btn-actions mb-3">
|
||||
<div class="btn-group mb-3">
|
||||
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
|
||||
<i class="fa-solid fa-circle-chevron-left"></i>
|
||||
{{ A_("Back") }}
|
||||
@ -36,11 +36,11 @@ First written: 2023/2/1
|
||||
|
||||
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post">
|
||||
{{ form.csrf_token }}
|
||||
{% if "next" in request.args %}
|
||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
||||
{% if request.args.next %}
|
||||
<input type="hidden" name="next" value="{{ request.args.next }}">
|
||||
{% endif %}
|
||||
<div class="form-floating mb-3">
|
||||
<input id="accounting-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}">
|
||||
<input id="accounting-base-code" type="hidden" name="base_code" value="{{ form.base_code.data|accounting_default }}">
|
||||
<div id="accounting-base" 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">
|
||||
<label class="form-label" for="accounting-base">{{ A_("Base account") }}</label>
|
||||
<div id="accounting-base-content">
|
||||
@ -57,15 +57,15 @@ First written: 2023/2/1
|
||||
</div>
|
||||
|
||||
<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="{{ "" if form.title.data is none else form.title.data }}" 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>
|
||||
<div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input id="accounting-is-pay-off-needed" class="form-check-input" type="checkbox" name="is_pay_off_needed" value="1" {% if form.is_pay_off_needed.data %} checked="checked" {% endif %}>
|
||||
<label class="form-check-label" for="accounting-is-pay-off-needed">
|
||||
{{ A_("The entries in the account need pay-off.") }}
|
||||
<input id="accounting-is-offset-needed" class="form-check-input" type="checkbox" name="is_offset_needed" value="1" {% if form.is_offset_needed.data %} checked="checked" {% endif %}>
|
||||
<label class="form-check-label" for="accounting-is-offset-needed">
|
||||
{{ A_("The entries in the account need offset.") }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
@ -21,35 +21,23 @@ First written: 2023/1/30
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Account Management") }}{% endif %}{% endblock %}{% endblock %}
|
||||
{% block header %}{% block title %}{% if request.args.q %}{{ A_("Search Result for \"%(query)s\"", query=request.args.q) }}{% else %}{{ A_("Account Management") }}{% endif %}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="btn-group mb-2 d-none d-md-inline-flex">
|
||||
<div class="mb-2 accounting-toolbar">
|
||||
{% if accounting_can_edit() %}
|
||||
<a class="btn btn-primary text-nowrap" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
|
||||
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
{{ A_("New") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-label="{{ A_("Search for Desktop") }}">
|
||||
<input id="accounting-search-desktop" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
|
||||
<label for="accounting-search-desktop" class="accounting-search-label">
|
||||
<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">
|
||||
<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">
|
||||
<button type="submit">
|
||||
<i class="fa-solid fa-magnifying-glass"></i>
|
||||
{{ A_("Search") }}
|
||||
</button>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="btn-group mb-2 d-md-none">
|
||||
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-label="{{ A_("Search for Mobile") }}">
|
||||
<input id="accounting-search-mobile" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
|
||||
<label for="accounting-search-mobile" class="accounting-search-label">
|
||||
<button type="submit">
|
||||
<i class="fa-solid fa-magnifying-glass"></i>
|
||||
{{ A_("Search") }}
|
||||
<span class="d-none d-md-inline">{{ A_("Search") }}</span>
|
||||
</button>
|
||||
</label>
|
||||
</form>
|
||||
@ -70,8 +58,8 @@ First written: 2023/1/30
|
||||
{% for item in list %}
|
||||
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}">
|
||||
{{ item }}
|
||||
{% if item.is_pay_off_needed %}
|
||||
<span class="badge rounded-pill bg-info">{{ A_("Pay-off needed") }}</span>
|
||||
{% if item.is_offset_needed %}
|
||||
<span class="badge rounded-pill bg-info">{{ A_("Need offset") }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
@ -40,8 +40,8 @@ First written: 2023/2/2
|
||||
{% if base.accounts|length > 1 and accounting_can_edit() %}
|
||||
<form action="{{ url_for("accounting.account.sort", base=base) }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% if "next" in request.args %}
|
||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
||||
{% if request.args.next %}
|
||||
<input type="hidden" name="next" value="{{ request.args.next }}">
|
||||
{% endif %}
|
||||
<ul id="accounting-order-list" class="list-group mb-3" data-base-code="{{ base.code }}">
|
||||
{% for account in base.accounts|sort(attribute="no") %}
|
||||
|
@ -21,17 +21,17 @@ First written: 2023/1/26
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Base Account Managements") }}{% endif %}{% endblock %}{% endblock %}
|
||||
{% block header %}{% block title %}{% if request.args.q %}{{ A_("Search Result for \"%(query)s\"", query=request.args.q) }}{% else %}{{ A_("Base Account Managements") }}{% endif %}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="btn-group mb-2">
|
||||
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-label="{{ A_("Search") }}">
|
||||
<input id="accounting-search" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
|
||||
<label for="accounting-search" class="accounting-search-label">
|
||||
<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">
|
||||
<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">
|
||||
<button type="submit">
|
||||
<i class="fa-solid fa-magnifying-glass"></i>
|
||||
{{ A_("Search") }}
|
||||
<span class="d-none d-md-inline">{{ A_("Search") }}</span>
|
||||
</button>
|
||||
</label>
|
||||
</form>
|
||||
|
@ -55,8 +55,8 @@ First written: 2023/2/6
|
||||
{% if accounting_can_edit() %}
|
||||
<form action="{{ url_for("accounting.currency.delete", currency=obj) }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% if "next" in request.args %}
|
||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
||||
{% if request.args.next %}
|
||||
<input type="hidden" name="next" value="{{ request.args.next }}">
|
||||
{% endif %}
|
||||
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
|
@ -27,7 +27,7 @@ First written: 2023/2/6
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="btn-group btn-actions mb-3">
|
||||
<div class="btn-group mb-3">
|
||||
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
|
||||
<i class="fa-solid fa-circle-chevron-left"></i>
|
||||
{{ A_("Back") }}
|
||||
@ -36,17 +36,17 @@ First written: 2023/2/6
|
||||
|
||||
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post">
|
||||
{{ form.csrf_token }}
|
||||
{% if "next" in request.args %}
|
||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
||||
{% if request.args.next %}
|
||||
<input type="hidden" name="next" value="{{ request.args.next }}">
|
||||
{% endif %}
|
||||
<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="{{ "" if form.code.data is none else form.code.data }}" 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>
|
||||
<div id="accounting-code-error" class="invalid-feedback">{% if form.code.errors %}{{ form.code.errors[0] }}{% endif %}</div>
|
||||
</div>
|
||||
|
||||
<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="{{ "" if form.name.data is none else form.name.data }}" 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>
|
||||
<div id="accounting-name-error" class="invalid-feedback">{% if form.name.errors %}{{ form.name.errors[0] }}{% endif %}</div>
|
||||
</div>
|
||||
|
@ -21,35 +21,23 @@ First written: 2023/2/6
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Currency Management") }}{% endif %}{% endblock %}{% endblock %}
|
||||
{% block header %}{% block title %}{% if request.args.q %}{{ A_("Search Result for \"%(query)s\"", query=request.args.q) }}{% else %}{{ A_("Currency Management") }}{% endif %}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="btn-group mb-2 d-none d-md-inline-flex">
|
||||
<div class="mb-2 accounting-toolbar">
|
||||
{% if accounting_can_edit() %}
|
||||
<a class="btn btn-primary text-nowrap" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
|
||||
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
{{ A_("New") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-label="{{ A_("Search for Desktop") }}">
|
||||
<input id="accounting-search-desktop" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
|
||||
<label for="accounting-search-desktop" class="accounting-search-label">
|
||||
<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">
|
||||
<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">
|
||||
<button type="submit">
|
||||
<i class="fa-solid fa-magnifying-glass"></i>
|
||||
{{ A_("Search") }}
|
||||
</button>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="btn-group mb-2 d-md-none">
|
||||
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-label="{{ A_("Search for Mobile") }}">
|
||||
<input id="accounting-search-mobile" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
|
||||
<label for="accounting-search-mobile" class="accounting-search-label">
|
||||
<button type="submit">
|
||||
<i class="fa-solid fa-magnifying-glass"></i>
|
||||
{{ A_("Search") }}
|
||||
<span class="d-none d-md-inline">{{ A_("Search") }}</span>
|
||||
</button>
|
||||
</label>
|
||||
</form>
|
||||
|
@ -28,14 +28,14 @@ First written: 2023/1/26
|
||||
</span>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item {% if request.endpoint.startswith("accounting.transaction.") %} active {% endif %}" href="{{ url_for("accounting.transaction.list") }}">
|
||||
<i class="fa-solid fa-receipt"></i>
|
||||
{{ A_("Transactions") }}
|
||||
<a class="dropdown-item {% if request.endpoint.startswith("accounting.report.") %} active {% endif %}" href="{{ url_for("accounting.report.default") }}">
|
||||
<i class="fa-solid fa-book"></i>
|
||||
{{ A_("Reports") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item {% if request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}">
|
||||
<i class="fa-solid fa-list"></i>
|
||||
<i class="fa-solid fa-clipboard"></i>
|
||||
{{ A_("Accounts") }}
|
||||
</a>
|
||||
</li>
|
||||
|
113
src/accounting/templates/accounting/report/balance-sheet.html
Normal file
113
src/accounting/templates/accounting/report/balance-sheet.html
Normal file
@ -0,0 +1,113 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
income-statement.html: The income statement
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/7
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block accounting_scripts %}
|
||||
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Balance Sheet of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="mb-3 accounting-toolbar">
|
||||
{% with use_currency_chooser = true,
|
||||
use_period_chooser = true %}
|
||||
{% include "accounting/report/include/toolbar-buttons.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
{% include "accounting/report/include/add-txn-material-fab.html" %}
|
||||
|
||||
{% include "accounting/report/include/period-chooser.html" %}
|
||||
|
||||
{% include "accounting/report/include/search-modal.html" %}
|
||||
|
||||
{% if report.has_data %}
|
||||
<div class="accounting-sheet">
|
||||
<div class="d-none d-sm-flex justify-content-center mb-3">
|
||||
<h2 class="text-center">{{ A_("Balance Sheet of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="row accounting-report-table accounting-balance-sheet-table">
|
||||
<div class="col-sm-6">
|
||||
{% if report.assets.subsections %}
|
||||
{% with section = report.assets %}
|
||||
{% include "accounting/report/include/balance-sheet-section.html" %}
|
||||
{% endwith %}
|
||||
<div class="d-md-none d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-total">
|
||||
<div>{{ A_("Total") }}</div>
|
||||
<div class="accounting-amount {% if report.assets.total < 0 %} text-danger {% endif %}">{{ report.assets.total|accounting_report_format_amount }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6">
|
||||
{% if report.liabilities.subsections %}
|
||||
{% with section = report.liabilities %}
|
||||
{% include "accounting/report/include/balance-sheet-section.html" %}
|
||||
{% endwith %}
|
||||
<div class="d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-subtotal">
|
||||
<div>{{ A_("Total") }}</div>
|
||||
<div class="accounting-amount {% if report.liabilities.total < 0 %} text-danger {% endif %}">{{ report.liabilities.total|accounting_report_format_amount }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if report.owner_s_equity.subsections %}
|
||||
{% with section = report.owner_s_equity %}
|
||||
{% include "accounting/report/include/balance-sheet-section.html" %}
|
||||
{% endwith %}
|
||||
<div class="d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-subtotal">
|
||||
<div>{{ A_("Total") }}</div>
|
||||
<div class="accounting-amount {% if report.owner_s_equity.total < 0 %} text-danger {% endif %}">{{ report.owner_s_equity.total|accounting_report_format_amount }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-md-none d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-total">
|
||||
<div>{{ A_("Total") }}</div>
|
||||
<div class="accounting-amount {% if report.liabilities.total < 0 %} text-danger {% endif %}">{{ (report.liabilities.total + report.owner_s_equity.total)|accounting_report_format_amount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row d-none d-md-flex">
|
||||
<div class="col-sm-6">
|
||||
<div class="d-flex justify-content-between accounting-balance-sheet-total">
|
||||
<div>{{ A_("Total") }}</div>
|
||||
<div class="accounting-amount {% if report.assets.total < 0 %} text-danger {% endif %}">{{ report.assets.total|accounting_report_format_amount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6">
|
||||
<div class="d-flex justify-content-between accounting-balance-sheet-total">
|
||||
<div>{{ A_("Total") }}</div>
|
||||
<div class="accounting-amount {% if report.liabilities.total + report.owner_s_equity.total < 0 %} text-danger {% endif %}">{{ (report.liabilities.total + report.owner_s_equity.total)|accounting_report_format_amount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>{{ A_("There is no data.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
@ -1,6 +1,6 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
add-new-material-fab.html: The material floating action buttons to add a new transaction
|
||||
add-txn-material-fab.html: The material floating action buttons to add a new transaction
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
@ -22,13 +22,13 @@ First written: 2023/2/25
|
||||
{% if accounting_can_edit() %}
|
||||
<div id="accounting-material-fab-speed-dial" class="d-md-none accounting-material-fab">
|
||||
<div id="accounting-material-fab-speed-dial-actions" class="d-md-none accounting-material-fab-speed-dial-group">
|
||||
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=types.expense)|accounting_append_next }}">
|
||||
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
|
||||
{{ A_("Cash expense") }}
|
||||
</a>
|
||||
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=types.income)|accounting_append_next }}">
|
||||
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
|
||||
{{ A_("Cash income") }}
|
||||
</a>
|
||||
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=types.transfer)|accounting_append_next }}">
|
||||
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
|
||||
{{ A_("Transfer") }}
|
||||
</a>
|
||||
</div>
|
@ -0,0 +1,43 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
balance-sheet-section.html: A section in the balance sheet.
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/8
|
||||
#}
|
||||
<div class="accounting-report-table-row accounting-balance-sheet-section">
|
||||
<div>{{ section.title.title|title }}</div>
|
||||
</div>
|
||||
<div class="accounting-report-table-body">
|
||||
{% for subsection in section.subsections %}
|
||||
<div class="accounting-report-table-row accounting-balance-sheet-subsection">
|
||||
<div>
|
||||
<span class="d-none d-md-inline">{{ subsection.title.code }}</span>
|
||||
{{ subsection.title.title|title }}
|
||||
</div>
|
||||
</div>
|
||||
{% for account in subsection.accounts %}
|
||||
<a class="d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-account" href="{{ account.url }}">
|
||||
<div>
|
||||
<span class="d-none d-md-inline">{{ account.account.code }}</span>
|
||||
{{ account.account.title|title }}
|
||||
</div>
|
||||
<div class="accounting-amount {% if account.amount < 0 %} text-danger {% endif %}">{{ account.amount|accounting_report_format_amount }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
@ -0,0 +1,27 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
income-expenses-row-desktop.html: The row in the income and expenses log for the desktop computers
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/8
|
||||
#}
|
||||
<div>{{ entry.date|accounting_format_date }}</div>
|
||||
<div>{{ entry.account.title|title }}</div>
|
||||
<div>{{ entry.summary|accounting_default }}</div>
|
||||
<div class="accounting-amount">{{ entry.income|accounting_format_amount|accounting_default }}</div>
|
||||
<div class="accounting-amount">{{ entry.expense|accounting_format_amount|accounting_default }}</div>
|
||||
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
|
@ -0,0 +1,50 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
income-expenses-row-mobile.html: The row in the income and expenses log for the mobile devices
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/5
|
||||
#}
|
||||
<div>
|
||||
{% if entry.date or entry.account %}
|
||||
<div class="text-muted small">
|
||||
{% if entry.date %}
|
||||
{{ entry.date|accounting_format_date }}
|
||||
{% endif %}
|
||||
{% if entry.account %}
|
||||
{{ entry.account.title|title }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if entry.summary %}
|
||||
<div>{{ entry.summary }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="text-nowrap">
|
||||
{% if entry.income %}
|
||||
<span class="badge rounded-pill bg-success">+{{ entry.income|accounting_format_amount }}</span>
|
||||
{% endif %}
|
||||
{% if entry.expense %}
|
||||
<span class="badge rounded-pill bg-warning">-{{ entry.expense|accounting_format_amount }}</span>
|
||||
{% endif %}
|
||||
{% if entry.balance < 0 %}
|
||||
<span class="badge rounded-pill bg-danger">{{ entry.balance|accounting_format_amount }}</span>
|
||||
{% else %}
|
||||
<span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span>
|
||||
{% endif %}
|
||||
</div>
|
@ -0,0 +1,26 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
ledger-row-desktop.html: The row in the ledger for the desktop computers
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/8
|
||||
#}
|
||||
<div>{{ entry.date|accounting_format_date }}</div>
|
||||
<div>{{ entry.summary|accounting_default }}</div>
|
||||
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
|
||||
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
|
||||
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
|
@ -0,0 +1,41 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
ledger-row-mobile.html: The row in the ledger for the mobile devices
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/5
|
||||
#}
|
||||
<div>
|
||||
{% if entry.date %}
|
||||
<div class="text-muted small">
|
||||
{{ entry.date|accounting_format_date }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if entry.summary %}
|
||||
<div>{{ entry.summary }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{% if entry.debit %}
|
||||
<span class="badge rounded-pill bg-success">+{{ entry.debit|accounting_format_amount }}</span>
|
||||
{% endif %}
|
||||
{% if entry.credit %}
|
||||
<span class="badge rounded-pill bg-warning">-{{ entry.credit|accounting_format_amount }}</span>
|
||||
{% endif %}
|
||||
<span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span>
|
||||
</div>
|
@ -0,0 +1,150 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
period-chooser.html: The period chooser
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/4
|
||||
#}
|
||||
<div id="accounting-period-chooser-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-period-chooser-modal-label" aria-hidden="true" data-url-template="{{ report.period_chooser.url_template }}">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="accounting-period-chooser-modal-label">{{ A_("Period Chooser") }}</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{# Tab navigation #}
|
||||
<ul class="nav nav-tabs mb-2">
|
||||
<li class="nav-item">
|
||||
<span id="accounting-period-chooser-month-tab" class="nav-link {% if report.period.is_type_month %} active {% endif %} accounting-clickable" aria-current="{% if report.period.is_type_month %} page {% else %} false {% endif %}" data-tab-id="month">
|
||||
{{ A_("Month") }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<span id="accounting-period-chooser-year-tab" class="nav-link {% if report.period.is_a_year %} active {% endif %} accounting-clickable" aria-current="{% if report.period.is_a_year %} page {% else %} false {% endif %}" data-tab-id="year">
|
||||
{{ A_("Year") }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<span id="accounting-period-chooser-day-tab" class="nav-link {% if report.period.is_a_day %} active {% endif %} accounting-clickable" aria-current="{% if report.period.is_a_day %} page {% else %} false {% endif %}" data-tab-id="day">
|
||||
{{ A_("Day") }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<span id="accounting-period-chooser-custom-tab" class="nav-link {% if report.period.is_type_arbitrary %} active {% endif %} accounting-clickable" aria-current="{% if report.period.is_type_arbitrary %} page {% else %} false {% endif %}" data-tab-id="custom">
|
||||
{{ A_("Custom") }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{# The month periods #}
|
||||
<div id="accounting-period-chooser-month-page" {% if report.period.is_type_month %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-month-tab">
|
||||
<div>
|
||||
<a class="btn {% if report.period.is_this_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.this_month_url }}">
|
||||
{{ A_("This month") }}
|
||||
</a>
|
||||
{% if report.period_chooser.has_last_month %}
|
||||
<a class="btn {% if report.period.is_last_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.last_month_url }}">
|
||||
{{ A_("Last month") }}
|
||||
</a>
|
||||
<a class="btn {% if report.period.is_since_last_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.since_last_month_url }}">
|
||||
{{ A_("Since last month") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if report.period_chooser.has_data %}
|
||||
<div id="accounting-period-chooser-month-chooser" class="mt-3" data-start="{{ report.period_chooser.data_start }}" data-default="{{ report.period.start|accounting_default(report.period_chooser.data_start) }}"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# The year periods #}
|
||||
<div id="accounting-period-chooser-year-page" {% if report.period.is_a_year %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-year-tab">
|
||||
<a class="btn {% if report.period.is_this_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.this_year_url }}">
|
||||
{{ A_("This year") }}
|
||||
</a>
|
||||
{% if report.period_chooser.has_last_year %}
|
||||
<a class="btn {% if report.period.is_last_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.last_year_url }}">
|
||||
{{ A_("Last year") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if report.period_chooser.available_years %}
|
||||
<ul class="nav nav-pills mt-3">
|
||||
{% for year in report.period_chooser.available_years %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if report.period.is_year(year) %} active {% endif %}" href="{{ report.period_chooser.year_url(year) }}">{{ year }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# The day periods #}
|
||||
<div id="accounting-period-chooser-day-page" {% if report.period.is_a_day %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-day-tab">
|
||||
<div>
|
||||
<a class="btn {% if report.period.is_today %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.today_url }}">
|
||||
{{ A_("Today") }}
|
||||
</a>
|
||||
{% if report.period_chooser.has_yesterday %}
|
||||
<a class="btn {% if report.period.is_yesterday %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.yesterday_url }}">
|
||||
{{ A_("Yesterday") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if report.period_chooser.has_data %}
|
||||
<div class="mt-3">
|
||||
<div class="form-floating mb-3">
|
||||
<input id="accounting-period-chooser-day-date" class="form-control" type="date" value="{{ report.period.start|accounting_default }}" min="{{ report.period_chooser.data_start }}" required="required">
|
||||
<label for="accounting-period-chooser-day-date" class="form-label">{{ A_("Date") }}</label>
|
||||
<div id="accounting-period-chooser-day-date-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# The custom periods #}
|
||||
<div id="accounting-period-chooser-custom-page" {% if report.period.is_type_arbitrary %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-custom-tab">
|
||||
<div>
|
||||
<a class="btn {% if report.period.is_all %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.all_url }}">
|
||||
{{ A_("All") }}
|
||||
</a>
|
||||
</div>
|
||||
{% if report.period_chooser.has_data %}
|
||||
<div class="mt-3">
|
||||
<div class="form-floating mb-3">
|
||||
<input id="accounting-period-chooser-custom-start" class="form-control" type="date" value="{{ report.period.start|accounting_default }}" min="{{ report.period_chooser.data_start }}" max="{{ report.period.end }}" required="required">
|
||||
<label for="accounting-period-chooser-custom-start" class="form-label">{{ A_("From") }}</label>
|
||||
<div id="accounting-period-chooser-custom-start-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
<input id="accounting-period-chooser-custom-end" class="form-control" type="date" value="{{ report.period.end|accounting_default }}" min="{{ report.period.start }}" required="required">
|
||||
<label for="accounting-period-chooser-custom-end" class="form-label">{{ A_("To") }}</label>
|
||||
<div id="accounting-period-chooser-custom-end-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button id="accounting-period-chooser-custom-confirm" class="btn btn-primary" type="submit">
|
||||
{{ A_("Confirm") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,43 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
search-modal.html: The search modal
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/8
|
||||
#}
|
||||
<form action="{{ url_for("accounting.report.search") }}" method="get" role="search" aria-labelledby="accounting-search-modal-label">
|
||||
<div class="modal fade" id="accounting-search-modal" tabindex="-1" aria-labelledby="accounting-search-modal-label" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="accounting-search-modal-label">{{ A_("Search") }}</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-floating">
|
||||
<input id="accounting-search-modal-search" class="form-control" type="text" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
|
||||
<label for="accounting-search-modal-search">{{ A_("Search") }}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
|
||||
<button type="submit" class="btn btn-primary">{{ A_("Search") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
@ -0,0 +1,130 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
toolbar-buttons.html: The toolbar buttons on the report
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/8
|
||||
#}
|
||||
{% if accounting_can_edit() %}
|
||||
<div class="btn-group d-none d-md-flex" role="group">
|
||||
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
<span class="d-none d-md-inline">{{ A_("New") }}</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
|
||||
{{ A_("Cash Expense") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
|
||||
{{ A_("Cash Income") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
|
||||
{{ A_("Transfer") }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="fa-solid fa-book"></i>
|
||||
<span class="d-none d-md-inline">{{ report.report_chooser.current_report }}</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-label="{{ A_("Report") }}">
|
||||
{% for report in report.report_chooser %}
|
||||
<li>
|
||||
<a class="dropdown-item {% if report.is_active %} active {% endif %}" href="{{ report.url }}">
|
||||
<i class="{{ report.fa_icon }}"></i>
|
||||
{{ report.title }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li>
|
||||
<span class="dropdown-item {% if report.report_chooser.is_search %} active {% endif %} accounting-clickable" data-bs-toggle="modal" data-bs-target="#accounting-search-modal">
|
||||
<i class="fa-solid fa-magnifying-glass"></i>
|
||||
{{ A_("Search") }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% if use_currency_chooser %}
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="fa-solid fa-money-bill-wave"></i>
|
||||
<span class="d-none d-md-inline">{{ report.currency.name|title }}</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-label="{{ A_("Currency") }}">
|
||||
{% for currency in report.currency_options %}
|
||||
<li>
|
||||
<a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
|
||||
{{ currency.title }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if use_account_chooser %}
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="fa-solid fa-clipboard"></i>
|
||||
<span class="d-none d-md-inline">{{ report.account.title|title }}</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-label="{{ A_("Account") }}">
|
||||
{% for account in report.account_options %}
|
||||
<li>
|
||||
<a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}">
|
||||
{{ account.title|title }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if use_period_chooser %}
|
||||
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-period-chooser-modal">
|
||||
<i class="fa-solid fa-calendar-day"></i>
|
||||
<span class="d-none d-md-inline">{{ report.period.desc|title }}</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if report.has_data %}
|
||||
<a class="btn btn-primary" role="button" href="{{ report.csv_uri }}">
|
||||
<i class="fa-solid fa-download"></i>
|
||||
<span class="d-none d-md-inline">{{ A_("Download") }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="btn btn-secondary" type="button" disabled="disabled">
|
||||
<i class="fa-solid fa-download"></i>
|
||||
<span class="d-none d-md-inline">{{ A_("Download") }}</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if use_search %}
|
||||
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.report.search") }}" 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">
|
||||
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
|
||||
<button type="submit">
|
||||
<i class="fa-solid fa-magnifying-glass"></i>
|
||||
<span class="d-none d-md-inline">{{ A_("Search") }}</span>
|
||||
</button>
|
||||
</label>
|
||||
</form>
|
||||
{% endif %}
|
116
src/accounting/templates/accounting/report/income-expenses.html
Normal file
116
src/accounting/templates/accounting/report/income-expenses.html
Normal file
@ -0,0 +1,116 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
income-expenses.html: The income and expenses log
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/5
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block accounting_scripts %}
|
||||
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Income and Expenses Log of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="mb-3 accounting-toolbar">
|
||||
{% with use_currency_chooser = true,
|
||||
use_account_chooser = true,
|
||||
use_period_chooser = true %}
|
||||
{% include "accounting/report/include/toolbar-buttons.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
{% include "accounting/report/include/add-txn-material-fab.html" %}
|
||||
|
||||
{% include "accounting/report/include/period-chooser.html" %}
|
||||
|
||||
{% include "accounting/report/include/search-modal.html" %}
|
||||
|
||||
{% if report.has_data %}
|
||||
{% with pagination = report.pagination %}
|
||||
{% include "accounting/include/pagination.html" %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="d-none d-md-block accounting-report-table accounting-income-expenses-table">
|
||||
<div class="accounting-report-table-header">
|
||||
<div class="accounting-report-table-row">
|
||||
<div>{{ A_("Date") }}</div>
|
||||
<div>{{ A_("Account") }}</div>
|
||||
<div>{{ A_("Summary") }}</div>
|
||||
<div class="accounting-amount">{{ A_("Income") }}</div>
|
||||
<div class="accounting-amount">{{ A_("Expense") }}</div>
|
||||
<div class="accounting-amount">{{ A_("Balance") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accounting-report-table-body">
|
||||
{% if report.brought_forward %}
|
||||
{% with entry = report.brought_forward %}
|
||||
<div class="accounting-report-table-row">
|
||||
{% include "accounting/report/include/income-expenses-row-desktop.html" %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% for entry in report.entries %}
|
||||
<a class="accounting-report-table-row" href="{{ entry.url|accounting_append_next }}">
|
||||
{% include "accounting/report/include/income-expenses-row-desktop.html" %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if report.total %}
|
||||
{% with entry = report.total %}
|
||||
<div class="accounting-report-table-footer">
|
||||
<div class="accounting-report-table-row">
|
||||
<div>{{ A_("Total") }}</div>
|
||||
<div class="accounting-amount">{{ entry.income|accounting_format_amount }}</div>
|
||||
<div class="accounting-amount">{{ entry.expense|accounting_format_amount }}</div>
|
||||
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="list-group d-md-none">
|
||||
{% if report.brought_forward %}
|
||||
{% with entry = report.brought_forward %}
|
||||
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||
{% include "accounting/report/include/income-expenses-row-mobile.html" %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% for entry in report.entries %}
|
||||
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ entry.url|accounting_append_next }}">
|
||||
{% include "accounting/report/include/income-expenses-row-mobile.html" %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% if report.total %}
|
||||
{% with entry = report.total %}
|
||||
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||
{% include "accounting/report/include/income-expenses-row-mobile.html" %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>{{ A_("There is no data.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
@ -0,0 +1,99 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
income-statement.html: The income statement
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/7
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block accounting_scripts %}
|
||||
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Income Statement of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="mb-3 accounting-toolbar">
|
||||
{% with use_currency_chooser = true,
|
||||
use_period_chooser = true %}
|
||||
{% include "accounting/report/include/toolbar-buttons.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
{% include "accounting/report/include/add-txn-material-fab.html" %}
|
||||
|
||||
{% include "accounting/report/include/period-chooser.html" %}
|
||||
|
||||
{% include "accounting/report/include/search-modal.html" %}
|
||||
|
||||
{% if report.has_data %}
|
||||
<div class="accounting-sheet">
|
||||
<div class="d-none d-sm-flex justify-content-center mb-3">
|
||||
<h2 class="text-center">{{ A_("Income Statement of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="accounting-report-table accounting-income-statement-table">
|
||||
<div class="accounting-report-table-header">
|
||||
<div class="accounting-report-table-row">
|
||||
<div class="accounting-amount">{{ A_("Amount") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accounting-report-table-body">
|
||||
{% for section in report.sections %}
|
||||
<div class="accounting-report-table-row accounting-income-statement-section">
|
||||
<div>
|
||||
<span class="d-none d-md-inline">{{ section.title.code }}</span>
|
||||
{{ section.title.title|title }}
|
||||
</div>
|
||||
</div>
|
||||
{% for subsection in section.subsections %}
|
||||
<div class="accounting-report-table-row accounting-income-statement-subsection">
|
||||
<div>
|
||||
<span class="d-none d-md-inline">{{ subsection.title.code }}</span>
|
||||
{{ subsection.title.title|title }}
|
||||
</div>
|
||||
</div>
|
||||
{% for account in subsection.accounts %}
|
||||
<a class="accounting-report-table-row accounting-income-statement-account" href="{{ account.url }}">
|
||||
<div>
|
||||
<span class="d-none d-md-inline">{{ account.account.code }}</span>
|
||||
{{ account.account.title|title }}
|
||||
</div>
|
||||
<div class="accounting-amount {% if account.amount < 0 %} text-danger {% endif %}">{{ account.amount|accounting_report_format_amount }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
<div class="accounting-report-table-row accounting-income-statement-subtotal">
|
||||
<div>{{ A_("Total") }}</div>
|
||||
<div class="accounting-amount {% if subsection.total < 0 %} text-danger {% endif %}">{{ subsection.total|accounting_report_format_amount }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="accounting-report-table-row accounting-income-statement-total">
|
||||
<div>{{ section.accumulated.title|title }}</div>
|
||||
<div class="accounting-amount {% if section.accumulated.amount < 0 %} text-danger {% endif %}">{{ section.accumulated.amount|accounting_report_format_amount }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>{{ A_("There is no data.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
106
src/accounting/templates/accounting/report/journal.html
Normal file
106
src/accounting/templates/accounting/report/journal.html
Normal file
@ -0,0 +1,106 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
journal.html: The journal
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/4
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block accounting_scripts %}
|
||||
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Journal %(period)s", period=report.period.desc|title) }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="mb-3 accounting-toolbar">
|
||||
{% with use_period_chooser = true %}
|
||||
{% include "accounting/report/include/toolbar-buttons.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
{% include "accounting/report/include/add-txn-material-fab.html" %}
|
||||
|
||||
{% include "accounting/report/include/period-chooser.html" %}
|
||||
|
||||
{% include "accounting/report/include/search-modal.html" %}
|
||||
|
||||
{% if report.has_data %}
|
||||
{% with pagination = report.pagination %}
|
||||
{% include "accounting/include/pagination.html" %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="d-none d-md-block accounting-report-table accounting-journal-table">
|
||||
<div class="accounting-report-table-header">
|
||||
<div class="accounting-report-table-row">
|
||||
<div>{{ A_("Date") }}</div>
|
||||
<div>{{ A_("Currency") }}</div>
|
||||
<div>{{ A_("Account") }}</div>
|
||||
<div>{{ A_("Summary") }}</div>
|
||||
<div class="accounting-amount">{{ A_("Debit") }}</div>
|
||||
<div class="accounting-amount">{{ A_("Credit") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accounting-report-table-body">
|
||||
{% for entry in report.entries %}
|
||||
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
|
||||
<div>{{ entry.transaction.date|accounting_format_date }}</div>
|
||||
<div>{{ entry.currency.name }}</div>
|
||||
<div>
|
||||
<span class="d-none d-md-inline">{{ entry.account.code }}</span>
|
||||
{{ entry.account.title|title }}
|
||||
</div>
|
||||
<div>{{ entry.summary|accounting_default }}</div>
|
||||
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
|
||||
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-group d-md-none">
|
||||
{% for entry in report.entries %}
|
||||
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div {% if not entry.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
|
||||
<div class="text-muted small">
|
||||
{{ entry.transaction.date|accounting_format_date }}
|
||||
{{ entry.account.title|title }}
|
||||
{% if entry.currency.code != accounting_default_currency_code() %}
|
||||
<span class="badge rounded-pill bg-info">{{ entry.currency.code }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if entry.summary is not none %}
|
||||
<div>{{ entry.summary }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="badge rounded-pill bg-info">{{ entry.amount|accounting_format_amount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>{{ A_("There is no data.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
115
src/accounting/templates/accounting/report/ledger.html
Normal file
115
src/accounting/templates/accounting/report/ledger.html
Normal file
@ -0,0 +1,115 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
ledger.html: The ledger
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/5
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block accounting_scripts %}
|
||||
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Ledger of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="mb-3 accounting-toolbar">
|
||||
{% with use_currency_chooser = true,
|
||||
use_account_chooser = true,
|
||||
use_period_chooser = true %}
|
||||
{% include "accounting/report/include/toolbar-buttons.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
{% include "accounting/report/include/add-txn-material-fab.html" %}
|
||||
|
||||
{% include "accounting/report/include/period-chooser.html" %}
|
||||
|
||||
{% include "accounting/report/include/search-modal.html" %}
|
||||
|
||||
{% if report.has_data %}
|
||||
{% with pagination = report.pagination %}
|
||||
{% include "accounting/include/pagination.html" %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="d-none d-md-block accounting-report-table accounting-ledger-table">
|
||||
<div class="accounting-report-table-header">
|
||||
<div class="accounting-report-table-row">
|
||||
<div>{{ A_("Date") }}</div>
|
||||
<div>{{ A_("Summary") }}</div>
|
||||
<div class="accounting-amount">{{ A_("Debit") }}</div>
|
||||
<div class="accounting-amount">{{ A_("Credit") }}</div>
|
||||
<div class="accounting-amount">{{ A_("Balance") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accounting-report-table-body">
|
||||
{% if report.brought_forward %}
|
||||
{% with entry = report.brought_forward %}
|
||||
<div class="accounting-report-table-row">
|
||||
{% include "accounting/report/include/ledger-row-desktop.html" %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% for entry in report.entries %}
|
||||
<a class="accounting-report-table-row" href="{{ entry.url|accounting_append_next }}">
|
||||
{% include "accounting/report/include/ledger-row-desktop.html" %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if report.total %}
|
||||
{% with entry = report.total %}
|
||||
<div class="accounting-report-table-footer">
|
||||
<div class="accounting-report-table-row">
|
||||
<div>{{ A_("Total") }}</div>
|
||||
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
|
||||
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
|
||||
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="list-group d-md-none">
|
||||
{% if report.brought_forward %}
|
||||
{% with entry = report.brought_forward %}
|
||||
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||
{% include "accounting/report/include/ledger-row-mobile.html" %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% for entry in report.entries %}
|
||||
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ entry.url|accounting_append_next }}">
|
||||
{% include "accounting/report/include/ledger-row-mobile.html" %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% if report.total %}
|
||||
{% with entry = report.total %}
|
||||
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||
{% include "accounting/report/include/ledger-row-mobile.html" %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>{{ A_("There is no data.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
103
src/accounting/templates/accounting/report/search.html
Normal file
103
src/accounting/templates/accounting/report/search.html
Normal file
@ -0,0 +1,103 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
search.html: The search result
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/8
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block accounting_scripts %}
|
||||
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Search Result for \"%(query)s\"", query=request.args.q) }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="mb-3 accounting-toolbar">
|
||||
{% with use_search = true %}
|
||||
{% include "accounting/report/include/toolbar-buttons.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
{% include "accounting/report/include/add-txn-material-fab.html" %}
|
||||
|
||||
{% include "accounting/report/include/search-modal.html" %}
|
||||
|
||||
{% if report.has_data %}
|
||||
{% with pagination = report.pagination %}
|
||||
{% include "accounting/include/pagination.html" %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="d-none d-md-block accounting-report-table accounting-journal-table">
|
||||
<div class="accounting-report-table-header">
|
||||
<div class="accounting-report-table-row">
|
||||
<div>{{ A_("Date") }}</div>
|
||||
<div>{{ A_("Currency") }}</div>
|
||||
<div>{{ A_("Account") }}</div>
|
||||
<div>{{ A_("Summary") }}</div>
|
||||
<div class="accounting-amount">{{ A_("Debit") }}</div>
|
||||
<div class="accounting-amount">{{ A_("Credit") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accounting-report-table-body">
|
||||
{% for entry in report.entries %}
|
||||
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
|
||||
<div>{{ entry.transaction.date|accounting_format_date }}</div>
|
||||
<div>{{ entry.currency.name }}</div>
|
||||
<div>
|
||||
<span class="d-none d-md-inline">{{ entry.account.code }}</span>
|
||||
{{ entry.account.title|title }}
|
||||
</div>
|
||||
<div>{{ entry.summary|accounting_default }}</div>
|
||||
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
|
||||
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-group d-md-none">
|
||||
{% for entry in report.entries %}
|
||||
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div {% if not entry.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
|
||||
<div class="text-muted small">
|
||||
{{ entry.transaction.date|accounting_format_date }}
|
||||
{{ entry.account.title|title }}
|
||||
{% if entry.currency.code != accounting_default_currency_code() %}
|
||||
<span class="badge rounded-pill bg-info">{{ entry.currency.code }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if entry.summary is not none %}
|
||||
<div>{{ entry.summary }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="badge rounded-pill bg-info">{{ entry.amount|accounting_format_amount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>{{ A_("There is no data.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
@ -0,0 +1,85 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
trial-balance.html: The trial balance
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/5
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block accounting_scripts %}
|
||||
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Trial Balance of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="mb-3 accounting-toolbar">
|
||||
{% with use_currency_chooser = true,
|
||||
use_period_chooser = true %}
|
||||
{% include "accounting/report/include/toolbar-buttons.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
{% include "accounting/report/include/add-txn-material-fab.html" %}
|
||||
|
||||
{% include "accounting/report/include/period-chooser.html" %}
|
||||
|
||||
{% include "accounting/report/include/search-modal.html" %}
|
||||
|
||||
{% if report.has_data %}
|
||||
<div class="accounting-sheet">
|
||||
<div class="d-none d-sm-flex justify-content-center mb-3">
|
||||
<h2 class="text-center">{{ A_("Trial Balance of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="accounting-report-table accounting-trial-balance-table">
|
||||
<div class="accounting-report-table-header">
|
||||
<div class="accounting-report-table-row">
|
||||
<div>{{ A_("Account") }}</div>
|
||||
<div class="accounting-amount">{{ A_("Debit") }}</div>
|
||||
<div class="accounting-amount">{{ A_("Credit") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accounting-report-table-body">
|
||||
{% for account in report.accounts %}
|
||||
<a class="accounting-report-table-row" href="{{ account.url }}">
|
||||
<div>
|
||||
<span class="d-none d-md-inline">{{ account.account.code }}</span>
|
||||
{{ account.account.title|title }}
|
||||
</div>
|
||||
<div class="accounting-amount">{{ account.debit|accounting_format_amount|accounting_default }}</div>
|
||||
<div class="accounting-amount">{{ account.credit|accounting_format_amount|accounting_default }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="accounting-report-table-footer">
|
||||
<div class="accounting-report-table-row">
|
||||
<div>{{ A_("Total") }}</div>
|
||||
<div class="accounting-amount">{{ report.total.debit|accounting_format_amount }}</div>
|
||||
<div class="accounting-amount">{{ report.total.credit|accounting_format_amount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>{{ A_("There is no data.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
@ -23,6 +23,6 @@ First written: 2023/2/25
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Add a New Cash Expense Transaction") }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting.transaction.list") }}{% endblock %}
|
||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
|
||||
|
||||
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}
|
||||
|
@ -44,14 +44,14 @@ First written: 2023/2/26
|
||||
<div>{{ entry.summary }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>{{ entry.amount|accounting_txn_format_amount }}</div>
|
||||
<div>{{ entry.amount|accounting_format_amount }}</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>{{ A_("Total") }}</div>
|
||||
<div>{{ currency.debit_total|accounting_txn_format_amount }}</div>
|
||||
<div>{{ currency.debit_total|accounting_format_amount }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
@ -25,7 +25,7 @@ First written: 2023/2/25
|
||||
<div class="d-flex justify-content-between mt-2 mb-3">
|
||||
<div class="form-floating accounting-currency-content">
|
||||
<select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code">
|
||||
{% for currency in accounting_txn_currency_options() %}
|
||||
{% for currency in accounting_currency_options() %}
|
||||
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
@ -50,14 +50,14 @@ First written: 2023/2/25
|
||||
entry_index = loop.index,
|
||||
entry_id = entry_form.eid.data,
|
||||
only_one_entry_form = debit_forms|length == 1,
|
||||
account_code_data = "" if entry_form.account_code.data is none else entry_form.account_code.data,
|
||||
account_code_data = entry_form.account_code.data|accounting_default,
|
||||
account_code_error = entry_form.account_code.errors,
|
||||
account_text = entry_form.account_text,
|
||||
summary_data = "" if entry_form.summary.data is none else entry_form.summary.data,
|
||||
summary_data = entry_form.summary.data|accounting_default,
|
||||
summary_errors = entry_form.summary.errors,
|
||||
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data|accounting_txn_format_amount_input,
|
||||
amount_data = entry_form.amount.data|accounting_txn_format_amount_input,
|
||||
amount_errors = entry_form.amount.errors,
|
||||
amount_text = entry_form.amount.data|accounting_txn_format_amount,
|
||||
amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"),
|
||||
entry_errors = entry_form.all_errors %}
|
||||
{% include "accounting/transaction/include/form-entry-item.html" %}
|
||||
{% endwith %}
|
||||
|
@ -31,14 +31,14 @@ First written: 2023/2/25
|
||||
currency_code_errors = currency_form.code.errors,
|
||||
debit_forms = currency_form.debit,
|
||||
debit_errors = currency_form.debit_errors,
|
||||
debit_total = currency_form.form.debit_total|accounting_txn_format_amount %}
|
||||
debit_total = currency_form.form.debit_total|accounting_format_amount %}
|
||||
{% include "accounting/transaction/expense/include/form-currency-item.html" %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% with currency_index = 1,
|
||||
only_one_currency_form = True,
|
||||
currency_code_data = accounting_txn_default_currency_code(),
|
||||
currency_code_data = accounting_default_currency_code(),
|
||||
debit_total = "-" %}
|
||||
{% include "accounting/transaction/expense/include/form-currency-item.html" %}
|
||||
{% endwith %}
|
||||
@ -46,8 +46,8 @@ First written: 2023/2/25
|
||||
{% endblock %}
|
||||
|
||||
{% block form_modals %}
|
||||
{% with summary_helper = form.summary_helper.debit %}
|
||||
{% include "accounting/transaction/include/summary-helper-modal.html" %}
|
||||
{% with summary_editor = form.summary_editor.debit %}
|
||||
{% include "accounting/transaction/include/summary-editor-modal.html" %}
|
||||
{% endwith %}
|
||||
{% with entry_type = "debit",
|
||||
account_options = form.debit_account_options %}
|
||||
|
@ -26,7 +26,7 @@ First written: 2023/2/26
|
||||
{% block content %}
|
||||
|
||||
<div class="btn-group mb-3">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.list")|accounting_or_next }}">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.report.default")|accounting_or_next }}">
|
||||
<i class="fa-solid fa-circle-chevron-left"></i>
|
||||
{{ A_("Back") }}
|
||||
</a>
|
||||
@ -60,8 +60,8 @@ First written: 2023/2/26
|
||||
{% if accounting_can_edit() %}
|
||||
<form action="{{ url_for("accounting.transaction.delete", txn=obj) }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% if "next" in request.args %}
|
||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
||||
{% if request.args.next %}
|
||||
<input type="hidden" name="next" value="{{ request.args.next }}">
|
||||
{% endif %}
|
||||
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
@ -83,13 +83,13 @@ First written: 2023/2/26
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="accounting-transaction-card">
|
||||
<div class="accounting-sheet">
|
||||
<div class="d-none d-sm-flex justify-content-center mb-3">
|
||||
<h2 class="text-center">{{ obj }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ obj.date|accounting_txn_format_date }}
|
||||
{{ obj.date|accounting_format_date }}
|
||||
</div>
|
||||
|
||||
{% block transaction_currencies %}{% endblock %}
|
||||
|
@ -32,7 +32,7 @@ First written: 2023/2/25
|
||||
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-control" class="form-control clickable d-flex justify-content-between accounting-entry-control {% if entry_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
|
||||
<div>
|
||||
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-text" class="small">{{ account_text }}</div>
|
||||
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary-text">{{ "" if summary_data is none else summary_data }}</div>
|
||||
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary-text">{{ summary_data }}</div>
|
||||
</div>
|
||||
<div><span id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount-text" class="badge rounded-pill bg-primary">{{ amount_text }}</span></div>
|
||||
</div>
|
||||
|
@ -25,12 +25,12 @@ First written: 2023/2/26
|
||||
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/transaction-form.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/summary-helper.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/summary-editor.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="btn-group btn-actions mb-3">
|
||||
<div class="btn-group mb-3">
|
||||
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
|
||||
<i class="fa-solid fa-circle-chevron-left"></i>
|
||||
{{ A_("Back") }}
|
||||
@ -39,12 +39,12 @@ First written: 2023/2/26
|
||||
|
||||
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post" data-currency-template="{{ currency_template }}" data-entry-template="{{ entry_template }}">
|
||||
{{ form.csrf_token }}
|
||||
{% if "next" in request.args %}
|
||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
||||
{% if request.args.next %}
|
||||
<input type="hidden" name="next" value="{{ request.args.next }}">
|
||||
{% endif %}
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
<input id="accounting-date" class="form-control {% if form.date.errors %} is-invalid {% endif %}" type="date" name="date" value="{{ "" if form.date.data is none else form.date.data }}" placeholder=" " required="required">
|
||||
<input id="accounting-date" class="form-control {% if form.date.errors %} is-invalid {% endif %}" type="date" name="date" value="{{ form.date.data|accounting_default }}" placeholder=" " required="required">
|
||||
<label class="form-label" for="accounting-date">{{ A_("Date") }}</label>
|
||||
<div id="accounting-date-error" class="invalid-feedback">{% if form.date.errors %}{{ form.date.errors[0] }}{% endif %}</div>
|
||||
</div>
|
||||
@ -67,7 +67,7 @@ First written: 2023/2/26
|
||||
</div>
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
<textarea id="accounting-note" class="form-control form-control-lg {% if form.note.errors %} is-invalid {% endif %}" name="note" rows="5" placeholder=" ">{{ "" if form.note.data is none else form.note.data }}</textarea>
|
||||
<textarea id="accounting-note" class="form-control form-control-lg {% if form.note.errors %} is-invalid {% endif %}" name="note" rows="5" placeholder=" ">{{ form.note.data|accounting_default }}</textarea>
|
||||
<label class="form-label" for="accounting-note">{{ A_("Note") }}</label>
|
||||
<div id="accounting-note-error" class="invalid-feedback">{% if form.note.errors %}{{ form.note.errors[0] }}{% endif %}</div>
|
||||
</div>
|
||||
|
@ -0,0 +1,190 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
summary-editor-modal.html: The modal of the summary editor
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/2/28
|
||||
#}
|
||||
<form id="accounting-summary-editor-{{ summary_editor.type }}" class="accounting-summary-editor" data-entry-type="{{ summary_editor.type }}">
|
||||
<div id="accounting-summary-editor-{{ summary_editor.type }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-modal-label" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="accounting-summary-editor-{{ summary_editor.type }}-modal-label">
|
||||
<label for="accounting-summary-editor-{{ summary_editor.type }}-summary">{{ A_("Summary") }}</label>
|
||||
</h1>
|
||||
<button class="btn-close" type="button" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<input id="accounting-summary-editor-{{ summary_editor.type }}-summary" class="form-control" type="text" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-modal-label">
|
||||
</div>
|
||||
|
||||
{# Tab navigation #}
|
||||
<ul class="nav nav-tabs mb-2">
|
||||
<li class="nav-item">
|
||||
<span id="accounting-summary-editor-{{ summary_editor.type }}-general-tab" class="nav-link active accounting-clickable" aria-current="page">
|
||||
{{ A_("General") }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<span id="accounting-summary-editor-{{ summary_editor.type }}-travel-tab" class="nav-link accounting-clickable" aria-current="false">
|
||||
{{ A_("Travel") }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<span id="accounting-summary-editor-{{ summary_editor.type }}-bus-tab" class="nav-link accounting-clickable" aria-current="false">
|
||||
{{ A_("Bus") }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<span id="accounting-summary-editor-{{ summary_editor.type }}-regular-tab" class="nav-link accounting-clickable" aria-current="false">
|
||||
{{ A_("Regular") }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<span id="accounting-summary-editor-{{ summary_editor.type }}-annotation-tab" class="nav-link accounting-clickable" aria-current="false">
|
||||
{{ A_("Annotation") }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{# A general summary with a tag #}
|
||||
<div id="accounting-summary-editor-{{ summary_editor.type }}-general-page" aria-current="page" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-general-tab">
|
||||
<div class="form-floating mb-2">
|
||||
<input id="accounting-summary-editor-{{ summary_editor.type }}-general-tag" class="form-control" type="text" value="" placeholder=" ">
|
||||
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-general-tag">{{ A_("Tag") }}</label>
|
||||
<div id="accounting-summary-editor-{{ summary_editor.type }}-general-tag-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{% for tag in summary_editor.general.tags %}
|
||||
<button class="btn btn-outline-primary accounting-summary-editor-{{ summary_editor.type }}-general-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
|
||||
{{ tag }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# A general trip with the origin and distination #}
|
||||
<div id="accounting-summary-editor-{{ summary_editor.type }}-travel-page" class="d-none" aria-current="false" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-travel-tab">
|
||||
<div class="form-floating mb-2">
|
||||
<input id="accounting-summary-editor-{{ summary_editor.type }}-travel-tag" class="form-control" type="text" value="" placeholder=" ">
|
||||
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-travel-tag">{{ A_("Tag") }}</label>
|
||||
<div id="accounting-summary-editor-{{ summary_editor.type }}-travel-tag-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{% for tag in summary_editor.travel.tags %}
|
||||
<button class="btn btn-outline-primary accounting-summary-editor-{{ summary_editor.type }}-travel-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
|
||||
{{ tag }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mt-2">
|
||||
<div class="form-floating">
|
||||
<input id="accounting-summary-editor-{{ summary_editor.type }}-travel-from" class="form-control" type="text" value="" placeholder=" ">
|
||||
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-travel-from">{{ A_("From") }}</label>
|
||||
<div id="accounting-summary-editor-{{ summary_editor.type }}-travel-from-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
<div class="btn-group-vertical ms-1 me-1">
|
||||
<button class="btn btn-primary accounting-summary-editor-{{ summary_editor.type }}-travel-direction accounting-default" type="button" tabindex="-1" data-arrow="→">→</button>
|
||||
<button class="btn btn-outline-primary accounting-summary-editor-{{ summary_editor.type }}-travel-direction" type="button" tabindex="-1" data-arrow="↔">↔</button>
|
||||
</div>
|
||||
<div class="form-floating">
|
||||
<input id="accounting-summary-editor-{{ summary_editor.type }}-travel-to" class="form-control" type="text" value="" placeholder=" ">
|
||||
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-travel-to">{{ A_("To") }}</label>
|
||||
<div id="accounting-summary-editor-{{ summary_editor.type }}-travel-to-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# A bus trip with the route name or route number, the origin and distination #}
|
||||
<div id="accounting-summary-editor-{{ summary_editor.type }}-bus-page" class="d-none" aria-current="false" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-bus-tab">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<div class="form-floating me-2">
|
||||
<input id="accounting-summary-editor-{{ summary_editor.type }}-bus-tag" class="form-control" type="text" value="" placeholder=" ">
|
||||
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-bus-tag">{{ A_("Tag") }}</label>
|
||||
<div id="accounting-summary-editor-{{ summary_editor.type }}-bus-tag-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
<div class="form-floating">
|
||||
<input id="accounting-summary-editor-{{ summary_editor.type }}-bus-route" class="form-control" type="text" value="" placeholder=" ">
|
||||
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-bus-route">{{ A_("Route") }}</label>
|
||||
<div id="accounting-summary-editor-{{ summary_editor.type }}-bus-route-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{% for tag in summary_editor.bus.tags %}
|
||||
<button class="btn btn-outline-primary accounting-summary-editor-{{ summary_editor.type }}-bus-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
|
||||
{{ tag }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mt-2">
|
||||
<div class="form-floating me-2">
|
||||
<input id="accounting-summary-editor-{{ summary_editor.type }}-bus-from" class="form-control" type="text" value="" placeholder=" ">
|
||||
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-bus-from">{{ A_("From") }}</label>
|
||||
<div id="accounting-summary-editor-{{ summary_editor.type }}-bus-from-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
<div class="form-floating">
|
||||
<input id="accounting-summary-editor-{{ summary_editor.type }}-bus-to" class="form-control" type="text" value="" placeholder=" ">
|
||||
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-bus-to">{{ A_("To") }}</label>
|
||||
<div id="accounting-summary-editor-{{ summary_editor.type }}-bus-to-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# A regular income or payment #}
|
||||
<div id="accounting-summary-editor-{{ summary_editor.type }}-regular-page" class="d-none" aria-current="false" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-regular-tab">
|
||||
{# TODO: To be done #}
|
||||
</div>
|
||||
|
||||
{# The annotation #}
|
||||
<div id="accounting-summary-editor-{{ summary_editor.type }}-annotation-page" class="d-none" aria-current="false" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-annotation-tab">
|
||||
<div class="form-floating">
|
||||
<input id="accounting-summary-editor-{{ summary_editor.type }}-annotation-number" class="form-control" type="number" min="1" value="" placeholder=" ">
|
||||
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-annotation-number">{{ A_("The number of items") }}</label>
|
||||
<div id="accounting-summary-editor-{{ summary_editor.type }}-annotation-number-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-floating mt-2">
|
||||
<input id="accounting-summary-editor-{{ summary_editor.type }}-annotation-note" class="form-control" type="text" value="" placeholder=" ">
|
||||
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-annotation-note">{{ A_("Note") }}</label>
|
||||
<div id="accounting-summary-editor-{{ summary_editor.type }}-annotation-note-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# The suggested accounts #}
|
||||
<div class="mt-3">
|
||||
{% for account in summary_editor.accounts %}
|
||||
<button class="btn btn-outline-primary d-none accounting-summary-editor-{{ summary_editor.type }}-account" type="button" data-code="{{ account.code }}" data-text="{{ account }}">
|
||||
{{ account }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button>
|
||||
<button id="accounting-summary-editor-{{ summary_editor.type }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
@ -1,181 +0,0 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
entry-form-modal.html: The modal of the summary helper
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/2/28
|
||||
#}
|
||||
<form id="accounting-summary-helper-{{ summary_helper.type }}" class="accounting-summary-helper" data-entry-type="{{ summary_helper.type }}" data-default-tab-id="general" data-selected-account-code="" data-selected-account-text="">
|
||||
<div id="accounting-summary-helper-{{ summary_helper.type }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-modal-label" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="accounting-summary-helper-{{ summary_helper.type }}-modal-label">
|
||||
<label for="accounting-summary-helper-{{ summary_helper.type }}-summary">{{ A_("Summary") }}</label>
|
||||
</h1>
|
||||
<button class="btn-close accounting-summary-helper-{{ summary_helper.type }}-close" type="button" data-bs-toggle="modal" data-bs-target="" aria-label="{{ A_("Close") }}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<input id="accounting-summary-helper-{{ summary_helper.type }}-summary" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-modal-label">
|
||||
</div>
|
||||
<ul class="nav nav-tabs mb-2">
|
||||
<li class="nav-item">
|
||||
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-general" class="nav-link active accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="page" data-tab-id="general">
|
||||
{{ A_("General") }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-travel" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="travel">
|
||||
{{ A_("Travel") }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-bus" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="bus">
|
||||
{{ A_("Bus") }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-regular" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="regular">
|
||||
{{ A_("Regular") }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-number" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="number">
|
||||
{{ A_("Number") }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
{# A general summary with a tag #}
|
||||
<div class="accounting-summary-helper-{{ summary_helper.type }}-page" aria-current="page" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-general" data-tab-id="general">
|
||||
<div class="form-floating mb-2">
|
||||
<input id="accounting-summary-helper-{{ summary_helper.type }}-general-tag" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
|
||||
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-general-tag">{{ A_("Tag") }}</label>
|
||||
<div id="accounting-summary-helper-{{ summary_helper.type }}-general-tag-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{% for tag in summary_helper.general.tags %}
|
||||
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-btn-tag accounting-summary-helper-{{ summary_helper.type }}-general-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
|
||||
{{ tag }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# A general trip with the origin and distination #}
|
||||
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-travel" data-tab-id="travel">
|
||||
<div class="form-floating mb-2">
|
||||
<input id="accounting-summary-helper-{{ summary_helper.type }}-travel-tag" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
|
||||
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-travel-tag">{{ A_("Tag") }}</label>
|
||||
<div id="accounting-summary-helper-{{ summary_helper.type }}-travel-tag-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{% for tag in summary_helper.travel.tags %}
|
||||
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-btn-tag accounting-summary-helper-{{ summary_helper.type }}-travel-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
|
||||
{{ tag }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mt-2">
|
||||
<div class="form-floating">
|
||||
<input id="accounting-summary-helper-{{ summary_helper.type }}-travel-from" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
|
||||
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-travel-from">{{ A_("From") }}</label>
|
||||
<div id="accounting-summary-helper-{{ summary_helper.type }}-travel-from-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
<div class="btn-group-vertical ms-1 me-1">
|
||||
<button class="btn btn-primary accounting-summary-helper-{{ summary_helper.type }}-travel-direction accounting-default" type="button" tabindex="-1" data-arrow="→">→</button>
|
||||
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-travel-direction" type="button" tabindex="-1" data-arrow="↔">↔</button>
|
||||
</div>
|
||||
<div class="form-floating">
|
||||
<input id="accounting-summary-helper-{{ summary_helper.type }}-travel-to" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
|
||||
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-travel-to">{{ A_("To") }}</label>
|
||||
<div id="accounting-summary-helper-{{ summary_helper.type }}-travel-to-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# A bus trip with the route name or route number, the origin and distination #}
|
||||
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-bus" data-tab-id="bus">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<div class="form-floating me-2">
|
||||
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-tag" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
|
||||
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-tag">{{ A_("Tag") }}</label>
|
||||
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-tag-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
<div class="form-floating">
|
||||
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-route" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
|
||||
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-route">{{ A_("Route") }}</label>
|
||||
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-route-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{% for tag in summary_helper.bus.tags %}
|
||||
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-btn-tag accounting-summary-helper-{{ summary_helper.type }}-bus-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
|
||||
{{ tag }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mt-2">
|
||||
<div class="form-floating me-2">
|
||||
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-from" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
|
||||
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-from">{{ A_("From") }}</label>
|
||||
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-from-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
<div class="form-floating">
|
||||
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-to" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
|
||||
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-to">{{ A_("To") }}</label>
|
||||
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-to-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# A regular income/payment #}
|
||||
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-regular" data-tab-id="regular">
|
||||
{# TODO: To be done #}
|
||||
</div>
|
||||
|
||||
{# The number of items #}
|
||||
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-number" data-tab-id="number">
|
||||
<div class="form-floating">
|
||||
<input id="accounting-summary-helper-{{ summary_helper.type }}-number" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="number" min="1" value="" placeholder=" ">
|
||||
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-number">{{ A_("The number of items") }}</label>
|
||||
<div id="accounting-summary-helper-{{ summary_helper.type }}-number-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# The suggested accounts #}
|
||||
<div class="mt-3">
|
||||
{% for account in summary_helper.accounts %}
|
||||
<button class="btn btn-outline-primary d-none accounting-summary-helper-{{ summary_helper.type }}-account" type="button" data-code="{{ account.code }}" data-text="{{ account }}">
|
||||
{{ account }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary accounting-summary-helper-{{ summary_helper.type }}-close" type="button" data-bs-toggle="modal" data-bs-target="">{{ A_("Cancel") }}</button>
|
||||
<button id="accounting-summary-helper-{{ summary_helper.type }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
@ -23,6 +23,6 @@ First written: 2023/2/25
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Add a New Cash Income Transaction") }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting.transaction.list") }}{% endblock %}
|
||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
|
||||
|
||||
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}
|
||||
|
@ -44,14 +44,14 @@ First written: 2023/2/26
|
||||
<div>{{ entry.summary }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>{{ entry.amount|accounting_txn_format_amount }}</div>
|
||||
<div>{{ entry.amount|accounting_format_amount }}</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>{{ A_("Total") }}</div>
|
||||
<div>{{ currency.debit_total|accounting_txn_format_amount }}</div>
|
||||
<div>{{ currency.debit_total|accounting_format_amount }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user