Compare commits
90 Commits
2ab60b2224
...
v0.4.0
Author | SHA1 | Date | |
---|---|---|---|
f3548a2327 | |||
79883d6940 | |||
b2bc993416 | |||
453b3f0da5 | |||
63ae3f0746 | |||
da4cc6489f | |||
1102a3a4f3 | |||
1402a12f04 | |||
f049b5d7ee | |||
14ed4ca354 | |||
535ff96ab3 | |||
57482f81fc | |||
a31ce3c400 | |||
319f0aed90 | |||
826dcf0f86 | |||
b2411aee74 | |||
731acdced0 | |||
35b3bca1e6 | |||
3c413497ae | |||
1b5e516413 | |||
20cb5cecc4 | |||
08dc24605d | |||
bb7e9e94ee | |||
2680a1c872 | |||
20a7ce591c | |||
474e844ed9 | |||
b34955f2fb | |||
2bd0f0f14d | |||
8b77d9ff93 | |||
a9c7360020 | |||
d02c87602b | |||
9f966643b5 | |||
5746e2a3d6 | |||
d5c2231794 | |||
fc8e257a10 | |||
2e9bf382fb | |||
de48c848da | |||
9cdcc828a7 | |||
b28d446d07 | |||
274a38a588 | |||
fff89a9957 | |||
5613657c8f | |||
26bb16dd40 | |||
f0d39bb27b | |||
4c17310ebf | |||
fd36672877 | |||
d67c57056b | |||
59c55ef574 | |||
329027969a | |||
9f7a8c9540 | |||
384bb2c46d | |||
cabfe268ce | |||
26df71014b | |||
3126ee8153 | |||
cb622f4bad | |||
515d39e61c | |||
952061c4bb | |||
788225826d | |||
c52081e528 | |||
1f235acdf9 | |||
0f6c23e1f3 | |||
488e72679e | |||
6d43b14862 | |||
685213cdbb | |||
05fde3a742 | |||
9383f5484f | |||
88314e1e45 | |||
83b5761bca | |||
f25c993b75 | |||
6d02f8033d | |||
2c367703e4 | |||
284b5be128 | |||
a672a13789 | |||
9af9afd14d | |||
d98e9f8f05 | |||
652bddc07a | |||
5a6e4f5b5e | |||
f878ba5535 | |||
e7c36ba13a | |||
4cfe7c7c59 | |||
b0b30a8ae6 | |||
2e3633b205 | |||
d68aa91c33 | |||
3f63fb0bda | |||
d5af5de3c1 | |||
d9c08568cf | |||
a4c89f1494 | |||
a73e3204b9 | |||
330a71ebf2 | |||
36b0bb3a0e |
@ -10,6 +10,7 @@ Subpackages
|
|||||||
accounting.account
|
accounting.account
|
||||||
accounting.base_account
|
accounting.base_account
|
||||||
accounting.currency
|
accounting.currency
|
||||||
|
accounting.transaction
|
||||||
accounting.utils
|
accounting.utils
|
||||||
|
|
||||||
Submodules
|
Submodules
|
||||||
|
69
docs/source/accounting.transaction.rst
Normal file
69
docs/source/accounting.transaction.rst
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
accounting.transaction package
|
||||||
|
==============================
|
||||||
|
|
||||||
|
Submodules
|
||||||
|
----------
|
||||||
|
|
||||||
|
accounting.transaction.converters module
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.converters
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.transaction.dispatcher module
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.dispatcher
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.transaction.forms module
|
||||||
|
-----------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.forms
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.transaction.query module
|
||||||
|
-----------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.query
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.transaction.summary\_helper module
|
||||||
|
---------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.summary_helper
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.transaction.template module
|
||||||
|
--------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.template
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.transaction.views module
|
||||||
|
-----------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.views
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
Module contents
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
@ -4,10 +4,18 @@ accounting.utils package
|
|||||||
Submodules
|
Submodules
|
||||||
----------
|
----------
|
||||||
|
|
||||||
accounting.utils.next\_url module
|
accounting.utils.flash\_errors module
|
||||||
|
-------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.utils.flash_errors
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.utils.next\_uri module
|
||||||
---------------------------------
|
---------------------------------
|
||||||
|
|
||||||
.. automodule:: accounting.utils.next_url
|
.. automodule:: accounting.utils.next_uri
|
||||||
:members:
|
:members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath('../../src/'))
|
|||||||
project = 'Mia! Accounting Flask'
|
project = 'Mia! Accounting Flask'
|
||||||
copyright = '2023, imacat'
|
copyright = '2023, imacat'
|
||||||
author = 'imacat'
|
author = 'imacat'
|
||||||
release = '0.0.0'
|
release = '0.4.0'
|
||||||
|
|
||||||
# -- General configuration ---------------------------------------------------
|
# -- General configuration ---------------------------------------------------
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||||
@ -28,5 +28,5 @@ exclude_patterns = []
|
|||||||
# -- Options for HTML output -------------------------------------------------
|
# -- Options for HTML output -------------------------------------------------
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
||||||
|
|
||||||
html_theme = 'nature'
|
html_theme = 'sphinx_rtd_theme'
|
||||||
html_static_path = ['_static']
|
html_static_path = ['_static']
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
[metadata]
|
[metadata]
|
||||||
name = mia-accounting-flask
|
name = mia-accounting-flask
|
||||||
version = 0.2.0
|
version = 0.4.0
|
||||||
author = imacat
|
author = imacat
|
||||||
author_email = imacat@mail.imacat.idv.tw
|
author_email = imacat@mail.imacat.idv.tw
|
||||||
description = The Mia! Accounting Flask project.
|
description = The Mia! Accounting Flask project.
|
||||||
|
@ -73,7 +73,10 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
|
|||||||
from . import currency
|
from . import currency
|
||||||
currency.init_app(app, bp)
|
currency.init_app(app, bp)
|
||||||
|
|
||||||
from .utils import next_url
|
from . import transaction
|
||||||
next_url.init_app(bp)
|
transaction.init_app(app, bp)
|
||||||
|
|
||||||
|
from .utils import next_uri
|
||||||
|
next_uri.init_app(bp)
|
||||||
|
|
||||||
app.register_blueprint(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]
|
AccountData = tuple[int, str, int, str, str, str, bool]
|
||||||
"""The format of the account data, as a list of (ID, base account code, number,
|
"""The format of the account data, as a list of (ID, base account code, number,
|
||||||
English, Traditional Chinese, Simplified Chinese, is-offset-needed) tuples."""
|
English, Traditional Chinese, Simplified Chinese, is-pay-off-needed) tuples."""
|
||||||
|
|
||||||
|
|
||||||
def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
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] = []
|
data: list[AccountData] = []
|
||||||
for base in bases_to_add:
|
for base in bases_to_add:
|
||||||
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
|
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
|
||||||
is_offset_needed: bool = True if re.match("^[12]1[34]", base.code) \
|
is_pay_off_needed: bool = True if re.match("^[12]1[34]", base.code) \
|
||||||
else False
|
else False
|
||||||
data.append((get_new_id(), base.code, 1, base.title_l10n,
|
data.append((get_new_id(), base.code, 1, base.title_l10n,
|
||||||
l10n["zh_Hant"], l10n["zh_Hans"], is_offset_needed))
|
l10n["zh_Hant"], l10n["zh_Hans"], is_pay_off_needed))
|
||||||
__add_accounting_accounts(data, creator_pk)
|
__add_accounting_accounts(data, creator_pk)
|
||||||
click.echo(F"{len(data)} added. Accounting accounts initialized.")
|
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],
|
base_code=x[1],
|
||||||
no=x[2],
|
no=x[2],
|
||||||
title_l10n=x[3],
|
title_l10n=x[3],
|
||||||
is_offset_needed=x[6],
|
is_pay_off_needed=x[6],
|
||||||
created_by_id=creator_pk,
|
created_by_id=creator_pk,
|
||||||
updated_by_id=creator_pk)
|
updated_by_id=creator_pk)
|
||||||
for x in data]
|
for x in data]
|
||||||
|
@ -66,8 +66,8 @@ class AccountForm(FlaskForm):
|
|||||||
filters=[strip_text],
|
filters=[strip_text],
|
||||||
validators=[DataRequired(lazy_gettext("Please fill in the title"))])
|
validators=[DataRequired(lazy_gettext("Please fill in the title"))])
|
||||||
"""The title."""
|
"""The title."""
|
||||||
is_offset_needed = BooleanField()
|
is_pay_off_needed = BooleanField()
|
||||||
"""Whether the the entries of this account need offsets."""
|
"""Whether the the entries of this account need pay-off."""
|
||||||
|
|
||||||
def populate_obj(self, obj: Account) -> None:
|
def populate_obj(self, obj: Account) -> None:
|
||||||
"""Populates the form data into an account object.
|
"""Populates the form data into an account object.
|
||||||
@ -76,29 +76,27 @@ class AccountForm(FlaskForm):
|
|||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
is_new: bool = obj.id is None
|
is_new: bool = obj.id is None
|
||||||
prev_base_code: str | None = obj.base_code
|
|
||||||
if is_new:
|
if is_new:
|
||||||
obj.id = new_id(Account)
|
obj.id = new_id(Account)
|
||||||
obj.base_code = self.base_code.data
|
if obj.base_code != self.base_code.data:
|
||||||
if prev_base_code != self.base_code.data:
|
if obj.base_code is not None:
|
||||||
max_no: int = db.session.scalars(
|
sort_accounts_in(obj.base_code, obj.id)
|
||||||
sa.select(sa.func.max(Account.no))
|
sort_accounts_in(self.base_code.data, obj.id)
|
||||||
.filter(Account.base_code == self.base_code.data)).one()
|
count: int = Account.query\
|
||||||
obj.no = 1 if max_no is None else max_no + 1
|
.filter(Account.base_code == self.base_code.data).count()
|
||||||
|
obj.base_code = self.base_code.data
|
||||||
|
obj.no = count + 1
|
||||||
obj.title = self.title.data
|
obj.title = self.title.data
|
||||||
obj.is_offset_needed = self.is_offset_needed.data
|
obj.is_pay_off_needed = self.is_pay_off_needed.data
|
||||||
if is_new:
|
if is_new:
|
||||||
current_user_pk: int = get_current_user_pk()
|
current_user_pk: int = get_current_user_pk()
|
||||||
obj.created_by_id = current_user_pk
|
obj.created_by_id = current_user_pk
|
||||||
obj.updated_by_id = current_user_pk
|
obj.updated_by_id = current_user_pk
|
||||||
if prev_base_code is not None \
|
|
||||||
and prev_base_code != self.base_code.data:
|
|
||||||
setattr(self, "__post_update",
|
|
||||||
lambda: sort_accounts_in(prev_base_code, obj.id))
|
|
||||||
|
|
||||||
def post_update(self, obj) -> None:
|
def post_update(self, obj: Account) -> None:
|
||||||
"""The post-processing after the update.
|
"""The post-processing after the update.
|
||||||
|
|
||||||
|
:param obj: The account object.
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
current_user_pk: int = get_current_user_pk()
|
current_user_pk: int = get_current_user_pk()
|
||||||
|
@ -47,8 +47,8 @@ def get_account_query() -> list[Account]:
|
|||||||
Account.title_l10n.contains(k),
|
Account.title_l10n.contains(k),
|
||||||
code.contains(k),
|
code.contains(k),
|
||||||
Account.id.in_(l10n_matches)]
|
Account.id.in_(l10n_matches)]
|
||||||
if k in gettext("Offset needed"):
|
if k in gettext("Pay-off needed"):
|
||||||
sub_conditions.append(Account.is_offset_needed)
|
sub_conditions.append(Account.is_pay_off_needed)
|
||||||
conditions.append(sa.or_(*sub_conditions))
|
conditions.append(sa.or_(*sub_conditions))
|
||||||
|
|
||||||
return Account.query.filter(*conditions)\
|
return Account.query.filter(*conditions)\
|
||||||
|
@ -19,6 +19,7 @@
|
|||||||
"""
|
"""
|
||||||
from urllib.parse import parse_qsl, urlencode
|
from urllib.parse import parse_qsl, urlencode
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
from flask import Blueprint, render_template, session, redirect, flash, \
|
from flask import Blueprint, render_template, session, redirect, flash, \
|
||||||
url_for, request
|
url_for, request
|
||||||
from werkzeug.datastructures import ImmutableMultiDict
|
from werkzeug.datastructures import ImmutableMultiDict
|
||||||
@ -26,10 +27,13 @@ from werkzeug.datastructures import ImmutableMultiDict
|
|||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.locale import lazy_gettext
|
from accounting.locale import lazy_gettext
|
||||||
from accounting.models import Account, BaseAccount
|
from accounting.models import Account, BaseAccount
|
||||||
from accounting.utils.next_url import inherit_next, or_next
|
from accounting.utils.flash_errors import flash_form_errors
|
||||||
|
from accounting.utils.next_uri import inherit_next, or_next
|
||||||
from accounting.utils.pagination import Pagination
|
from accounting.utils.pagination import Pagination
|
||||||
from accounting.utils.permission import can_view, has_permission, can_edit
|
from accounting.utils.permission import can_view, has_permission, can_edit
|
||||||
|
from accounting.utils.user import get_current_user_pk
|
||||||
from .forms import AccountForm, sort_accounts_in, AccountReorderForm
|
from .forms import AccountForm, sort_accounts_in, AccountReorderForm
|
||||||
|
from .query import get_account_query
|
||||||
|
|
||||||
bp: Blueprint = Blueprint("account", __name__)
|
bp: Blueprint = Blueprint("account", __name__)
|
||||||
"""The view blueprint for the account management."""
|
"""The view blueprint for the account management."""
|
||||||
@ -42,7 +46,6 @@ def list_accounts() -> str:
|
|||||||
|
|
||||||
:return: The account list.
|
:return: The account list.
|
||||||
"""
|
"""
|
||||||
from .query import get_account_query
|
|
||||||
accounts: list[BaseAccount] = get_account_query()
|
accounts: list[BaseAccount] = get_account_query()
|
||||||
pagination: Pagination = Pagination[BaseAccount](accounts)
|
pagination: Pagination = Pagination[BaseAccount](accounts)
|
||||||
return render_template("accounting/account/list.html",
|
return render_template("accounting/account/list.html",
|
||||||
@ -76,9 +79,7 @@ def add_account() -> redirect:
|
|||||||
"""
|
"""
|
||||||
form = AccountForm(request.form)
|
form = AccountForm(request.form)
|
||||||
if not form.validate():
|
if not form.validate():
|
||||||
for key in form.errors:
|
flash_form_errors(form)
|
||||||
for error in form.errors[key]:
|
|
||||||
flash(error, "error")
|
|
||||||
session["form"] = urlencode(list(request.form.items()))
|
session["form"] = urlencode(list(request.form.items()))
|
||||||
return redirect(inherit_next(url_for("accounting.account.create")))
|
return redirect(inherit_next(url_for("accounting.account.create")))
|
||||||
account: Account = Account()
|
account: Account = Account()
|
||||||
@ -86,8 +87,7 @@ def add_account() -> redirect:
|
|||||||
db.session.add(account)
|
db.session.add(account)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(lazy_gettext("The account is added successfully"), "success")
|
flash(lazy_gettext("The account is added successfully"), "success")
|
||||||
return redirect(inherit_next(url_for("accounting.account.detail",
|
return redirect(inherit_next(__get_detail_uri(account)))
|
||||||
account=account)))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<account:account>", endpoint="detail")
|
@bp.get("/<account:account>", endpoint="detail")
|
||||||
@ -131,9 +131,7 @@ def update_account(account: Account) -> redirect:
|
|||||||
"""
|
"""
|
||||||
form = AccountForm(request.form)
|
form = AccountForm(request.form)
|
||||||
if not form.validate():
|
if not form.validate():
|
||||||
for key in form.errors:
|
flash_form_errors(form)
|
||||||
for error in form.errors[key]:
|
|
||||||
flash(error, "error")
|
|
||||||
session["form"] = urlencode(list(request.form.items()))
|
session["form"] = urlencode(list(request.form.items()))
|
||||||
return redirect(inherit_next(url_for("accounting.account.edit",
|
return redirect(inherit_next(url_for("accounting.account.edit",
|
||||||
account=account)))
|
account=account)))
|
||||||
@ -141,13 +139,12 @@ def update_account(account: Account) -> redirect:
|
|||||||
form.populate_obj(account)
|
form.populate_obj(account)
|
||||||
if not account.is_modified:
|
if not account.is_modified:
|
||||||
flash(lazy_gettext("The account was not modified."), "success")
|
flash(lazy_gettext("The account was not modified."), "success")
|
||||||
return redirect(inherit_next(url_for("accounting.account.detail",
|
return redirect(inherit_next(__get_detail_uri(account)))
|
||||||
account=account)))
|
account.updated_by_id = get_current_user_pk()
|
||||||
form.post_update(account)
|
account.updated_at = sa.func.now()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(lazy_gettext("The account is updated successfully."), "success")
|
flash(lazy_gettext("The account is updated successfully."), "success")
|
||||||
return redirect(inherit_next(url_for("accounting.account.detail",
|
return redirect(inherit_next(__get_detail_uri(account)))
|
||||||
account=account)))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<account:account>/delete", endpoint="delete")
|
@bp.post("/<account:account>/delete", endpoint="delete")
|
||||||
@ -163,7 +160,7 @@ def delete_account(account: Account) -> redirect:
|
|||||||
sort_accounts_in(account.base_code, account.id)
|
sort_accounts_in(account.base_code, account.id)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(lazy_gettext("The account is deleted successfully."), "success")
|
flash(lazy_gettext("The account is deleted successfully."), "success")
|
||||||
return redirect(or_next(url_for("accounting.account.list")))
|
return redirect(or_next(__get_list_uri()))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/bases/<baseAccount:base>", endpoint="order")
|
@bp.get("/bases/<baseAccount:base>", endpoint="order")
|
||||||
@ -190,7 +187,24 @@ def sort_accounts(base: BaseAccount) -> redirect:
|
|||||||
form.save_order()
|
form.save_order()
|
||||||
if not form.is_modified:
|
if not form.is_modified:
|
||||||
flash(lazy_gettext("The order was not modified."), "success")
|
flash(lazy_gettext("The order was not modified."), "success")
|
||||||
return redirect(or_next(url_for("accounting.account.list")))
|
return redirect(or_next(__get_list_uri()))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(lazy_gettext("The order is updated successfully."), "success")
|
flash(lazy_gettext("The order is updated successfully."), "success")
|
||||||
return redirect(or_next(url_for("accounting.account.list")))
|
return redirect(or_next(__get_list_uri()))
|
||||||
|
|
||||||
|
|
||||||
|
def __get_detail_uri(account: Account) -> str:
|
||||||
|
"""Returns the detail URI of an account.
|
||||||
|
|
||||||
|
:param account: The account.
|
||||||
|
:return: The detail URI of the account.
|
||||||
|
"""
|
||||||
|
return url_for("accounting.account.detail", account=account)
|
||||||
|
|
||||||
|
|
||||||
|
def __get_list_uri() -> str:
|
||||||
|
"""Returns the account list URI.
|
||||||
|
|
||||||
|
:return: The account list URI.
|
||||||
|
"""
|
||||||
|
return url_for("accounting.account.list")
|
||||||
|
@ -19,7 +19,6 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import StringField, ValidationError
|
from wtforms import StringField, ValidationError
|
||||||
from wtforms.validators import DataRequired, Regexp, NoneOf
|
from wtforms.validators import DataRequired, Regexp, NoneOf
|
||||||
@ -82,12 +81,3 @@ class CurrencyForm(FlaskForm):
|
|||||||
current_user_pk: int = get_current_user_pk()
|
current_user_pk: int = get_current_user_pk()
|
||||||
obj.created_by_id = current_user_pk
|
obj.created_by_id = current_user_pk
|
||||||
obj.updated_by_id = current_user_pk
|
obj.updated_by_id = current_user_pk
|
||||||
|
|
||||||
def post_update(self, obj) -> None:
|
|
||||||
"""The post-processing after the update.
|
|
||||||
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
current_user_pk: int = get_current_user_pk()
|
|
||||||
obj.updated_by_id = current_user_pk
|
|
||||||
obj.updated_at = sa.func.now()
|
|
||||||
|
@ -19,6 +19,7 @@
|
|||||||
"""
|
"""
|
||||||
from urllib.parse import urlencode, parse_qsl
|
from urllib.parse import urlencode, parse_qsl
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
from flask import Blueprint, render_template, redirect, session, request, \
|
from flask import Blueprint, render_template, redirect, session, request, \
|
||||||
flash, url_for
|
flash, url_for
|
||||||
from werkzeug.datastructures import ImmutableMultiDict
|
from werkzeug.datastructures import ImmutableMultiDict
|
||||||
@ -26,9 +27,11 @@ from werkzeug.datastructures import ImmutableMultiDict
|
|||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.locale import lazy_gettext
|
from accounting.locale import lazy_gettext
|
||||||
from accounting.models import Currency
|
from accounting.models import Currency
|
||||||
from accounting.utils.next_url import inherit_next, or_next
|
from accounting.utils.flash_errors import flash_form_errors
|
||||||
|
from accounting.utils.next_uri import inherit_next, or_next
|
||||||
from accounting.utils.pagination import Pagination
|
from accounting.utils.pagination import Pagination
|
||||||
from accounting.utils.permission import has_permission, can_view, can_edit
|
from accounting.utils.permission import has_permission, can_view, can_edit
|
||||||
|
from accounting.utils.user import get_current_user_pk
|
||||||
from .forms import CurrencyForm
|
from .forms import CurrencyForm
|
||||||
|
|
||||||
bp: Blueprint = Blueprint("currency", __name__)
|
bp: Blueprint = Blueprint("currency", __name__)
|
||||||
@ -78,9 +81,7 @@ def add_currency() -> redirect:
|
|||||||
"""
|
"""
|
||||||
form = CurrencyForm(request.form)
|
form = CurrencyForm(request.form)
|
||||||
if not form.validate():
|
if not form.validate():
|
||||||
for key in form.errors:
|
flash_form_errors(form)
|
||||||
for error in form.errors[key]:
|
|
||||||
flash(error, "error")
|
|
||||||
session["form"] = urlencode(list(request.form.items()))
|
session["form"] = urlencode(list(request.form.items()))
|
||||||
return redirect(inherit_next(url_for("accounting.currency.create")))
|
return redirect(inherit_next(url_for("accounting.currency.create")))
|
||||||
currency: Currency = Currency()
|
currency: Currency = Currency()
|
||||||
@ -88,8 +89,7 @@ def add_currency() -> redirect:
|
|||||||
db.session.add(currency)
|
db.session.add(currency)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(lazy_gettext("The currency is added successfully"), "success")
|
flash(lazy_gettext("The currency is added successfully"), "success")
|
||||||
return redirect(inherit_next(url_for("accounting.currency.detail",
|
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||||
currency=currency)))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<currency:currency>", endpoint="detail")
|
@bp.get("/<currency:currency>", endpoint="detail")
|
||||||
@ -134,9 +134,7 @@ def update_currency(currency: Currency) -> redirect:
|
|||||||
form = CurrencyForm(request.form)
|
form = CurrencyForm(request.form)
|
||||||
form.obj_code = currency.code
|
form.obj_code = currency.code
|
||||||
if not form.validate():
|
if not form.validate():
|
||||||
for key in form.errors:
|
flash_form_errors(form)
|
||||||
for error in form.errors[key]:
|
|
||||||
flash(error, "error")
|
|
||||||
session["form"] = urlencode(list(request.form.items()))
|
session["form"] = urlencode(list(request.form.items()))
|
||||||
return redirect(inherit_next(url_for("accounting.currency.edit",
|
return redirect(inherit_next(url_for("accounting.currency.edit",
|
||||||
currency=currency)))
|
currency=currency)))
|
||||||
@ -144,13 +142,12 @@ def update_currency(currency: Currency) -> redirect:
|
|||||||
form.populate_obj(currency)
|
form.populate_obj(currency)
|
||||||
if not currency.is_modified:
|
if not currency.is_modified:
|
||||||
flash(lazy_gettext("The currency was not modified."), "success")
|
flash(lazy_gettext("The currency was not modified."), "success")
|
||||||
return redirect(inherit_next(url_for("accounting.currency.detail",
|
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||||
currency=currency)))
|
currency.updated_by_id = get_current_user_pk()
|
||||||
form.post_update(currency)
|
currency.updated_at = sa.func.now()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(lazy_gettext("The currency is updated successfully."), "success")
|
flash(lazy_gettext("The currency is updated successfully."), "success")
|
||||||
return redirect(inherit_next(url_for("accounting.currency.detail",
|
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||||
currency=currency)))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<currency:currency>/delete", endpoint="delete")
|
@bp.post("/<currency:currency>/delete", endpoint="delete")
|
||||||
@ -176,3 +173,13 @@ def exists_code() -> dict[str, bool]:
|
|||||||
:return: Whether the currency code exists.
|
:return: Whether the currency code exists.
|
||||||
"""
|
"""
|
||||||
return {"exists": db.session.get(Currency, request.args["q"]) is not None}
|
return {"exists": db.session.get(Currency, request.args["q"]) is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def __get_detail_uri(currency: Currency) -> str:
|
||||||
|
"""Returns the detail URI of a currency.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:return: The detail URI of the currency.
|
||||||
|
"""
|
||||||
|
return url_for("accounting.currency.detail", currency=currency)
|
||||||
|
|
||||||
|
@ -17,8 +17,11 @@
|
|||||||
"""The data models.
|
"""The data models.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import typing as t
|
import typing as t
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
@ -26,6 +29,7 @@ from flask_babel import get_locale
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from accounting import db
|
from accounting import db
|
||||||
|
from accounting.locale import gettext
|
||||||
from accounting.utils.user import user_cls, user_pk_column
|
from accounting.utils.user import user_cls, user_pk_column
|
||||||
|
|
||||||
|
|
||||||
@ -109,8 +113,8 @@ class Account(db.Model):
|
|||||||
"""The account number under the base account."""
|
"""The account number under the base account."""
|
||||||
title_l10n = db.Column("title", db.String, nullable=False)
|
title_l10n = db.Column("title", db.String, nullable=False)
|
||||||
"""The title."""
|
"""The title."""
|
||||||
is_offset_needed = db.Column(db.Boolean, nullable=False, default=False)
|
is_pay_off_needed = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
"""Whether the entries of this account need offsets."""
|
"""Whether the entries of this account need pay-off."""
|
||||||
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||||
server_default=db.func.now())
|
server_default=db.func.now())
|
||||||
"""The time of creation."""
|
"""The time of creation."""
|
||||||
@ -134,6 +138,8 @@ class Account(db.Model):
|
|||||||
l10n = db.relationship("AccountL10n", back_populates="account",
|
l10n = db.relationship("AccountL10n", back_populates="account",
|
||||||
lazy=False)
|
lazy=False)
|
||||||
"""The localized titles."""
|
"""The localized titles."""
|
||||||
|
entries = db.relationship("JournalEntry", back_populates="account")
|
||||||
|
"""The journal entries."""
|
||||||
|
|
||||||
__CASH = "1111-001"
|
__CASH = "1111-001"
|
||||||
"""The code of the cash account,"""
|
"""The code of the cash account,"""
|
||||||
@ -204,7 +210,7 @@ class Account(db.Model):
|
|||||||
:param code: The code.
|
:param code: The code.
|
||||||
:return: The account, or None if this account does not exist.
|
:return: The account, or None if this account does not exist.
|
||||||
"""
|
"""
|
||||||
m = re.match("^([1-9]{4})-([0-9]{3})$", code)
|
m = re.match(r"^([1-9]{4})-(\d{3})$", code)
|
||||||
if m is None:
|
if m is None:
|
||||||
return None
|
return None
|
||||||
return cls.query.filter(cls.base_code == m.group(1),
|
return cls.query.filter(cls.base_code == m.group(1),
|
||||||
@ -251,6 +257,14 @@ class Account(db.Model):
|
|||||||
cls.base_code != "3353")\
|
cls.base_code != "3353")\
|
||||||
.order_by(cls.base_code, cls.no).all()
|
.order_by(cls.base_code, cls.no).all()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def query_values(self) -> list[str]:
|
||||||
|
"""Returns the values to be queried.
|
||||||
|
|
||||||
|
:return: The values to be queried.
|
||||||
|
"""
|
||||||
|
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def cash(cls) -> t.Self:
|
def cash(cls) -> t.Self:
|
||||||
"""Returns the cash account.
|
"""Returns the cash account.
|
||||||
@ -370,6 +384,8 @@ class Currency(db.Model):
|
|||||||
l10n = db.relationship("CurrencyL10n", back_populates="currency",
|
l10n = db.relationship("CurrencyL10n", back_populates="currency",
|
||||||
lazy=False)
|
lazy=False)
|
||||||
"""The localized names."""
|
"""The localized names."""
|
||||||
|
entries = db.relationship("JournalEntry", back_populates="currency")
|
||||||
|
"""The journal entries."""
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
"""Returns the string representation of the currency.
|
"""Returns the string representation of the currency.
|
||||||
@ -450,3 +466,215 @@ class CurrencyL10n(db.Model):
|
|||||||
"""The locale."""
|
"""The locale."""
|
||||||
name = db.Column(db.String, nullable=False)
|
name = db.Column(db.String, nullable=False)
|
||||||
"""The localized name."""
|
"""The localized name."""
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionCurrency:
|
||||||
|
"""A currency in a transaction."""
|
||||||
|
|
||||||
|
def __init__(self, code: str, debit: list[JournalEntry],
|
||||||
|
credit: list[JournalEntry]):
|
||||||
|
"""Constructs the currency in the transaction.
|
||||||
|
|
||||||
|
:param code: The currency code.
|
||||||
|
:param debit: The debit entries.
|
||||||
|
:param credit: The credit entries.
|
||||||
|
"""
|
||||||
|
self.code: str = code
|
||||||
|
"""The currency code."""
|
||||||
|
self.debit: list[JournalEntry] = debit
|
||||||
|
"""The debit entries."""
|
||||||
|
self.credit: list[JournalEntry] = credit
|
||||||
|
"""The credit entries."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Returns the currency name.
|
||||||
|
|
||||||
|
:return: The currency name.
|
||||||
|
"""
|
||||||
|
return db.session.get(Currency, self.code).name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def debit_total(self) -> Decimal:
|
||||||
|
"""Returns the total amount of the debit journal entries.
|
||||||
|
|
||||||
|
:return: The total amount of the debit journal entries.
|
||||||
|
"""
|
||||||
|
return sum([x.amount for x in self.debit])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credit_total(self) -> str:
|
||||||
|
"""Returns the total amount of the credit journal entries.
|
||||||
|
|
||||||
|
:return: The total amount of the credit journal entries.
|
||||||
|
"""
|
||||||
|
return sum([x.amount for x in self.credit])
|
||||||
|
|
||||||
|
|
||||||
|
class Transaction(db.Model):
|
||||||
|
"""A transaction."""
|
||||||
|
__tablename__ = "accounting_transactions"
|
||||||
|
"""The table name."""
|
||||||
|
id = db.Column(db.Integer, nullable=False, primary_key=True,
|
||||||
|
autoincrement=False)
|
||||||
|
"""The transaction ID."""
|
||||||
|
date = db.Column(db.Date, nullable=False)
|
||||||
|
"""The date."""
|
||||||
|
no = db.Column(db.Integer, nullable=False, default=text("1"))
|
||||||
|
"""The account number under the date."""
|
||||||
|
note = db.Column(db.String)
|
||||||
|
"""The note."""
|
||||||
|
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=db.func.now())
|
||||||
|
"""The time of creation."""
|
||||||
|
created_by_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey(user_pk_column,
|
||||||
|
onupdate="CASCADE"),
|
||||||
|
nullable=False)
|
||||||
|
"""The ID of the creator."""
|
||||||
|
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
|
||||||
|
"""The creator."""
|
||||||
|
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=db.func.now())
|
||||||
|
"""The time of last update."""
|
||||||
|
updated_by_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey(user_pk_column,
|
||||||
|
onupdate="CASCADE"),
|
||||||
|
nullable=False)
|
||||||
|
"""The ID of the updator."""
|
||||||
|
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
|
||||||
|
"""The updator."""
|
||||||
|
entries = db.relationship("JournalEntry", back_populates="transaction")
|
||||||
|
"""The journal entries."""
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Returns the string representation of this transaction.
|
||||||
|
|
||||||
|
:return: The string representation of this transaction.
|
||||||
|
"""
|
||||||
|
if self.is_cash_expense:
|
||||||
|
return gettext("Cash Expense Transaction#%(id)s", id=self.id)
|
||||||
|
if self.is_cash_income:
|
||||||
|
return gettext("Cash Income Transaction#%(id)s", id=self.id)
|
||||||
|
return gettext("Transfer Transaction#%(id)s", id=self.id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def currencies(self) -> list[TransactionCurrency]:
|
||||||
|
"""Returns the journal entries categorized by their currencies.
|
||||||
|
|
||||||
|
:return: The currency categories.
|
||||||
|
"""
|
||||||
|
entries: list[JournalEntry] = sorted(self.entries, key=lambda x: x.no)
|
||||||
|
codes: list[str] = []
|
||||||
|
by_currency: dict[str, list[JournalEntry]] = {}
|
||||||
|
for entry in entries:
|
||||||
|
if entry.currency_code not in by_currency:
|
||||||
|
codes.append(entry.currency_code)
|
||||||
|
by_currency[entry.currency_code] = []
|
||||||
|
by_currency[entry.currency_code].append(entry)
|
||||||
|
return [TransactionCurrency(code=x,
|
||||||
|
debit=[y for y in by_currency[x]
|
||||||
|
if y.is_debit],
|
||||||
|
credit=[y for y in by_currency[x]
|
||||||
|
if not y.is_debit])
|
||||||
|
for x in codes]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_cash_income(self) -> bool:
|
||||||
|
"""Returns whether this is a cash income transaction.
|
||||||
|
|
||||||
|
:return: True if this is a cash income transaction, or False otherwise.
|
||||||
|
"""
|
||||||
|
for currency in self.currencies:
|
||||||
|
if len(currency.debit) > 1:
|
||||||
|
return False
|
||||||
|
if currency.debit[0].account.code != "1111-001":
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_cash_expense(self) -> bool:
|
||||||
|
"""Returns whether this is a cash expense transaction.
|
||||||
|
|
||||||
|
:return: True if this is a cash expense transaction, or False
|
||||||
|
otherwise.
|
||||||
|
"""
|
||||||
|
for currency in self.currencies:
|
||||||
|
if len(currency.credit) > 1:
|
||||||
|
return False
|
||||||
|
if currency.credit[0].account.code != "1111-001":
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
"""Deletes the transaction.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
JournalEntry.query\
|
||||||
|
.filter(JournalEntry.transaction_id == self.id).delete()
|
||||||
|
db.session.delete(self)
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntry(db.Model):
|
||||||
|
"""An accounting journal entry."""
|
||||||
|
__tablename__ = "accounting_journal_entries"
|
||||||
|
"""The table name."""
|
||||||
|
id = db.Column(db.Integer, nullable=False, primary_key=True,
|
||||||
|
autoincrement=False)
|
||||||
|
"""The entry ID."""
|
||||||
|
transaction_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey(Transaction.id,
|
||||||
|
onupdate="CASCADE",
|
||||||
|
ondelete="CASCADE"),
|
||||||
|
nullable=False)
|
||||||
|
"""The transaction ID."""
|
||||||
|
transaction = db.relationship(Transaction, back_populates="entries")
|
||||||
|
"""The transaction."""
|
||||||
|
is_debit = db.Column(db.Boolean, nullable=False)
|
||||||
|
"""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,
|
||||||
|
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",
|
||||||
|
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."""
|
||||||
|
currency_code = db.Column(db.String,
|
||||||
|
db.ForeignKey(Currency.code, onupdate="CASCADE"),
|
||||||
|
nullable=False)
|
||||||
|
"""The currency code."""
|
||||||
|
currency = db.relationship(Currency, back_populates="entries")
|
||||||
|
"""The currency."""
|
||||||
|
account_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey(Account.id,
|
||||||
|
onupdate="CASCADE"),
|
||||||
|
nullable=False)
|
||||||
|
"""The account ID."""
|
||||||
|
account = db.relationship(Account, back_populates="entries", lazy=False)
|
||||||
|
"""The account."""
|
||||||
|
summary = db.Column(db.String, nullable=True)
|
||||||
|
"""The summary."""
|
||||||
|
amount = db.Column(db.Numeric(14, 2), nullable=False)
|
||||||
|
"""The amount."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def eid(self) -> int | None:
|
||||||
|
"""Returns the journal entry ID. This is the alternative name of the
|
||||||
|
ID field, to work with WTForms.
|
||||||
|
|
||||||
|
:return: The journal entry ID.
|
||||||
|
"""
|
||||||
|
return self.id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_code(self) -> str:
|
||||||
|
"""Returns the account code.
|
||||||
|
|
||||||
|
:return: The account code.
|
||||||
|
"""
|
||||||
|
return self.account.code
|
||||||
|
@ -34,6 +34,13 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
.form-floating > textarea.form-control {
|
||||||
|
height: 6rem;
|
||||||
|
}
|
||||||
|
.accounting-dragged {
|
||||||
|
color: #141619;
|
||||||
|
background-color: #D3D3D4;
|
||||||
|
}
|
||||||
|
|
||||||
/** The card layout */
|
/** The card layout */
|
||||||
.accounting-card {
|
.accounting-card {
|
||||||
@ -58,6 +65,50 @@
|
|||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The transaction management */
|
||||||
|
.accounting-currency-control {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
.accounting-currency-content {
|
||||||
|
width: calc(100% - 3rem);
|
||||||
|
}
|
||||||
|
.accounting-entry-content {
|
||||||
|
width: calc(100% - 3rem);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.accounting-list-group-stripped .list-group-item-success:nth-child(2n+1) {
|
||||||
|
background-color: #c7dbd2;
|
||||||
|
}
|
||||||
|
.accounting-list-group-hover .list-group-item:hover {
|
||||||
|
background-color: #ececec;
|
||||||
|
}
|
||||||
|
.accounting-transaction-entry {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.accounting-transaction-entry-header {
|
||||||
|
font-weight: bolder;
|
||||||
|
border-bottom: thick double slategray;
|
||||||
|
}
|
||||||
|
.list-group-item.accounting-transaction-entry-total {
|
||||||
|
font-weight: bolder;
|
||||||
|
border-top: thick double slategray;
|
||||||
|
}
|
||||||
|
|
||||||
/* The Material Design text field (floating form control in Bootstrap) */
|
/* The Material Design text field (floating form control in Bootstrap) */
|
||||||
.accounting-material-text-field {
|
.accounting-material-text-field {
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -96,6 +147,36 @@
|
|||||||
.accounting-material-fab .btn:hover, .accounting-material-fab .btn:focus {
|
.accounting-material-fab .btn:hover, .accounting-material-fab .btn:focus {
|
||||||
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12);
|
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12);
|
||||||
}
|
}
|
||||||
|
.accounting-btn-material-fab {
|
||||||
|
transition: transform .1s ease-in-out, right .1s ease-in-out, bottom .1s ease-in-out;
|
||||||
|
}
|
||||||
|
.show .accounting-btn-material-fab {
|
||||||
|
transform: scale(1.5) rotate(-45deg);
|
||||||
|
}
|
||||||
|
.accounting-material-fab-speed-dial-group {
|
||||||
|
position: absolute;
|
||||||
|
right: -2rem;
|
||||||
|
bottom: -7rem;
|
||||||
|
text-align: right;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.1);
|
||||||
|
line-height: 5.5rem;
|
||||||
|
transition: opacity .1s ease-in-out, transform .1s ease-in-out, right .1s ease-in-out, bottom .1s ease-in-out;
|
||||||
|
}
|
||||||
|
.show .accounting-material-fab-speed-dial-group {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(0.6);
|
||||||
|
right: -0.5rem;
|
||||||
|
bottom: 0.7rem;
|
||||||
|
}
|
||||||
|
.accounting-material-fab-speed-dial-group .btn {
|
||||||
|
background-color: white;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0,0,0,.12);
|
||||||
|
}
|
||||||
|
.accounting-material-fab-speed-dial-group .btn:hover, .accounting-material-fab-speed-dial-group .btn:focus {
|
||||||
|
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12);
|
||||||
|
}
|
||||||
|
|
||||||
/* The Material Design form switch */
|
/* The Material Design form switch */
|
||||||
@media(max-width:767px) {
|
@media(max-width:767px) {
|
||||||
|
@ -38,7 +38,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
function initializeBaseAccountSelector() {
|
function initializeBaseAccountSelector() {
|
||||||
const selector = document.getElementById("accounting-base-selector-model");
|
const selector = document.getElementById("accounting-base-selector-modal");
|
||||||
const base = document.getElementById("accounting-base");
|
const base = document.getElementById("accounting-base");
|
||||||
const baseCode = document.getElementById("accounting-base-code");
|
const baseCode = document.getElementById("accounting-base-code");
|
||||||
const baseContent = document.getElementById("accounting-base-content");
|
const baseContent = document.getElementById("accounting-base-content");
|
||||||
@ -46,9 +46,9 @@ function initializeBaseAccountSelector() {
|
|||||||
const btnClear = document.getElementById("accounting-btn-clear-base");
|
const btnClear = document.getElementById("accounting-btn-clear-base");
|
||||||
selector.addEventListener("show.bs.modal", function () {
|
selector.addEventListener("show.bs.modal", function () {
|
||||||
base.classList.add("accounting-not-empty");
|
base.classList.add("accounting-not-empty");
|
||||||
options.forEach(function (item) {
|
for (const option of options) {
|
||||||
item.classList.remove("active");
|
option.classList.remove("active");
|
||||||
});
|
}
|
||||||
const selected = document.getElementById("accounting-base-option-" + baseCode.value);
|
const selected = document.getElementById("accounting-base-option-" + baseCode.value);
|
||||||
if (selected !== null) {
|
if (selected !== null) {
|
||||||
selected.classList.add("active");
|
selected.classList.add("active");
|
||||||
@ -59,7 +59,7 @@ function initializeBaseAccountSelector() {
|
|||||||
base.classList.remove("accounting-not-empty");
|
base.classList.remove("accounting-not-empty");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
options.forEach(function (option) {
|
for (const option of options) {
|
||||||
option.onclick = function () {
|
option.onclick = function () {
|
||||||
baseCode.value = option.dataset.code;
|
baseCode.value = option.dataset.code;
|
||||||
baseContent.innerText = option.dataset.content;
|
baseContent.innerText = option.dataset.content;
|
||||||
@ -69,7 +69,7 @@ function initializeBaseAccountSelector() {
|
|||||||
validateBase();
|
validateBase();
|
||||||
bootstrap.Modal.getInstance(selector).hide();
|
bootstrap.Modal.getInstance(selector).hide();
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
btnClear.onclick = function () {
|
btnClear.onclick = function () {
|
||||||
baseCode.value = "";
|
baseCode.value = "";
|
||||||
baseContent.innerText = "";
|
baseContent.innerText = "";
|
||||||
@ -93,21 +93,20 @@ function initializeBaseAccountQuery() {
|
|||||||
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
|
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
|
||||||
const queryNoResult = document.getElementById("accounting-base-option-no-result");
|
const queryNoResult = document.getElementById("accounting-base-option-no-result");
|
||||||
query.addEventListener("input", function () {
|
query.addEventListener("input", function () {
|
||||||
console.log(query.value);
|
|
||||||
if (query.value === "") {
|
if (query.value === "") {
|
||||||
options.forEach(function (option) {
|
for (const option of options) {
|
||||||
option.classList.remove("d-none");
|
option.classList.remove("d-none");
|
||||||
});
|
}
|
||||||
optionList.classList.remove("d-none");
|
optionList.classList.remove("d-none");
|
||||||
queryNoResult.classList.add("d-none");
|
queryNoResult.classList.add("d-none");
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let hasAnyMatched = false;
|
let hasAnyMatched = false;
|
||||||
options.forEach(function (option) {
|
for (const option of options) {
|
||||||
const queryValues = JSON.parse(option.dataset.queryValues);
|
const queryValues = JSON.parse(option.dataset.queryValues);
|
||||||
let isMatched = false;
|
let isMatched = false;
|
||||||
for (let i = 0; i < queryValues.length; i++) {
|
for (const queryValue of queryValues) {
|
||||||
if (queryValues[i].includes(query.value)) {
|
if (queryValue.includes(query.value)) {
|
||||||
isMatched = true;
|
isMatched = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -118,7 +117,7 @@ function initializeBaseAccountQuery() {
|
|||||||
} else {
|
} else {
|
||||||
option.classList.add("d-none");
|
option.classList.add("d-none");
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
if (!hasAnyMatched) {
|
if (!hasAnyMatched) {
|
||||||
optionList.classList.add("d-none");
|
optionList.classList.add("d-none");
|
||||||
queryNoResult.classList.remove("d-none");
|
queryNoResult.classList.remove("d-none");
|
||||||
|
254
src/accounting/static/js/account-selector.js
Normal file
254
src/accounting/static/js/account-selector.js
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
/* The Mia! Accounting Flask Project
|
||||||
|
* transaction-transfer-form.js: The JavaScript for the transfer transaction form
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Copyright (c) 2023 imacat.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
* First written: 2023/2/28
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Initializes the page JavaScript.
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
AccountSelector.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account selector.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
class AccountSelector {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entry type
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
#entryType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prefix of the HTML ID and class
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
#prefix;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an account selector.
|
||||||
|
*
|
||||||
|
* @param modal {HTMLFormElement} the account selector modal
|
||||||
|
*/
|
||||||
|
constructor(modal) {
|
||||||
|
this.#entryType = modal.dataset.entryType;
|
||||||
|
this.#prefix = "accounting-account-selector-" + modal.dataset.entryType;
|
||||||
|
this.#init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the account selector.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#init() {
|
||||||
|
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
|
||||||
|
const formAccount = document.getElementById("accounting-entry-form-account");
|
||||||
|
const more = document.getElementById(this.#prefix + "-more");
|
||||||
|
const btnClear = document.getElementById(this.#prefix + "-btn-clear");
|
||||||
|
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
|
||||||
|
const selector1 = this
|
||||||
|
more.onclick = function () {
|
||||||
|
more.classList.add("d-none");
|
||||||
|
selector1.#filterAccountOptions();
|
||||||
|
};
|
||||||
|
this.#initializeAccountQuery();
|
||||||
|
btnClear.onclick = function () {
|
||||||
|
formAccountControl.classList.remove("accounting-not-empty");
|
||||||
|
formAccount.innerText = "";
|
||||||
|
formAccount.dataset.code = "";
|
||||||
|
formAccount.dataset.text = "";
|
||||||
|
validateJournalEntryAccount();
|
||||||
|
};
|
||||||
|
for (const option of options) {
|
||||||
|
option.onclick = function () {
|
||||||
|
formAccountControl.classList.add("accounting-not-empty");
|
||||||
|
formAccount.innerText = option.dataset.content;
|
||||||
|
formAccount.dataset.code = option.dataset.code;
|
||||||
|
formAccount.dataset.text = option.dataset.content;
|
||||||
|
validateJournalEntryAccount();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the query on the account options.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#initializeAccountQuery() {
|
||||||
|
const query = document.getElementById(this.#prefix + "-query");
|
||||||
|
const helper = this;
|
||||||
|
query.addEventListener("input", function () {
|
||||||
|
helper.#filterAccountOptions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters the account options.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#filterAccountOptions() {
|
||||||
|
const query = document.getElementById(this.#prefix + "-query");
|
||||||
|
const optionList = document.getElementById(this.#prefix + "-option-list");
|
||||||
|
if (optionList === null) {
|
||||||
|
console.log(this.#prefix + "-option-list");
|
||||||
|
}
|
||||||
|
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
|
||||||
|
const more = document.getElementById(this.#prefix + "-more");
|
||||||
|
const queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
|
||||||
|
const codesInUse = this.#getAccountCodeUsedInForm();
|
||||||
|
let shouldAnyShow = false;
|
||||||
|
for (const option of options) {
|
||||||
|
const shouldShow = this.#shouldAccountOptionShow(option, more, codesInUse, query);
|
||||||
|
if (shouldShow) {
|
||||||
|
option.classList.remove("d-none");
|
||||||
|
shouldAnyShow = true;
|
||||||
|
} else {
|
||||||
|
option.classList.add("d-none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!shouldAnyShow && more.classList.contains("d-none")) {
|
||||||
|
optionList.classList.add("d-none");
|
||||||
|
queryNoResult.classList.remove("d-none");
|
||||||
|
} else {
|
||||||
|
optionList.classList.remove("d-none");
|
||||||
|
queryNoResult.classList.add("d-none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the account codes that are used in the form.
|
||||||
|
*
|
||||||
|
* @return {string[]} the account codes that are used in the form
|
||||||
|
*/
|
||||||
|
#getAccountCodeUsedInForm() {
|
||||||
|
const accountCodes = Array.from(document.getElementsByClassName("accounting-" + this.#prefix + "-account-code"));
|
||||||
|
const formAccount = document.getElementById("accounting-entry-form-account");
|
||||||
|
const inUse = [formAccount.dataset.code];
|
||||||
|
for (const accountCode of accountCodes) {
|
||||||
|
inUse.push(accountCode.value);
|
||||||
|
}
|
||||||
|
return inUse
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether an account option should show.
|
||||||
|
*
|
||||||
|
* @param option {HTMLLIElement} the account option
|
||||||
|
* @param more {HTMLLIElement} the more account element
|
||||||
|
* @param inUse {string[]} the account codes that are used in the form
|
||||||
|
* @param query {HTMLInputElement} the query element, if any
|
||||||
|
* @return {boolean} true if the account option should show, or false otherwise
|
||||||
|
*/
|
||||||
|
#shouldAccountOptionShow(option, more, inUse, query) {
|
||||||
|
const isQueryMatched = function () {
|
||||||
|
if (query.value === "") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const queryValues = JSON.parse(option.dataset.queryValues);
|
||||||
|
for (const queryValue of queryValues) {
|
||||||
|
if (queryValue.includes(query.value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const isMoreMatched = function () {
|
||||||
|
if (more.classList.contains("d-none")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code);
|
||||||
|
};
|
||||||
|
return isMoreMatched() && isQueryMatched();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the account selector when it is shown.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
initShow() {
|
||||||
|
const formAccount = document.getElementById("accounting-entry-form-account");
|
||||||
|
const query = document.getElementById(this.#prefix + "-query")
|
||||||
|
const more = document.getElementById(this.#prefix + "-more");
|
||||||
|
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
|
||||||
|
const btnClear = document.getElementById(this.#prefix + "-btn-clear");
|
||||||
|
query.value = "";
|
||||||
|
more.classList.remove("d-none");
|
||||||
|
this.#filterAccountOptions();
|
||||||
|
for (const option of options) {
|
||||||
|
if (option.dataset.code === formAccount.dataset.code) {
|
||||||
|
option.classList.add("active");
|
||||||
|
} else {
|
||||||
|
option.classList.remove("active");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (formAccount.dataset.code === "") {
|
||||||
|
btnClear.classList.add("btn-secondary");
|
||||||
|
btnClear.classList.remove("btn-danger");
|
||||||
|
btnClear.disabled = true;
|
||||||
|
} else {
|
||||||
|
btnClear.classList.add("btn-danger");
|
||||||
|
btnClear.classList.remove("btn-secondary");
|
||||||
|
btnClear.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account selectors.
|
||||||
|
* @type {{debit: AccountSelector, credit: AccountSelector}}
|
||||||
|
*/
|
||||||
|
static #selectors = {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the account selectors.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
static initialize() {
|
||||||
|
const modals = Array.from(document.getElementsByClassName("accounting-account-selector-modal"));
|
||||||
|
for (const modal of modals) {
|
||||||
|
const selector = new AccountSelector(modal);
|
||||||
|
this.#selectors[selector.#entryType] = selector;
|
||||||
|
}
|
||||||
|
this.#initializeTransactionForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the transaction form.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
static #initializeTransactionForm() {
|
||||||
|
const entryForm = document.getElementById("accounting-entry-form");
|
||||||
|
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
|
||||||
|
const selectors = this.#selectors;
|
||||||
|
formAccountControl.onclick = function () {
|
||||||
|
selectors[entryForm.dataset.entryType].initShow();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Initializes the account selector for the journal entry form.
|
||||||
|
*x
|
||||||
|
*/
|
||||||
|
static initializeJournalEntryForm() {
|
||||||
|
const entryForm = document.getElementById("accounting-entry-form");
|
||||||
|
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
|
||||||
|
formAccountControl.dataset.bsTarget = "#accounting-account-selector-" + entryForm.dataset.entryType + "-modal";
|
||||||
|
}
|
||||||
|
}
|
@ -65,9 +65,9 @@ function validateForm() {
|
|||||||
*/
|
*/
|
||||||
function submitFormIfAllAsyncValid() {
|
function submitFormIfAllAsyncValid() {
|
||||||
let isValid = true;
|
let isValid = true;
|
||||||
Object.keys(isAsyncValid).forEach(function (key) {
|
for (const key of Object.keys(isAsyncValid)) {
|
||||||
isValid = isAsyncValid[key] && isValid;
|
isValid = isAsyncValid[key] && isValid;
|
||||||
});
|
}
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
document.getElementById("accounting-form").submit()
|
document.getElementById("accounting-form").submit()
|
||||||
}
|
}
|
||||||
|
@ -42,21 +42,21 @@ function initializeDragAndDropReordering(list, onReorder) {
|
|||||||
function initializeMouseDragAndDropReordering(list, onReorder) {
|
function initializeMouseDragAndDropReordering(list, onReorder) {
|
||||||
const items = Array.from(list.children);
|
const items = Array.from(list.children);
|
||||||
let dragged = null;
|
let dragged = null;
|
||||||
items.forEach(function (item) {
|
for (const item of items) {
|
||||||
item.draggable = true;
|
item.draggable = true;
|
||||||
item.addEventListener("dragstart", function () {
|
item.addEventListener("dragstart", function () {
|
||||||
dragged = item;
|
dragged = item;
|
||||||
dragged.classList.add("list-group-item-dark");
|
dragged.classList.add("accounting-dragged");
|
||||||
});
|
});
|
||||||
item.addEventListener("dragover", function () {
|
item.addEventListener("dragover", function () {
|
||||||
onDragOver(dragged, item);
|
onDragOver(dragged, item);
|
||||||
onReorder();
|
onReorder();
|
||||||
});
|
});
|
||||||
item.addEventListener("dragend", function () {
|
item.addEventListener("dragend", function () {
|
||||||
dragged.classList.remove("list-group-item-dark");
|
dragged.classList.remove("accounting-dragged");
|
||||||
dragged = null;
|
dragged = null;
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -68,9 +68,9 @@ function initializeMouseDragAndDropReordering(list, onReorder) {
|
|||||||
*/
|
*/
|
||||||
function initializeTouchDragAndDropReordering(list, onReorder) {
|
function initializeTouchDragAndDropReordering(list, onReorder) {
|
||||||
const items = Array.from(list.children);
|
const items = Array.from(list.children);
|
||||||
items.forEach(function (item) {
|
for (const item of items) {
|
||||||
item.addEventListener("touchstart", function () {
|
item.addEventListener("touchstart", function () {
|
||||||
item.classList.add("list-group-item-dark");
|
item.classList.add("accounting-dragged");
|
||||||
});
|
});
|
||||||
item.addEventListener("touchmove", function (event) {
|
item.addEventListener("touchmove", function (event) {
|
||||||
const touch = event.targetTouches[0];
|
const touch = event.targetTouches[0];
|
||||||
@ -79,9 +79,9 @@ function initializeTouchDragAndDropReordering(list, onReorder) {
|
|||||||
onReorder();
|
onReorder();
|
||||||
});
|
});
|
||||||
item.addEventListener("touchend", function () {
|
item.addEventListener("touchend", function () {
|
||||||
item.classList.remove("list-group-item-dark");
|
item.classList.remove("accounting-dragged");
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -91,7 +91,7 @@ function initializeTouchDragAndDropReordering(list, onReorder) {
|
|||||||
* @param target {Element} the other item that was dragged over
|
* @param target {Element} the other item that was dragged over
|
||||||
*/
|
*/
|
||||||
function onDragOver(dragged, target) {
|
function onDragOver(dragged, target) {
|
||||||
if (target.parentElement !== dragged.parentElement || target === dragged) {
|
if (dragged === null || target.parentElement !== dragged.parentElement || target === dragged) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let isBefore = false;
|
let isBefore = false;
|
||||||
|
44
src/accounting/static/js/material-fab-speed-dial.js
Normal file
44
src/accounting/static/js/material-fab-speed-dial.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
/* The Mia! Accounting Flask Project
|
||||||
|
* material-fab-speed-dial.js: The JavaScript for the speed dial for the material floating buttons
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Copyright (c) 2023 imacat.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
* First written: 2023/2/25
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Initializes the page JavaScript.
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
initializeMaterialFabSpeedDial();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the speed dial of the material floating buttons.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function initializeMaterialFabSpeedDial() {
|
||||||
|
const btnFab = document.getElementById("accounting-btn-material-fab-speed-dial");
|
||||||
|
const fab = document.getElementById(btnFab.dataset.target);
|
||||||
|
btnFab.onclick = function () {
|
||||||
|
if (fab.classList.contains("show")) {
|
||||||
|
fab.classList.remove("show");
|
||||||
|
} else {
|
||||||
|
fab.classList.add("show");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
827
src/accounting/static/js/summary-helper.js
Normal file
827
src/accounting/static/js/summary-helper.js
Normal file
@ -0,0 +1,827 @@
|
|||||||
|
/* The Mia! Accounting Flask Project
|
||||||
|
* summary-helper.js: The JavaScript for the summary helper
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Copyright (c) 2023 imacat.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
* First written: 2023/2/28
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Initializes the page JavaScript.
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
SummaryHelper.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A summary helper.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
class SummaryHelper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entry type, either "debit" or "credit"
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
#entryType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prefix of the HTML ID and class
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
#prefix;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default tab ID
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
#defaultTabId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a summary helper.
|
||||||
|
*
|
||||||
|
* @param form {HTMLFormElement} the summary helper form
|
||||||
|
*/
|
||||||
|
constructor(form) {
|
||||||
|
this.#entryType = form.dataset.entryType;
|
||||||
|
this.#prefix = "accounting-summary-helper-" + form.dataset.entryType;
|
||||||
|
this.#defaultTabId = form.dataset.defaultTabId;
|
||||||
|
this.#init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the summary helper.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#init() {
|
||||||
|
const helper = this;
|
||||||
|
const summary = document.getElementById(this.#prefix + "-summary");
|
||||||
|
const tabs = Array.from(document.getElementsByClassName(this.#prefix + "-tab"));
|
||||||
|
for (const tab of tabs) {
|
||||||
|
tab.onclick = function () {
|
||||||
|
helper.#switchToTab(tab.dataset.tabId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#initializeGeneralTagHelper();
|
||||||
|
this.#initializeGeneralTripHelper();
|
||||||
|
this.#initializeBusTripHelper();
|
||||||
|
this.#initializeNumberHelper();
|
||||||
|
this.#initializeSuggestedAccounts();
|
||||||
|
this.#initializeSubmission();
|
||||||
|
summary.onchange = function () {
|
||||||
|
summary.value = summary.value.trim();
|
||||||
|
helper.#parseAndPopulate();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches to a tab.
|
||||||
|
*
|
||||||
|
* @param tabId {string} the tab ID.
|
||||||
|
*/
|
||||||
|
#switchToTab(tabId) {
|
||||||
|
const tabs = Array.from(document.getElementsByClassName(this.#prefix + "-tab"));
|
||||||
|
const pages = Array.from(document.getElementsByClassName(this.#prefix + "-page"));
|
||||||
|
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-" + tabId + "-btn-tag"));
|
||||||
|
for (const tab of tabs) {
|
||||||
|
if (tab.dataset.tabId === tabId) {
|
||||||
|
tab.classList.add("active");
|
||||||
|
tab.ariaCurrent = "page";
|
||||||
|
} else {
|
||||||
|
tab.classList.remove("active");
|
||||||
|
tab.ariaCurrent = "false";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const page of pages) {
|
||||||
|
if (page.dataset.tabId === tabId) {
|
||||||
|
page.classList.remove("d-none");
|
||||||
|
page.ariaCurrent = "page";
|
||||||
|
} else {
|
||||||
|
page.classList.add("d-none");
|
||||||
|
page.ariaCurrent = "false";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let selectedBtnTag = null;
|
||||||
|
for (const tagButton of tagButtons) {
|
||||||
|
if (tagButton.classList.contains("btn-primary")) {
|
||||||
|
selectedBtnTag = tagButton;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#filterSuggestedAccounts(selectedBtnTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the general tag helper.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#initializeGeneralTagHelper() {
|
||||||
|
const summary = document.getElementById(this.#prefix + "-summary");
|
||||||
|
const tag = document.getElementById(this.#prefix + "-general-tag");
|
||||||
|
const helper = this;
|
||||||
|
const updateSummary = function () {
|
||||||
|
const pos = summary.value.indexOf("—");
|
||||||
|
const prefix = tag.value === ""? "": tag.value + "—";
|
||||||
|
if (pos === -1) {
|
||||||
|
summary.value = prefix + summary.value;
|
||||||
|
} else {
|
||||||
|
summary.value = prefix + summary.value.substring(pos + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#initializeTagButtons("general", tag, updateSummary);
|
||||||
|
tag.onchange = function () {
|
||||||
|
helper.#onTagInputChange("general", tag, updateSummary);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the general trip helper.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#initializeGeneralTripHelper() {
|
||||||
|
const summary = document.getElementById(this.#prefix + "-summary");
|
||||||
|
const tag = document.getElementById(this.#prefix + "-travel-tag");
|
||||||
|
const from = document.getElementById(this.#prefix + "-travel-from");
|
||||||
|
const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction"))
|
||||||
|
const to = document.getElementById(this.#prefix + "-travel-to");
|
||||||
|
const helper = this;
|
||||||
|
const updateSummary = function () {
|
||||||
|
let direction;
|
||||||
|
for (const directionButton of directionButtons) {
|
||||||
|
if (directionButton.classList.contains("btn-primary")) {
|
||||||
|
direction = directionButton.dataset.arrow;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary.value = tag.value + "—" + from.value + direction + to.value;
|
||||||
|
};
|
||||||
|
this.#initializeTagButtons("travel", tag, updateSummary);
|
||||||
|
tag.onchange = function () {
|
||||||
|
helper.#onTagInputChange("travel", tag, updateSummary);
|
||||||
|
helper.#validateGeneralTripTag();
|
||||||
|
};
|
||||||
|
from.onchange = function () {
|
||||||
|
updateSummary();
|
||||||
|
helper.#validateGeneralTripFrom();
|
||||||
|
};
|
||||||
|
for (const directionButton of directionButtons) {
|
||||||
|
directionButton.onclick = function () {
|
||||||
|
for (const otherButton of directionButtons) {
|
||||||
|
otherButton.classList.remove("btn-primary");
|
||||||
|
otherButton.classList.add("btn-outline-primary");
|
||||||
|
}
|
||||||
|
directionButton.classList.remove("btn-outline-primary");
|
||||||
|
directionButton.classList.add("btn-primary");
|
||||||
|
updateSummary();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
to.onchange = function () {
|
||||||
|
updateSummary();
|
||||||
|
helper.#validateGeneralTripTo();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the bus trip helper.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#initializeBusTripHelper() {
|
||||||
|
const summary = document.getElementById(this.#prefix + "-summary");
|
||||||
|
const tag = document.getElementById(this.#prefix + "-bus-tag");
|
||||||
|
const route = document.getElementById(this.#prefix + "-bus-route");
|
||||||
|
const from = document.getElementById(this.#prefix + "-bus-from");
|
||||||
|
const to = document.getElementById(this.#prefix + "-bus-to");
|
||||||
|
const helper = this;
|
||||||
|
const updateSummary = function () {
|
||||||
|
summary.value = tag.value + "—" + route.value + "—" + from.value + "→" + to.value;
|
||||||
|
};
|
||||||
|
this.#initializeTagButtons("bus", tag, updateSummary);
|
||||||
|
tag.onchange = function () {
|
||||||
|
helper.#onTagInputChange("bus", tag, updateSummary);
|
||||||
|
helper.#validateBusTripTag();
|
||||||
|
};
|
||||||
|
route.onchange = function () {
|
||||||
|
updateSummary();
|
||||||
|
helper.#validateBusTripRoute();
|
||||||
|
};
|
||||||
|
from.onchange = function () {
|
||||||
|
updateSummary();
|
||||||
|
helper.#validateBusTripFrom();
|
||||||
|
};
|
||||||
|
to.onchange = function () {
|
||||||
|
updateSummary();
|
||||||
|
helper.#validateBusTripTo();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the tag buttons.
|
||||||
|
*
|
||||||
|
* @param tabId {string} the tab ID
|
||||||
|
* @param tag {HTMLInputElement} the tag input
|
||||||
|
* @param updateSummary {function(): void} the callback to update the summary
|
||||||
|
*/
|
||||||
|
#initializeTagButtons(tabId, tag, updateSummary) {
|
||||||
|
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-" + tabId + "-btn-tag"));
|
||||||
|
const helper = this;
|
||||||
|
for (const tagButton of tagButtons) {
|
||||||
|
tagButton.onclick = function () {
|
||||||
|
for (const otherButton of tagButtons) {
|
||||||
|
otherButton.classList.remove("btn-primary");
|
||||||
|
otherButton.classList.add("btn-outline-primary");
|
||||||
|
}
|
||||||
|
tagButton.classList.remove("btn-outline-primary");
|
||||||
|
tagButton.classList.add("btn-primary");
|
||||||
|
tag.value = tagButton.dataset.value;
|
||||||
|
helper.#filterSuggestedAccounts(tagButton);
|
||||||
|
updateSummary();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The callback when the tag input is changed.
|
||||||
|
*
|
||||||
|
* @param tabId {string} the tab ID
|
||||||
|
* @param tag {HTMLInputElement} the tag input
|
||||||
|
* @param updateSummary {function(): void} the callback to update the summary
|
||||||
|
*/
|
||||||
|
#onTagInputChange(tabId, tag, updateSummary) {
|
||||||
|
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-" + tabId + "-btn-tag"));
|
||||||
|
let isMatched = false;
|
||||||
|
for (const tagButton of tagButtons) {
|
||||||
|
if (tagButton.dataset.value === tag.value) {
|
||||||
|
tagButton.classList.remove("btn-outline-primary");
|
||||||
|
tagButton.classList.add("btn-primary");
|
||||||
|
this.#filterSuggestedAccounts(tagButton);
|
||||||
|
isMatched = true;
|
||||||
|
} else {
|
||||||
|
tagButton.classList.remove("btn-primary");
|
||||||
|
tagButton.classList.add("btn-outline-primary");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isMatched) {
|
||||||
|
this.#filterSuggestedAccounts(null);
|
||||||
|
}
|
||||||
|
updateSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters the suggested accounts.
|
||||||
|
*
|
||||||
|
* @param tagButton {HTMLButtonElement|null} the tag button
|
||||||
|
*/
|
||||||
|
#filterSuggestedAccounts(tagButton) {
|
||||||
|
const accountButtons = Array.from(document.getElementsByClassName(this.#prefix + "-account"));
|
||||||
|
if (tagButton === null) {
|
||||||
|
for (const accountButton of accountButtons) {
|
||||||
|
accountButton.classList.add("d-none");
|
||||||
|
accountButton.classList.remove("btn-primary");
|
||||||
|
accountButton.classList.add("btn-outline-primary");
|
||||||
|
this.#selectAccount(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const suggested = JSON.parse(tagButton.dataset.accounts);
|
||||||
|
for (const accountButton of accountButtons) {
|
||||||
|
if (suggested.includes(accountButton.dataset.code)) {
|
||||||
|
accountButton.classList.remove("d-none");
|
||||||
|
} else {
|
||||||
|
accountButton.classList.add("d-none");
|
||||||
|
}
|
||||||
|
this.#selectAccount(suggested[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the number helper.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#initializeNumberHelper() {
|
||||||
|
const summary = document.getElementById(this.#prefix + "-summary");
|
||||||
|
const number = document.getElementById(this.#prefix + "-number");
|
||||||
|
number.onchange = function () {
|
||||||
|
const found = summary.value.match(/^(.+)×(\d+)$/);
|
||||||
|
if (found !== null) {
|
||||||
|
summary.value = found[1];
|
||||||
|
}
|
||||||
|
if (number.value > 1) {
|
||||||
|
summary.value = summary.value + "×" + String(number.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the suggested accounts.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#initializeSuggestedAccounts() {
|
||||||
|
const accountButtons = Array.from(document.getElementsByClassName(this.#prefix + "-account"));
|
||||||
|
const helper = this;
|
||||||
|
for (const accountButton of accountButtons) {
|
||||||
|
accountButton.onclick = function () {
|
||||||
|
helper.#selectAccount(accountButton.dataset.code);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a suggested account.
|
||||||
|
*
|
||||||
|
* @param selectedCode {string|null} the account code, or null to deselect the account
|
||||||
|
*/
|
||||||
|
#selectAccount(selectedCode) {
|
||||||
|
const form = document.getElementById(this.#prefix);
|
||||||
|
if (selectedCode === null) {
|
||||||
|
form.dataset.selectedAccountCode = "";
|
||||||
|
form.dataset.selectedAccountText = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const accountButtons = Array.from(document.getElementsByClassName(this.#prefix + "-account"));
|
||||||
|
for (const accountButton of accountButtons) {
|
||||||
|
if (accountButton.dataset.code === selectedCode) {
|
||||||
|
accountButton.classList.remove("btn-outline-primary");
|
||||||
|
accountButton.classList.add("btn-primary");
|
||||||
|
form.dataset.selectedAccountCode = accountButton.dataset.code;
|
||||||
|
form.dataset.selectedAccountText = accountButton.dataset.text;
|
||||||
|
} else {
|
||||||
|
accountButton.classList.remove("btn-primary");
|
||||||
|
accountButton.classList.add("btn-outline-primary");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the summary submission
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#initializeSubmission() {
|
||||||
|
const form = document.getElementById(this.#prefix);
|
||||||
|
const helper = this;
|
||||||
|
form.onsubmit = function () {
|
||||||
|
if (helper.#validate()) {
|
||||||
|
helper.#submit();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the form.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validate() {
|
||||||
|
const tabs = Array.from(document.getElementsByClassName(this.#prefix + "-tab"));
|
||||||
|
let isValid = true;
|
||||||
|
for (const tab of tabs) {
|
||||||
|
if (tab.classList.contains("active")) {
|
||||||
|
switch (tab.dataset.tabId) {
|
||||||
|
case "general":
|
||||||
|
isValid = this.#validateGeneralTag() && isValid;
|
||||||
|
break;
|
||||||
|
case "travel":
|
||||||
|
isValid = this.#validateGeneralTrip() && isValid;
|
||||||
|
break;
|
||||||
|
case "bus":
|
||||||
|
isValid = this.#validateBusTrip() && isValid;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a general tag.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validateGeneralTag() {
|
||||||
|
const field = document.getElementById(this.#prefix + "-general-tag");
|
||||||
|
const error = document.getElementById(this.#prefix + "-general-tag-error");
|
||||||
|
field.value = field.value.trim();
|
||||||
|
field.classList.remove("is-invalid");
|
||||||
|
error.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a general trip.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validateGeneralTrip() {
|
||||||
|
let isValid = true;
|
||||||
|
isValid = this.#validateGeneralTripTag() && isValid;
|
||||||
|
isValid = this.#validateGeneralTripFrom() && isValid;
|
||||||
|
isValid = this.#validateGeneralTripTo() && isValid;
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the tag of a general trip.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validateGeneralTripTag() {
|
||||||
|
const field = document.getElementById(this.#prefix + "-travel-tag");
|
||||||
|
const error = document.getElementById(this.#prefix + "-travel-tag-error");
|
||||||
|
field.value = field.value.trim();
|
||||||
|
if (field.value === "") {
|
||||||
|
field.classList.add("is-invalid");
|
||||||
|
error.innerText = A_("Please fill in the tag.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
field.classList.remove("is-invalid");
|
||||||
|
error.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the origin of a general trip.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validateGeneralTripFrom() {
|
||||||
|
const field = document.getElementById(this.#prefix + "-travel-from");
|
||||||
|
const error = document.getElementById(this.#prefix + "-travel-from-error");
|
||||||
|
field.value = field.value.trim();
|
||||||
|
if (field.value === "") {
|
||||||
|
field.classList.add("is-invalid");
|
||||||
|
error.innerText = A_("Please fill in the origin.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
field.classList.remove("is-invalid");
|
||||||
|
error.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the destination of a general trip.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validateGeneralTripTo() {
|
||||||
|
const field = document.getElementById(this.#prefix + "-travel-to");
|
||||||
|
const error = document.getElementById(this.#prefix + "-travel-to-error");
|
||||||
|
field.value = field.value.trim();
|
||||||
|
if (field.value === "") {
|
||||||
|
field.classList.add("is-invalid");
|
||||||
|
error.innerText = A_("Please fill in the destination.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
field.classList.remove("is-invalid");
|
||||||
|
error.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a bus trip.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validateBusTrip() {
|
||||||
|
let isValid = true;
|
||||||
|
isValid = this.#validateBusTripTag() && isValid;
|
||||||
|
isValid = this.#validateBusTripRoute() && isValid;
|
||||||
|
isValid = this.#validateBusTripFrom() && isValid;
|
||||||
|
isValid = this.#validateBusTripTo() && isValid;
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the tag of a bus trip.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validateBusTripTag() {
|
||||||
|
const field = document.getElementById(this.#prefix + "-bus-tag");
|
||||||
|
const error = document.getElementById(this.#prefix + "-bus-tag-error");
|
||||||
|
field.value = field.value.trim();
|
||||||
|
if (field.value === "") {
|
||||||
|
field.classList.add("is-invalid");
|
||||||
|
error.innerText = A_("Please fill in the tag.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
field.classList.remove("is-invalid");
|
||||||
|
error.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the route of a bus trip.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validateBusTripRoute() {
|
||||||
|
const field = document.getElementById(this.#prefix + "-bus-route");
|
||||||
|
const error = document.getElementById(this.#prefix + "-bus-route-error");
|
||||||
|
field.value = field.value.trim();
|
||||||
|
if (field.value === "") {
|
||||||
|
field.classList.add("is-invalid");
|
||||||
|
error.innerText = A_("Please fill in the route.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
field.classList.remove("is-invalid");
|
||||||
|
error.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the origin of a bus trip.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validateBusTripFrom() {
|
||||||
|
const field = document.getElementById(this.#prefix + "-bus-from");
|
||||||
|
const error = document.getElementById(this.#prefix + "-bus-from-error");
|
||||||
|
field.value = field.value.trim();
|
||||||
|
if (field.value === "") {
|
||||||
|
field.classList.add("is-invalid");
|
||||||
|
error.innerText = A_("Please fill in the origin.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
field.classList.remove("is-invalid");
|
||||||
|
error.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the destination of a bus trip.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validateBusTripTo() {
|
||||||
|
const field = document.getElementById(this.#prefix + "-bus-to");
|
||||||
|
const error = document.getElementById(this.#prefix + "-bus-to-error");
|
||||||
|
field.value = field.value.trim();
|
||||||
|
if (field.value === "") {
|
||||||
|
field.classList.add("is-invalid");
|
||||||
|
error.innerText = A_("Please fill in the destination.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
field.classList.remove("is-invalid");
|
||||||
|
error.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submits the summary.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#submit() {
|
||||||
|
const form = document.getElementById(this.#prefix);
|
||||||
|
const summary = document.getElementById(this.#prefix + "-summary");
|
||||||
|
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
|
||||||
|
const formSummary = document.getElementById("accounting-entry-form-summary");
|
||||||
|
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
|
||||||
|
const formAccount = document.getElementById("accounting-entry-form-account");
|
||||||
|
const helperModal = document.getElementById(this.#prefix + "-modal");
|
||||||
|
const entryModal = document.getElementById("accounting-entry-form-modal");
|
||||||
|
if (summary.value === "") {
|
||||||
|
formSummaryControl.classList.remove("accounting-not-empty");
|
||||||
|
} else {
|
||||||
|
formSummaryControl.classList.add("accounting-not-empty");
|
||||||
|
}
|
||||||
|
if (form.dataset.selectedAccountCode !== "") {
|
||||||
|
formAccountControl.classList.add("accounting-not-empty");
|
||||||
|
formAccount.dataset.code = form.dataset.selectedAccountCode;
|
||||||
|
formAccount.dataset.text = form.dataset.selectedAccountText;
|
||||||
|
formAccount.innerText = form.dataset.selectedAccountText;
|
||||||
|
}
|
||||||
|
formSummary.dataset.value = summary.value;
|
||||||
|
formSummary.innerText = summary.value;
|
||||||
|
bootstrap.Modal.getInstance(helperModal).hide();
|
||||||
|
bootstrap.Modal.getOrCreateInstance(entryModal).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the summary helper when it is shown.
|
||||||
|
*
|
||||||
|
* @param isNew {boolean} true for adding a new journal entry, or false otherwise
|
||||||
|
*/
|
||||||
|
initShow(isNew) {
|
||||||
|
const formSummary = document.getElementById("accounting-entry-form-summary");
|
||||||
|
const summary = document.getElementById(this.#prefix + "-summary");
|
||||||
|
const closeButtons = Array.from(document.getElementsByClassName(this.#prefix + "-close"));
|
||||||
|
for (const closeButton of closeButtons) {
|
||||||
|
if (isNew) {
|
||||||
|
closeButton.dataset.bsTarget = "#" + this.#prefix + "-modal";
|
||||||
|
} else {
|
||||||
|
closeButton.dataset.bsTarget = "#accounting-entry-form-modal";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#reset();
|
||||||
|
if (!isNew) {
|
||||||
|
summary.value = formSummary.dataset.value;
|
||||||
|
this.#parseAndPopulate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the summary helper.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#reset() {
|
||||||
|
const inputs = Array.from(document.getElementsByClassName(this.#prefix + "-input"));
|
||||||
|
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-btn-tag"));
|
||||||
|
const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction"));
|
||||||
|
for (const input of inputs) {
|
||||||
|
input.value = "";
|
||||||
|
input.classList.remove("is-invalid");
|
||||||
|
}
|
||||||
|
for (const tagButton of tagButtons) {
|
||||||
|
tagButton.classList.remove("btn-primary");
|
||||||
|
tagButton.classList.add("btn-outline-primary");
|
||||||
|
}
|
||||||
|
for (const directionButton of directionButtons) {
|
||||||
|
if (directionButton.classList.contains("accounting-default")) {
|
||||||
|
directionButton.classList.remove("btn-outline-primary");
|
||||||
|
directionButton.classList.add("btn-primary");
|
||||||
|
} else {
|
||||||
|
directionButton.classList.add("btn-outline-primary");
|
||||||
|
directionButton.classList.remove("btn-primary");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#filterSuggestedAccounts(null);
|
||||||
|
this.#switchToTab(this.#defaultTabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the summary input and populates the summary helper.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#parseAndPopulate() {
|
||||||
|
const summary = document.getElementById(this.#prefix + "-summary");
|
||||||
|
const pos = summary.value.indexOf("—");
|
||||||
|
if (pos === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let found;
|
||||||
|
found = summary.value.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:×(\d+))?$/);
|
||||||
|
if (found !== null) {
|
||||||
|
return this.#populateBusTrip(found[1], found[2], found[3], found[4], found[5]);
|
||||||
|
}
|
||||||
|
found = summary.value.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:×(\d+))?$/);
|
||||||
|
if (found !== null) {
|
||||||
|
return this.#populateGeneralTrip(found[1], found[2], found[3], found[4], found[5]);
|
||||||
|
}
|
||||||
|
found = summary.value.match(/^([^—]+)—.+?(?:×(\d+)?)?$/);
|
||||||
|
if (found !== null) {
|
||||||
|
return this.#populateGeneralTag(found[1], found[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populates a bus trip.
|
||||||
|
*
|
||||||
|
* @param tagName {string} the tag name
|
||||||
|
* @param routeName {string} the route name or route number
|
||||||
|
* @param fromName {string} the name of the origin
|
||||||
|
* @param toName {string} the name of the destination
|
||||||
|
* @param numberStr {string|undefined} the number of items, if any
|
||||||
|
*/
|
||||||
|
#populateBusTrip(tagName, routeName, fromName, toName, numberStr) {
|
||||||
|
const tag = document.getElementById(this.#prefix + "-bus-tag");
|
||||||
|
const route = document.getElementById(this.#prefix + "-bus-route");
|
||||||
|
const from = document.getElementById(this.#prefix + "-bus-from");
|
||||||
|
const to = document.getElementById(this.#prefix + "-bus-to");
|
||||||
|
const number = document.getElementById(this.#prefix + "-number");
|
||||||
|
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-bus-btn-tag"));
|
||||||
|
tag.value = tagName;
|
||||||
|
route.value = routeName;
|
||||||
|
from.value = fromName;
|
||||||
|
to.value = toName;
|
||||||
|
if (numberStr !== undefined) {
|
||||||
|
number.value = parseInt(numberStr);
|
||||||
|
}
|
||||||
|
for (const tagButton of tagButtons) {
|
||||||
|
if (tagButton.dataset.value === tagName) {
|
||||||
|
tagButton.classList.remove("btn-outline-primary");
|
||||||
|
tagButton.classList.add("btn-primary");
|
||||||
|
this.#filterSuggestedAccounts(tagButton);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#switchToTab("bus");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populates a general trip.
|
||||||
|
*
|
||||||
|
* @param tagName {string} the tag name
|
||||||
|
* @param fromName {string} the name of the origin
|
||||||
|
* @param direction {string} the direction arrow, either "→" or "↔"
|
||||||
|
* @param toName {string} the name of the destination
|
||||||
|
* @param numberStr {string|undefined} the number of items, if any
|
||||||
|
*/
|
||||||
|
#populateGeneralTrip(tagName, fromName, direction, toName, numberStr) {
|
||||||
|
const tag = document.getElementById(this.#prefix + "-travel-tag");
|
||||||
|
const from = document.getElementById(this.#prefix + "-travel-from");
|
||||||
|
const to = document.getElementById(this.#prefix + "-travel-to");
|
||||||
|
const number = document.getElementById(this.#prefix + "-number");
|
||||||
|
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-btn-tag"));
|
||||||
|
const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction"));
|
||||||
|
tag.value = tagName;
|
||||||
|
from.value = fromName;
|
||||||
|
for (const directionButton of directionButtons) {
|
||||||
|
if (directionButton.dataset.arrow === direction) {
|
||||||
|
directionButton.classList.remove("btn-outline-primary");
|
||||||
|
directionButton.classList.add("btn-primary");
|
||||||
|
} else {
|
||||||
|
directionButton.classList.add("btn-outline-primary");
|
||||||
|
directionButton.classList.remove("btn-primary");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
to.value = toName;
|
||||||
|
if (numberStr !== undefined) {
|
||||||
|
number.value = parseInt(numberStr);
|
||||||
|
}
|
||||||
|
for (const tagButton of tagButtons) {
|
||||||
|
if (tagButton.dataset.value === tagName) {
|
||||||
|
tagButton.classList.remove("btn-outline-primary");
|
||||||
|
tagButton.classList.add("btn-primary");
|
||||||
|
this.#filterSuggestedAccounts(tagButton);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#switchToTab("travel");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populates a general tag.
|
||||||
|
*
|
||||||
|
* @param tagName {string} the tag name
|
||||||
|
* @param numberStr {string|undefined} the number of items, if any
|
||||||
|
*/
|
||||||
|
#populateGeneralTag(tagName, numberStr) {
|
||||||
|
const tag = document.getElementById(this.#prefix + "-general-tag");
|
||||||
|
const number = document.getElementById(this.#prefix + "-number");
|
||||||
|
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-general-btn-tag"));
|
||||||
|
tag.value = tagName;
|
||||||
|
if (numberStr !== undefined) {
|
||||||
|
number.value = parseInt(numberStr);
|
||||||
|
}
|
||||||
|
for (const tagButton of tagButtons) {
|
||||||
|
if (tagButton.dataset.value === tagName) {
|
||||||
|
tagButton.classList.remove("btn-outline-primary");
|
||||||
|
tagButton.classList.add("btn-primary");
|
||||||
|
this.#filterSuggestedAccounts(tagButton);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#switchToTab("general");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The summary helpers.
|
||||||
|
* @type {{debit: SummaryHelper, credit: SummaryHelper}}
|
||||||
|
*/
|
||||||
|
static #helpers = {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the summary helpers.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
static initialize() {
|
||||||
|
const forms = Array.from(document.getElementsByClassName("accounting-summary-helper"));
|
||||||
|
for (const form of forms) {
|
||||||
|
const helper = new SummaryHelper(form);
|
||||||
|
this.#helpers[helper.#entryType] = helper;
|
||||||
|
}
|
||||||
|
this.#initializeTransactionForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the transaction form.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
static #initializeTransactionForm() {
|
||||||
|
const entryForm = document.getElementById("accounting-entry-form");
|
||||||
|
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
|
||||||
|
const helpers = this.#helpers;
|
||||||
|
formSummaryControl.onclick = function () {
|
||||||
|
helpers[entryForm.dataset.entryType].initShow(false);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the summary helper for a new journal entry.
|
||||||
|
*
|
||||||
|
* @param entryType {string} the entry type, either "debit" or "credit"
|
||||||
|
*/
|
||||||
|
static initializeNewJournalEntry(entryType) {
|
||||||
|
this.#helpers[entryType].initShow(true);
|
||||||
|
}
|
||||||
|
}
|
679
src/accounting/static/js/transaction-form.js
Normal file
679
src/accounting/static/js/transaction-form.js
Normal file
@ -0,0 +1,679 @@
|
|||||||
|
/* The Mia! Accounting Flask Project
|
||||||
|
* transaction-transfer-form.js: The JavaScript for the transfer transaction form
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Copyright (c) 2023 imacat.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
* First written: 2023/2/25
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Initializes the page JavaScript.
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
initializeCurrencyForms();
|
||||||
|
initializeJournalEntries();
|
||||||
|
initializeFormValidation();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes the HTML special characters and returns.
|
||||||
|
*
|
||||||
|
* @param s {string} the original string
|
||||||
|
* @returns {string} the string with HTML special character escaped
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll("\"", """);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a Decimal number.
|
||||||
|
*
|
||||||
|
* @param number {Decimal} the Decimal number
|
||||||
|
* @returns {string} the formatted Decimal number
|
||||||
|
*/
|
||||||
|
function formatDecimal(number) {
|
||||||
|
if (number.equals(new Decimal("0"))) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const frac = number.modulo(1);
|
||||||
|
const whole = Number(number.minus(frac)).toLocaleString();
|
||||||
|
return whole + String(frac).substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the currency forms.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function initializeCurrencyForms() {
|
||||||
|
const form = document.getElementById("accounting-form");
|
||||||
|
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 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 () {
|
||||||
|
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
|
||||||
|
let maxIndex = 0;
|
||||||
|
for (const currency of currencies) {
|
||||||
|
const index = parseInt(currency.dataset.index);
|
||||||
|
if (maxIndex < index) {
|
||||||
|
maxIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const newIndex = String(maxIndex + 1);
|
||||||
|
const html = form.dataset.currencyTemplate
|
||||||
|
.replaceAll("CURRENCY_INDEX", escapeHtml(newIndex));
|
||||||
|
currencyList.insertAdjacentHTML("beforeend", html);
|
||||||
|
const newEntryButtons = Array.from(document.getElementsByClassName("accounting-currency-" + newIndex + "-btn-new-entry"));
|
||||||
|
const btnDelete = document.getElementById("accounting-btn-delete-currency-" + newIndex);
|
||||||
|
newEntryButtons.forEach(initializeNewEntryButton);
|
||||||
|
initializeBtnDeleteCurrency(btnDelete);
|
||||||
|
resetDeleteCurrencyButtons();
|
||||||
|
initializeDragAndDropReordering(currencyList, onReorder);
|
||||||
|
};
|
||||||
|
deleteButtons.forEach(initializeBtnDeleteCurrency);
|
||||||
|
initializeDragAndDropReordering(currencyList, onReorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the button to delete a currency.
|
||||||
|
*
|
||||||
|
* @param button {HTMLButtonElement} the button to delete a currency.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function initializeBtnDeleteCurrency(button) {
|
||||||
|
const target = document.getElementById(button.dataset.target);
|
||||||
|
button.onclick = function () {
|
||||||
|
target.parentElement.removeChild(target);
|
||||||
|
resetDeleteCurrencyButtons();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the status of the delete currency buttons.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function resetDeleteCurrencyButtons() {
|
||||||
|
const buttons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency"));
|
||||||
|
if (buttons.length > 1) {
|
||||||
|
for (const button of buttons) {
|
||||||
|
button.classList.remove("d-none");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
buttons[0].classList.add("d-none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the journal entry forms.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function initializeJournalEntries() {
|
||||||
|
const newButtons = Array.from(document.getElementsByClassName("accounting-btn-new-entry"));
|
||||||
|
const entryLists = Array.from(document.getElementsByClassName("accounting-entry-list"));
|
||||||
|
const entries = Array.from(document.getElementsByClassName("accounting-entry"))
|
||||||
|
const deleteButtons = Array.from(document.getElementsByClassName("accounting-btn-delete-entry"));
|
||||||
|
newButtons.forEach(initializeNewEntryButton);
|
||||||
|
entryLists.forEach(initializeJournalEntryListReorder);
|
||||||
|
entries.forEach(initializeJournalEntry);
|
||||||
|
deleteButtons.forEach(initializeDeleteJournalEntryButton);
|
||||||
|
initializeJournalEntryFormModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the button to add a new journal entry.
|
||||||
|
*
|
||||||
|
* @param button {HTMLButtonElement} the button to add a new journal entry
|
||||||
|
*/
|
||||||
|
function initializeNewEntryButton(button) {
|
||||||
|
const entryForm = document.getElementById("accounting-entry-form");
|
||||||
|
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
|
||||||
|
const formAccount = document.getElementById("accounting-entry-form-account");
|
||||||
|
const formAccountError = document.getElementById("accounting-entry-form-account-error")
|
||||||
|
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
|
||||||
|
const formSummary = document.getElementById("accounting-entry-form-summary");
|
||||||
|
const 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 () {
|
||||||
|
entryForm.dataset.currencyIndex = button.dataset.currencyIndex;
|
||||||
|
entryForm.dataset.entryType = button.dataset.entryType;
|
||||||
|
entryForm.dataset.entryIndex = button.dataset.entryIndex;
|
||||||
|
formAccountControl.classList.remove("accounting-not-empty");
|
||||||
|
formAccountControl.classList.remove("is-invalid");
|
||||||
|
formAccount.innerText = "";
|
||||||
|
formAccount.dataset.code = "";
|
||||||
|
formAccount.dataset.text = "";
|
||||||
|
formAccountError.innerText = "";
|
||||||
|
formSummaryControl.dataset.bsTarget = "#accounting-summary-helper-" + button.dataset.entryType + "-modal";
|
||||||
|
formSummaryControl.classList.remove("accounting-not-empty");
|
||||||
|
formSummaryControl.classList.remove("is-invalid");
|
||||||
|
formSummary.dataset.value = "";
|
||||||
|
formSummary.innerText = ""
|
||||||
|
formSummaryError.innerText = ""
|
||||||
|
formAmount.value = "";
|
||||||
|
formAmount.classList.remove("is-invalid");
|
||||||
|
formAmountError.innerText = "";
|
||||||
|
AccountSelector.initializeJournalEntryForm();
|
||||||
|
SummaryHelper.initializeNewJournalEntry(button.dataset.entryType);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the reordering of a journal entry list.
|
||||||
|
*
|
||||||
|
* @param entryList {HTMLUListElement} the journal entry list.
|
||||||
|
*/
|
||||||
|
function initializeJournalEntryListReorder(entryList) {
|
||||||
|
initializeDragAndDropReordering(entryList, function () {
|
||||||
|
const entries = Array.from(entryList.children);
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
const no = document.getElementById(entries[i].dataset.prefix + "-no");
|
||||||
|
no.value = String(i + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the journal entry.
|
||||||
|
*
|
||||||
|
* @param entry {HTMLLIElement} the journal entry.
|
||||||
|
*/
|
||||||
|
function initializeJournalEntry(entry) {
|
||||||
|
const entryForm = document.getElementById("accounting-entry-form");
|
||||||
|
const accountCode = document.getElementById(entry.dataset.prefix + "-account-code");
|
||||||
|
const summary = document.getElementById(entry.dataset.prefix + "-summary");
|
||||||
|
const amount = document.getElementById(entry.dataset.prefix + "-amount");
|
||||||
|
const control = document.getElementById(entry.dataset.prefix + "-control");
|
||||||
|
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
|
||||||
|
const formAccount = document.getElementById("accounting-entry-form-account");
|
||||||
|
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
|
||||||
|
const formSummary = document.getElementById("accounting-entry-form-summary");
|
||||||
|
const formAmount = document.getElementById("accounting-entry-form-amount");
|
||||||
|
control.onclick = function () {
|
||||||
|
entryForm.dataset.currencyIndex = entry.dataset.currencyIndex;
|
||||||
|
entryForm.dataset.entryType = entry.dataset.entryType;
|
||||||
|
entryForm.dataset.entryIndex = entry.dataset.entryIndex;
|
||||||
|
if (accountCode.value === "") {
|
||||||
|
formAccountControl.classList.remove("accounting-not-empty");
|
||||||
|
} else {
|
||||||
|
formAccountControl.classList.add("accounting-not-empty");
|
||||||
|
}
|
||||||
|
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";
|
||||||
|
if (summary.value === "") {
|
||||||
|
formSummaryControl.classList.remove("accounting-not-empty");
|
||||||
|
} else {
|
||||||
|
formSummaryControl.classList.add("accounting-not-empty");
|
||||||
|
}
|
||||||
|
formSummary.dataset.value = summary.value;
|
||||||
|
formSummary.innerText = summary.value;
|
||||||
|
formAmount.value = amount.value;
|
||||||
|
AccountSelector.initializeJournalEntryForm();
|
||||||
|
validateJournalEntryForm();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the journal entry form modal.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function initializeJournalEntryFormModal() {
|
||||||
|
const entryForm = document.getElementById("accounting-entry-form");
|
||||||
|
const formAmount = document.getElementById("accounting-entry-form-amount");
|
||||||
|
const modal = document.getElementById("accounting-entry-form-modal");
|
||||||
|
formAmount.onchange = validateJournalEntryAmount;
|
||||||
|
entryForm.onsubmit = function () {
|
||||||
|
if (validateJournalEntryForm()) {
|
||||||
|
saveJournalEntryForm();
|
||||||
|
bootstrap.Modal.getInstance(modal).hide();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the journal entry form modal.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if the form is valid, or false otherwise.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function validateJournalEntryForm() {
|
||||||
|
let isValid = true;
|
||||||
|
isValid = validateJournalEntryAccount() && isValid;
|
||||||
|
isValid = validateJournalEntrySummary() && isValid;
|
||||||
|
isValid = validateJournalEntryAmount() && isValid
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the account in the journal entry form modal.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
function validateJournalEntryAccount() {
|
||||||
|
const field = document.getElementById("accounting-entry-form-account");
|
||||||
|
const error = document.getElementById("accounting-entry-form-account-error");
|
||||||
|
const control = document.getElementById("accounting-entry-form-account-control");
|
||||||
|
if (field.dataset.code === "") {
|
||||||
|
control.classList.add("is-invalid");
|
||||||
|
error.innerText = A_("Please select the account.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
control.classList.remove("is-invalid");
|
||||||
|
error.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the summary in the journal entry form modal.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function validateJournalEntrySummary() {
|
||||||
|
const control = document.getElementById("accounting-entry-form-summary-control");
|
||||||
|
const error = document.getElementById("accounting-entry-form-summary-error");
|
||||||
|
control.classList.remove("is-invalid");
|
||||||
|
error.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the amount in the journal entry form modal.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function validateJournalEntryAmount() {
|
||||||
|
const field = document.getElementById("accounting-entry-form-amount");
|
||||||
|
const error = document.getElementById("accounting-entry-form-amount-error");
|
||||||
|
field.value = field.value.trim();
|
||||||
|
field.classList.remove("is-invalid");
|
||||||
|
if (field.value === "") {
|
||||||
|
field.classList.add("is-invalid");
|
||||||
|
error.innerText = A_("Please fill in the amount.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
error.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the journal entry form modal to the form.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function saveJournalEntryForm() {
|
||||||
|
const form = document.getElementById("accounting-form");
|
||||||
|
const entryForm = document.getElementById("accounting-entry-form");
|
||||||
|
const formAccount = document.getElementById("accounting-entry-form-account");
|
||||||
|
const formSummary = document.getElementById("accounting-entry-form-summary");
|
||||||
|
const formAmount = document.getElementById("accounting-entry-form-amount");
|
||||||
|
const currencyIndex = entryForm.dataset.currencyIndex;
|
||||||
|
const entryType = entryForm.dataset.entryType;
|
||||||
|
let entryIndex;
|
||||||
|
if (entryForm.dataset.entryIndex === "new") {
|
||||||
|
const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType));
|
||||||
|
const entryList = document.getElementById("accounting-currency-" + currencyIndex + "-" + entryType + "-list")
|
||||||
|
let maxIndex = 0;
|
||||||
|
for (const entry of entries) {
|
||||||
|
const index = parseInt(entry.dataset.entryIndex);
|
||||||
|
if (maxIndex < index) {
|
||||||
|
maxIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entryIndex = String(maxIndex + 1);
|
||||||
|
const html = form.dataset.entryTemplate
|
||||||
|
.replaceAll("CURRENCY_INDEX", escapeHtml(currencyIndex))
|
||||||
|
.replaceAll("ENTRY_TYPE", escapeHtml(entryType))
|
||||||
|
.replaceAll("ENTRY_INDEX", escapeHtml(entryIndex));
|
||||||
|
entryList.insertAdjacentHTML("beforeend", html);
|
||||||
|
initializeJournalEntryListReorder(entryList);
|
||||||
|
} else {
|
||||||
|
entryIndex = entryForm.dataset.entryIndex;
|
||||||
|
}
|
||||||
|
const currency = document.getElementById("accounting-currency-" + currencyIndex);
|
||||||
|
const entry = document.getElementById("accounting-currency-" + currencyIndex + "-" + entryType + "-" + entryIndex);
|
||||||
|
const accountCode = document.getElementById(entry.dataset.prefix + "-account-code");
|
||||||
|
const accountText = document.getElementById(entry.dataset.prefix + "-account-text");
|
||||||
|
const summary = document.getElementById(entry.dataset.prefix + "-summary");
|
||||||
|
const summaryText = document.getElementById(entry.dataset.prefix + "-summary-text");
|
||||||
|
const amount = document.getElementById(entry.dataset.prefix + "-amount");
|
||||||
|
const amountText = document.getElementById(entry.dataset.prefix + "-amount-text");
|
||||||
|
accountCode.value = formAccount.dataset.code;
|
||||||
|
accountCode.dataset.text = formAccount.dataset.text;
|
||||||
|
accountText.innerText = formAccount.dataset.text;
|
||||||
|
summary.value = formSummary.dataset.value;
|
||||||
|
summaryText.innerText = formSummary.dataset.value;
|
||||||
|
amount.value = formAmount.value;
|
||||||
|
amountText.innerText = formatDecimal(new Decimal(formAmount.value));
|
||||||
|
if (entryForm.dataset.entryIndex === "new") {
|
||||||
|
const btnDelete = document.getElementById(entry.dataset.prefix + "-btn-delete");
|
||||||
|
initializeJournalEntry(entry);
|
||||||
|
initializeDeleteJournalEntryButton(btnDelete);
|
||||||
|
resetDeleteJournalEntryButtons(btnDelete.dataset.sameClass);
|
||||||
|
}
|
||||||
|
updateBalance(currencyIndex, entryType);
|
||||||
|
validateJournalEntriesReal(currencyIndex, entryType);
|
||||||
|
validateBalance(currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the button to delete a journal entry.
|
||||||
|
*
|
||||||
|
* @param button {HTMLButtonElement} the button to delete a journal entry
|
||||||
|
*/
|
||||||
|
function initializeDeleteJournalEntryButton(button) {
|
||||||
|
const target = document.getElementById(button.dataset.target);
|
||||||
|
const currencyIndex = target.dataset.currencyIndex;
|
||||||
|
const entryType = target.dataset.entryType;
|
||||||
|
const currency = document.getElementById("accounting-currency-" + currencyIndex);
|
||||||
|
button.onclick = function () {
|
||||||
|
target.parentElement.removeChild(target);
|
||||||
|
resetDeleteJournalEntryButtons(button.dataset.sameClass);
|
||||||
|
updateBalance(currencyIndex, entryType);
|
||||||
|
validateJournalEntriesReal(currencyIndex, entryType);
|
||||||
|
validateBalance(currency);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the status of the delete journal entry buttons.
|
||||||
|
*
|
||||||
|
* @param sameClass {string} the class of the buttons
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function resetDeleteJournalEntryButtons(sameClass) {
|
||||||
|
const buttons = Array.from(document.getElementsByClassName(sameClass));
|
||||||
|
if (buttons.length > 1) {
|
||||||
|
for (const button of buttons) {
|
||||||
|
button.classList.remove("d-none");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
buttons[0].classList.add("d-none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the balance.
|
||||||
|
*
|
||||||
|
* @param currencyIndex {string} the currency index.
|
||||||
|
* @param entryType {string} the journal entry type, either "debit" or "credit"
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function updateBalance(currencyIndex, entryType) {
|
||||||
|
const prefix = "accounting-currency-" + currencyIndex + "-" + entryType;
|
||||||
|
const amounts = Array.from(document.getElementsByClassName(prefix + "-amount"));
|
||||||
|
const totalText = document.getElementById(prefix + "-total");
|
||||||
|
let total = new Decimal("0");
|
||||||
|
for (const amount of amounts) {
|
||||||
|
if (amount.value !== "") {
|
||||||
|
total = total.plus(new Decimal(amount.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
totalText.innerText = formatDecimal(total);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the form validation.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function initializeFormValidation() {
|
||||||
|
const date = document.getElementById("accounting-date");
|
||||||
|
const note = document.getElementById("accounting-note");
|
||||||
|
const form = document.getElementById("accounting-form");
|
||||||
|
date.onchange = validateDate;
|
||||||
|
note.onchange = validateNote;
|
||||||
|
form.onsubmit = validateForm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the form.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function validateForm() {
|
||||||
|
let isValid = true;
|
||||||
|
isValid = validateDate() && isValid;
|
||||||
|
isValid = validateCurrencies() && isValid;
|
||||||
|
isValid = validateNote() && isValid;
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the date.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function validateDate() {
|
||||||
|
const field = document.getElementById("accounting-date");
|
||||||
|
const error = document.getElementById("accounting-date-error");
|
||||||
|
field.value = field.value.trim();
|
||||||
|
field.classList.remove("is-invalid");
|
||||||
|
if (field.value === "") {
|
||||||
|
field.classList.add("is-invalid");
|
||||||
|
error.innerText = A_("Please fill in the date.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
error.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the currency sub-forms.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function validateCurrencies() {
|
||||||
|
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
|
||||||
|
let isValid = true;
|
||||||
|
isValid = validateCurrenciesReal() && isValid;
|
||||||
|
for (const currency of currencies) {
|
||||||
|
isValid = validateCurrency(currency) && isValid;
|
||||||
|
}
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the currency sub-forms, the validator itself.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function validateCurrenciesReal() {
|
||||||
|
const field = document.getElementById("accounting-currencies");
|
||||||
|
const error = document.getElementById("accounting-currencies-error");
|
||||||
|
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
|
||||||
|
if (currencies.length === 0) {
|
||||||
|
field.classList.add("is-invalid");
|
||||||
|
error.innerText = A_("Please add some currencies.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
field.classList.remove("is-invalid");
|
||||||
|
error.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a currency sub-form.
|
||||||
|
*
|
||||||
|
* @param currency {HTMLDivElement} the currency sub-form
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function validateCurrency(currency) {
|
||||||
|
const prefix = "accounting-currency-" + currency.dataset.index;
|
||||||
|
const debit = document.getElementById(prefix + "-debit");
|
||||||
|
const credit = document.getElementById(prefix + "-credit");
|
||||||
|
let isValid = true;
|
||||||
|
if (debit !== null) {
|
||||||
|
isValid = validateJournalEntries(currency, "debit") && isValid;
|
||||||
|
}
|
||||||
|
if (credit !== null) {
|
||||||
|
isValid = validateJournalEntries(currency, "credit") && isValid;
|
||||||
|
}
|
||||||
|
if (debit !== null && credit !== null) {
|
||||||
|
isValid = validateBalance(currency) && isValid;
|
||||||
|
}
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the journal entries in a currency sub-form.
|
||||||
|
*
|
||||||
|
* @param currency {HTMLDivElement} the currency
|
||||||
|
* @param entryType {string} the journal entry type, either "debit" or "credit"
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function validateJournalEntries(currency, entryType) {
|
||||||
|
const currencyIndex = currency.dataset.index;
|
||||||
|
const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType));
|
||||||
|
let isValid = true;
|
||||||
|
isValid = validateJournalEntriesReal(currencyIndex, entryType) && isValid;
|
||||||
|
for (const entry of entries) {
|
||||||
|
isValid = validateJournalEntry(entry) && isValid;
|
||||||
|
}
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the journal entries, the validator itself.
|
||||||
|
*
|
||||||
|
* @param currencyIndex {string} the currency index
|
||||||
|
* @param entryType {string} the journal entry type, either "debit" or "credit"
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function validateJournalEntriesReal(currencyIndex, entryType) {
|
||||||
|
const prefix = "accounting-currency-" + currencyIndex + "-" + entryType;
|
||||||
|
const field = document.getElementById(prefix);
|
||||||
|
const error = document.getElementById(prefix + "-error");
|
||||||
|
const entries = Array.from(document.getElementsByClassName(prefix));
|
||||||
|
if (entries.length === 0) {
|
||||||
|
field.classList.add("is-invalid");
|
||||||
|
error.innerText = A_("Please add some journal entries.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
field.classList.remove("is-invalid");
|
||||||
|
error.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a journal entry sub-form in a currency sub-form.
|
||||||
|
*
|
||||||
|
* @param entry {HTMLLIElement} the journal entry
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function validateJournalEntry(entry) {
|
||||||
|
const control = document.getElementById(entry.dataset.prefix + "-control");
|
||||||
|
const error = document.getElementById(entry.dataset.prefix + "-error");
|
||||||
|
const accountCode = document.getElementById(entry.dataset.prefix + "-account-code");
|
||||||
|
const amount = document.getElementById(entry.dataset.prefix + "-amount");
|
||||||
|
if (accountCode.value === "") {
|
||||||
|
control.classList.add("is-invalid");
|
||||||
|
error.innerText = A_("Please select the account.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (amount.value === "") {
|
||||||
|
control.classList.add("is-invalid");
|
||||||
|
error.innerText = A_("Please fill in the amount.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
control.classList.remove("is-invalid");
|
||||||
|
error.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the balance of a currency sub-form.
|
||||||
|
*
|
||||||
|
* @param currency {HTMLDivElement} the currency sub-form
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function validateBalance(currency) {
|
||||||
|
const prefix = "accounting-currency-" + currency.dataset.index;
|
||||||
|
const control = document.getElementById(prefix + "-control");
|
||||||
|
const error = document.getElementById(prefix + "-error");
|
||||||
|
const debit = document.getElementById(prefix + "-debit");
|
||||||
|
const debitAmounts = Array.from(document.getElementsByClassName(prefix + "-debit-amount"));
|
||||||
|
const credit = document.getElementById(prefix + "-credit");
|
||||||
|
const creditAmounts = Array.from(document.getElementsByClassName(prefix + "-credit-amount"));
|
||||||
|
if (debit !== null && credit !== null) {
|
||||||
|
let debitTotal = new Decimal("0");
|
||||||
|
for (const amount of debitAmounts) {
|
||||||
|
if (amount.value !== "") {
|
||||||
|
debitTotal = debitTotal.plus(new Decimal(amount.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let creditTotal = new Decimal("0");
|
||||||
|
for (const amount of creditAmounts) {
|
||||||
|
if (amount.value !== "") {
|
||||||
|
creditTotal = creditTotal.plus(new Decimal(amount.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!debitTotal.equals(creditTotal)) {
|
||||||
|
control.classList.add("is-invalid");
|
||||||
|
error.innerText = A_("The totals of the debit and credit amounts do not match.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
control.classList.remove("is-invalid");
|
||||||
|
error.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the note.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function validateNote() {
|
||||||
|
const field = document.getElementById("accounting-note");
|
||||||
|
const error = document.getElementById("accounting-note-error");
|
||||||
|
field.value = field.value
|
||||||
|
.replace(/^\s*\n/, "")
|
||||||
|
.trimEnd();
|
||||||
|
field.classList.remove("is-invalid");
|
||||||
|
error.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
37
src/accounting/static/js/transaction-order.js
Normal file
37
src/accounting/static/js/transaction-order.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
/* The Mia! Accounting Flask Project
|
||||||
|
* transaction-order.js: The JavaScript for the transaction order
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 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/26
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Initializes the page JavaScript.
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
const list = document.getElementById("accounting-order-list");
|
||||||
|
if (list !== null) {
|
||||||
|
const onReorder = function () {
|
||||||
|
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");
|
||||||
|
no.value = String(i + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
initializeDragAndDropReordering(list, onReorder);
|
||||||
|
}
|
||||||
|
});
|
@ -62,12 +62,12 @@ First written: 2023/1/31
|
|||||||
{% if "next" in request.args %}
|
{% if "next" in request.args %}
|
||||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-model-label" aria-hidden="true">
|
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h1 class="modal-title fs-5" id="accounting-delete-model-label">{{ A_("Delete Account Confirmation") }}</h1>
|
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Account Confirmation") }}</h1>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
{{ A_("Do you really want to delete this account?") }}
|
{{ A_("Do you really want to delete this account?") }}
|
||||||
@ -85,9 +85,9 @@ First written: 2023/1/31
|
|||||||
<div class="accounting-card col-sm-6">
|
<div class="accounting-card col-sm-6">
|
||||||
<div class="accounting-card-title">{{ obj.title }}</div>
|
<div class="accounting-card-title">{{ obj.title }}</div>
|
||||||
<div class="accounting-card-code">{{ obj.code }}</div>
|
<div class="accounting-card-code">{{ obj.code }}</div>
|
||||||
{% if obj.is_offset_needed %}
|
{% if obj.is_pay_off_needed %}
|
||||||
<div>
|
<div>
|
||||||
<span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span>
|
<span class="badge rounded-pill bg-info">{{ A_("Pay-off needed") }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="small text-secondary fst-italic">
|
<div class="small text-secondary fst-italic">
|
||||||
|
@ -41,7 +41,7 @@ First written: 2023/2/1
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="form-floating mb-3">
|
<div class="form-floating mb-3">
|
||||||
<input id="accounting-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}">
|
<input id="accounting-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}">
|
||||||
<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-model">
|
<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>
|
<label class="form-label" for="accounting-base">{{ A_("Base account") }}</label>
|
||||||
<div id="accounting-base-content">
|
<div id="accounting-base-content">
|
||||||
{% if form.base_code.data %}
|
{% if form.base_code.data %}
|
||||||
@ -63,9 +63,9 @@ First written: 2023/2/1
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-check form-switch mb-3">
|
<div class="form-check form-switch mb-3">
|
||||||
<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 %}>
|
<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-offset-needed">
|
<label class="form-check-label" for="accounting-is-pay-off-needed">
|
||||||
{{ A_("The entries in the account need offsets.") }}
|
{{ A_("The entries in the account need pay-off.") }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -83,16 +83,16 @@ First written: 2023/2/1
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="modal fade" id="accounting-base-selector-model" tabindex="-1" aria-labelledby="accounting-base-selector-model-label" aria-hidden="true">
|
<div class="modal fade" id="accounting-base-selector-modal" tabindex="-1" aria-labelledby="accounting-base-selector-modal-label" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h1 class="modal-title fs-5" id="accounting-base-selector-model-label">{{ A_("Select Base Account") }}</h1>
|
<h1 class="modal-title fs-5" id="accounting-base-selector-modal-label">{{ A_("Select Base Account") }}</h1>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="input-group mb-2">
|
<div class="input-group mb-2">
|
||||||
<input id="accounting-base-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required" aria-label="Search">
|
<input id="accounting-base-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
|
||||||
<label class="input-group-text" for="accounting-base-selector-query">
|
<label class="input-group-text" for="accounting-base-selector-query">
|
||||||
<i class="fa-solid fa-magnifying-glass"></i>
|
<i class="fa-solid fa-magnifying-glass"></i>
|
||||||
{{ A_("Search") }}
|
{{ A_("Search") }}
|
||||||
|
@ -25,16 +25,28 @@ First written: 2023/1/30
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="btn-group mb-2">
|
<div class="btn-group mb-2 d-none d-md-inline-flex">
|
||||||
{% if accounting_can_edit() %}
|
{% if accounting_can_edit() %}
|
||||||
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
|
<a class="btn btn-primary text-nowrap" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
|
||||||
<i class="fa-solid fa-plus"></i>
|
<i class="fa-solid fa-plus"></i>
|
||||||
{{ A_("New") }}
|
{{ A_("New") }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search">
|
<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" 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" aria-label="Search">
|
<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" class="accounting-search-label">
|
<label for="accounting-search-desktop" class="accounting-search-label">
|
||||||
|
<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">
|
<button type="submit">
|
||||||
<i class="fa-solid fa-magnifying-glass"></i>
|
<i class="fa-solid fa-magnifying-glass"></i>
|
||||||
{{ A_("Search") }}
|
{{ A_("Search") }}
|
||||||
@ -58,8 +70,8 @@ First written: 2023/1/30
|
|||||||
{% for item in list %}
|
{% for item in list %}
|
||||||
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}">
|
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}">
|
||||||
{{ item }}
|
{{ item }}
|
||||||
{% if item.is_offset_needed %}
|
{% if item.is_pay_off_needed %}
|
||||||
<span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span>
|
<span class="badge rounded-pill bg-info">{{ A_("Pay-off needed") }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -26,8 +26,8 @@ First written: 2023/1/26
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="btn-group mb-2">
|
<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">
|
<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" aria-label="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">
|
<label for="accounting-search" class="accounting-search-label">
|
||||||
<button type="submit">
|
<button type="submit">
|
||||||
<i class="fa-solid fa-magnifying-glass"></i>
|
<i class="fa-solid fa-magnifying-glass"></i>
|
||||||
|
@ -58,12 +58,12 @@ First written: 2023/2/6
|
|||||||
{% if "next" in request.args %}
|
{% if "next" in request.args %}
|
||||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-model-label" aria-hidden="true">
|
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h1 class="modal-title fs-5" id="accounting-delete-model-label">{{ A_("Delete Currency Confirmation") }}</h1>
|
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Currency Confirmation") }}</h1>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
{{ A_("Do you really want to delete this currency?") }}
|
{{ A_("Do you really want to delete this currency?") }}
|
||||||
|
@ -25,16 +25,28 @@ First written: 2023/2/6
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="btn-group mb-2">
|
<div class="btn-group mb-2 d-none d-md-inline-flex">
|
||||||
{% if accounting_can_edit() %}
|
{% if accounting_can_edit() %}
|
||||||
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
|
<a class="btn btn-primary text-nowrap" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
|
||||||
<i class="fa-solid fa-plus"></i>
|
<i class="fa-solid fa-plus"></i>
|
||||||
{{ A_("New") }}
|
{{ A_("New") }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search">
|
<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" 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" aria-label="Search">
|
<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" class="accounting-search-label">
|
<label for="accounting-search-desktop" class="accounting-search-label">
|
||||||
|
<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">
|
<button type="submit">
|
||||||
<i class="fa-solid fa-magnifying-glass"></i>
|
<i class="fa-solid fa-magnifying-glass"></i>
|
||||||
{{ A_("Search") }}
|
{{ A_("Search") }}
|
||||||
|
@ -19,13 +19,20 @@ nav.html: The navigation menu for the accounting application.
|
|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
First written: 2023/1/26
|
First written: 2023/1/26
|
||||||
#}
|
#}
|
||||||
|
{# <ul> For SonarQube not to complain about incorrect HTML #}
|
||||||
{% if accounting_can_view() %}
|
{% if accounting_can_view() %}
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
<span class="nav-link dropdown-toggle" data-bs-toggle="dropdown">
|
<span class="nav-link dropdown-toggle" data-bs-toggle="dropdown">
|
||||||
<i class="fa-solid fa-gear"></i>
|
<i class="fa-solid fa-file-invoice-dollar"></i>
|
||||||
{{ A_("Accounting") }}
|
{{ A_("Accounting") }}
|
||||||
</span>
|
</span>
|
||||||
<ul class="dropdown-menu">
|
<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>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item {% if request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}">
|
<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-list"></i>
|
||||||
@ -47,3 +54,4 @@ First written: 2023/1/26
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{# </ul> For SonarQube not to complain about incorrect HTML #}
|
||||||
|
@ -20,7 +20,7 @@ Author: imacat@mail.imacat.idv.tw (imacat)
|
|||||||
First written: 2023/1/26
|
First written: 2023/1/26
|
||||||
#}
|
#}
|
||||||
{% if pagination.is_paged %}
|
{% if pagination.is_paged %}
|
||||||
<nav aria-label="Page navigation">
|
<nav aria-label="{{ A_("Page navigation") }}">
|
||||||
<ul class="pagination">
|
<ul class="pagination">
|
||||||
{% for link in pagination.pages %}
|
{% for link in pagination.pages %}
|
||||||
{% if link.uri is none %}
|
{% if link.uri is none %}
|
||||||
|
@ -0,0 +1,28 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
create.html: The cash expense transaction creation form
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/25
|
||||||
|
#}
|
||||||
|
{% extends "accounting/transaction/expense/include/form.html" %}
|
||||||
|
|
||||||
|
{% 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 action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}
|
@ -0,0 +1,60 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
detail.html: The account detail
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/26
|
||||||
|
#}
|
||||||
|
{% extends "accounting/transaction/include/detail.html" %}
|
||||||
|
|
||||||
|
{% block to_transfer %}
|
||||||
|
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_txn_to_transfer|accounting_inherit_next }}">
|
||||||
|
<i class="fa-solid fa-bars-staggered"></i>
|
||||||
|
{{ A_("To Transfer") }}
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block transaction_currencies %}
|
||||||
|
{% for currency in obj.currencies %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
|
||||||
|
|
||||||
|
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
|
||||||
|
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li>
|
||||||
|
{% for entry in currency.debit %}
|
||||||
|
<li class="list-group-item accounting-transaction-entry">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<div class="small">{{ entry.account }}</div>
|
||||||
|
{% if entry.summary is not none %}
|
||||||
|
<div>{{ entry.summary }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>{{ entry.amount|accounting_txn_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>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
@ -0,0 +1,28 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
edit.html: The cash expense transaction edit form
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/25
|
||||||
|
#}
|
||||||
|
{% extends "accounting/transaction/expense/include/form.html" %}
|
||||||
|
|
||||||
|
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
|
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %}
|
||||||
|
|
||||||
|
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %}
|
@ -0,0 +1,83 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
currency-sub-form.html: The currency sub-form in the cash expense transaction form
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/25
|
||||||
|
#}
|
||||||
|
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}">
|
||||||
|
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
|
||||||
|
<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() %}
|
||||||
|
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label>
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button id="accounting-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
|
||||||
|
<i class="fas fa-minus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-debit" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}">
|
||||||
|
<label class="form-label" for="accounting-currency-{{ currency_index }}-debit">{{ A_("Content") }}</label>
|
||||||
|
<ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-entry-list">
|
||||||
|
{% for entry_form in debit_forms %}
|
||||||
|
{% with currency_index = currency_index,
|
||||||
|
entry_type = "debit",
|
||||||
|
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_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_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_errors = entry_form.amount.errors,
|
||||||
|
amount_text = entry_form.amount.data|accounting_txn_format_amount,
|
||||||
|
entry_errors = entry_form.all_errors %}
|
||||||
|
{% include "accounting/transaction/include/form-entry-item.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<div>{{ A_("Total") }}</div>
|
||||||
|
<div><span id="accounting-currency-{{ currency_index }}-debit-total" class="badge rounded-pill bg-primary">{{ debit_total }}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
{{ A_("New") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-debit-error" class="invalid-feedback">{% if debit_errors %}{{ debit_errors[0] }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div>
|
||||||
|
</div>
|
@ -0,0 +1,56 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
form.html: The cash expense transaction form
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/25
|
||||||
|
#}
|
||||||
|
{% extends "accounting/transaction/include/form.html" %}
|
||||||
|
|
||||||
|
{% block currency_sub_forms %}
|
||||||
|
{% if form.currencies %}
|
||||||
|
{% for currency_form in form.currencies %}
|
||||||
|
{% with currency_index = loop.index,
|
||||||
|
only_one_currency_form = form.currencies|length == 1,
|
||||||
|
currency_errors = currency_form.whole_form.errors,
|
||||||
|
currency_code_data = currency_form.code.data,
|
||||||
|
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 %}
|
||||||
|
{% 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(),
|
||||||
|
debit_total = "-" %}
|
||||||
|
{% include "accounting/transaction/expense/include/form-currency-item.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block form_modals %}
|
||||||
|
{% with summary_helper = form.summary_helper.debit %}
|
||||||
|
{% include "accounting/transaction/include/summary-helper-modal.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% with entry_type = "debit",
|
||||||
|
account_options = form.debit_account_options %}
|
||||||
|
{% include "accounting/transaction/include/account-selector-modal.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock %}
|
@ -0,0 +1,54 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
account-selector-modal.html: The modal for the account selector
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/25
|
||||||
|
#}
|
||||||
|
<div id="accounting-account-selector-{{ entry_type }}-modal" class="modal fade accounting-account-selector-modal" data-entry-type="{{ entry_type }}" tabindex="-1" aria-labelledby="accounting-account-selector-{{ entry_type }}-modal-label" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1 class="modal-title fs-5" id="accounting-account-selector-{{ entry_type }}-modal-label">{{ A_("Select Account") }}</h1>
|
||||||
|
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="input-group mb-2">
|
||||||
|
<input id="accounting-account-selector-{{ entry_type }}-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
|
||||||
|
<label class="input-group-text" for="accounting-account-selector-{{ entry_type }}-query">
|
||||||
|
<i class="fa-solid fa-magnifying-glass"></i>
|
||||||
|
{{ A_("Search") }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul id="accounting-account-selector-{{ entry_type }}-option-list" class="list-group accounting-selector-list">
|
||||||
|
{% for account in account_options %}
|
||||||
|
<li id="accounting-account-selector-{{ entry_type }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ entry_type }}-option {% if account.is_in_use %} accounting-account-in-use {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
|
||||||
|
{{ account }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
<li id="accounting-account-selector-{{ entry_type }}-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
|
||||||
|
</ul>
|
||||||
|
<p id="accounting-account-selector-{{ entry_type }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button>
|
||||||
|
<button id="accounting-account-selector-{{ entry_type }}-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,39 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
add-new-material-fab.html: The material floating action buttons to add a new transaction
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/25
|
||||||
|
#}
|
||||||
|
{% 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_("Cash expense") }}
|
||||||
|
</a>
|
||||||
|
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=types.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_("Transfer") }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<button id="accounting-btn-material-fab-speed-dial" class="btn btn-primary rounded-circle accounting-btn-material-fab" type="button" data-target="accounting-material-fab-speed-dial">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
@ -0,0 +1,112 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
detail.html: The account detail
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/26
|
||||||
|
#}
|
||||||
|
{% extends "accounting/base.html" %}
|
||||||
|
|
||||||
|
{% block header %}{% block title %}{{ obj }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="btn-group mb-3">
|
||||||
|
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.list")|accounting_or_next }}">
|
||||||
|
<i class="fa-solid fa-circle-chevron-left"></i>
|
||||||
|
{{ A_("Back") }}
|
||||||
|
</a>
|
||||||
|
{% if accounting_can_edit() %}
|
||||||
|
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_inherit_next }}">
|
||||||
|
<i class="fa-solid fa-gear"></i>
|
||||||
|
{{ A_("Settings") }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.order", txn_date=obj.date)|accounting_append_next }}">
|
||||||
|
<i class="fa-solid fa-bars-staggered"></i>
|
||||||
|
{{ A_("Order") }}
|
||||||
|
</a>
|
||||||
|
{% if accounting_can_edit() %}
|
||||||
|
{% block to_transfer %}{% endblock %}
|
||||||
|
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
|
||||||
|
<i class="fa-solid fa-trash"></i>
|
||||||
|
{{ A_("Delete") }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if accounting_can_edit() %}
|
||||||
|
<div class="d-md-none accounting-material-fab">
|
||||||
|
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_inherit_next }}">
|
||||||
|
<i class="fa-solid fa-pen-to-square"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% 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"] }}">
|
||||||
|
{% endif %}
|
||||||
|
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-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-delete-modal-label">{{ A_("Delete Transaction Confirmation") }}</h1>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
{{ A_("Do you really want to delete this transaction?") }}
|
||||||
|
</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-danger">{{ A_("Confirm") }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="accounting-transaction-card">
|
||||||
|
<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 }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block transaction_currencies %}{% endblock %}
|
||||||
|
|
||||||
|
{% if obj.note %}
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<i class="far fa-comment-dots"></i>
|
||||||
|
{{ obj.note|accounting_txn_text2html|safe }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="small text-secondary fst-italic">
|
||||||
|
<div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div>
|
||||||
|
<div>{{ A_("Updated") }} {{ obj.updated_at }} {{ obj.updated_by }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -0,0 +1,60 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
entry-form-modal.html: The modal of the journal entry sub-form
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/25
|
||||||
|
#}
|
||||||
|
<form id="accounting-entry-form" data-currency-index="" data-entry-type="" data-entry-index="">
|
||||||
|
<div id="accounting-entry-form-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-entry-form-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-entry-form-modal-label">{{ A_("Journal Entry Content") }}</h1>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div id="accounting-entry-form-account-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
|
||||||
|
<label class="form-label" for="accounting-entry-form-account">{{ A_("Account") }}</label>
|
||||||
|
<div id="accounting-entry-form-account" data-code="" data-text=""></div>
|
||||||
|
</div>
|
||||||
|
<div id="accounting-entry-form-account-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div id="accounting-entry-form-summary-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
|
||||||
|
<label class="form-label" for="accounting-entry-form-summary">{{ A_("Summary") }}</label>
|
||||||
|
<div id="accounting-entry-form-summary" data-value=""></div>
|
||||||
|
</div>
|
||||||
|
<div id="accounting-entry-form-summary-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<input id="accounting-entry-form-amount" class="form-control" type="number" value="" min="0.01" max="" step="0.01" placeholder=" " required="required">
|
||||||
|
<label for="accounting-entry-form-amount">{{ A_("Amount") }}</label>
|
||||||
|
<div id="accounting-entry-form-amount-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
|
||||||
|
<button id="accounting-entry-form-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
@ -0,0 +1,48 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
entry-sub-form.html: The journal entry sub-form in the transaction form
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/25
|
||||||
|
#}
|
||||||
|
{# <ul> For SonarQube not to complain about incorrect HTML #}
|
||||||
|
<li id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-entry accounting-currency-{{ currency_index }}-{{ entry_type }}" data-currency-index="{{ currency_index }}" data-entry-type="{{ entry_type }}" data-entry-index="{{ entry_index }}" data-prefix="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}">
|
||||||
|
{% if entry_id %}
|
||||||
|
<input type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-eid" value="{{ entry_id }}">
|
||||||
|
{% endif %}
|
||||||
|
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" value="{{ entry_index }}">
|
||||||
|
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-code" class="accounting-{{ entry_type }}-account-code" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account_code" value="{{ account_code_data }}" data-text="{{ account_text }}">
|
||||||
|
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" value="{{ summary_data }}">
|
||||||
|
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" class="accounting-currency-{{ currency_index }}-{{ entry_type }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" value="{{ amount_data }}">
|
||||||
|
<div class="accounting-entry-content">
|
||||||
|
<div 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>
|
||||||
|
<div><span id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount-text" class="badge rounded-pill bg-primary">{{ amount_text }}</span></div>
|
||||||
|
</div>
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-error" class="invalid-feedback">{% if entry_errors %}{{ entry_errors[0] }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-btn-delete" class="btn btn-danger rounded-circle accounting-btn-delete-entry accounting-currency-{{ currency_index }}-{{ entry_type }}-btn-delete-entry {% if only_one_entry_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" data-same-class="accounting-currency-{{ currency_index }}-{{ entry_type }}-btn-delete-entry">
|
||||||
|
<i class="fas fa-minus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{# </ul> For SonarQube not to complain about incorrect HTML #}
|
@ -0,0 +1,92 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
form.html: The transfer transaction form
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/26
|
||||||
|
#}
|
||||||
|
{% extends "accounting/base.html" %}
|
||||||
|
|
||||||
|
{% block accounting_scripts %}
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/transaction-form.js") }}"></script>
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script>
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/summary-helper.js") }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="btn-group btn-actions 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") }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"] }}">
|
||||||
|
{% 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">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div id="accounting-currencies" class="form-control accounting-material-text-field accounting-not-empty {% if form.currencies_errors %} is-invalid {% endif %}">
|
||||||
|
<label class="form-label" for="accounting-currencies">{{ A_("Content") }}</label>
|
||||||
|
<div id="accounting-currency-list" class="mt-2">
|
||||||
|
{% block currency_sub_forms %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button id="accounting-btn-new-currency" class="btn btn-primary" type="button">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
{{ A_("New") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="accounting-currencies-error" class="invalid-feedback">{% if form.currencies_errors %}{{ form.currencies_errors[0] }}{% endif %}</div>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="d-none d-md-block">
|
||||||
|
<button class="btn btn-primary" type="submit">
|
||||||
|
<i class="fa-solid fa-floppy-disk"></i>
|
||||||
|
{{ A_("Save") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-md-none accounting-material-fab">
|
||||||
|
<button class="btn btn-primary" type="submit">
|
||||||
|
<i class="fa-solid fa-floppy-disk"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% include "accounting/transaction/include/entry-form-modal.html" %}
|
||||||
|
{% block form_modals %}{% endblock %}
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -0,0 +1,181 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
entry-form-modal.html: The modal of the summary helper
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/28
|
||||||
|
#}
|
||||||
|
<form id="accounting-summary-helper-{{ summary_helper.type }}" class="accounting-summary-helper" data-entry-type="{{ summary_helper.type }}" data-default-tab-id="general" data-selected-account-code="" data-selected-account-text="">
|
||||||
|
<div id="accounting-summary-helper-{{ summary_helper.type }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-modal-label" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1 class="modal-title fs-5" id="accounting-summary-helper-{{ summary_helper.type }}-modal-label">
|
||||||
|
<label for="accounting-summary-helper-{{ summary_helper.type }}-summary">{{ A_("Summary") }}</label>
|
||||||
|
</h1>
|
||||||
|
<button class="btn-close accounting-summary-helper-{{ summary_helper.type }}-close" type="button" data-bs-toggle="modal" data-bs-target="" aria-label="{{ A_("Close") }}"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<input id="accounting-summary-helper-{{ summary_helper.type }}-summary" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-modal-label">
|
||||||
|
</div>
|
||||||
|
<ul class="nav nav-tabs mb-2">
|
||||||
|
<li class="nav-item">
|
||||||
|
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-general" class="nav-link active accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="page" data-tab-id="general">
|
||||||
|
{{ A_("General") }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-travel" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="travel">
|
||||||
|
{{ A_("Travel") }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-bus" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="bus">
|
||||||
|
{{ A_("Bus") }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-regular" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="regular">
|
||||||
|
{{ A_("Regular") }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-number" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="number">
|
||||||
|
{{ A_("Number") }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{# A general summary with a tag #}
|
||||||
|
<div class="accounting-summary-helper-{{ summary_helper.type }}-page" aria-current="page" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-general" data-tab-id="general">
|
||||||
|
<div class="form-floating mb-2">
|
||||||
|
<input id="accounting-summary-helper-{{ summary_helper.type }}-general-tag" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
|
||||||
|
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-general-tag">{{ A_("Tag") }}</label>
|
||||||
|
<div id="accounting-summary-helper-{{ summary_helper.type }}-general-tag-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{% for tag in summary_helper.general.tags %}
|
||||||
|
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-btn-tag accounting-summary-helper-{{ summary_helper.type }}-general-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
|
||||||
|
{{ tag }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# A general trip with the origin and distination #}
|
||||||
|
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-travel" data-tab-id="travel">
|
||||||
|
<div class="form-floating mb-2">
|
||||||
|
<input id="accounting-summary-helper-{{ summary_helper.type }}-travel-tag" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
|
||||||
|
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-travel-tag">{{ A_("Tag") }}</label>
|
||||||
|
<div id="accounting-summary-helper-{{ summary_helper.type }}-travel-tag-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{% for tag in summary_helper.travel.tags %}
|
||||||
|
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-btn-tag accounting-summary-helper-{{ summary_helper.type }}-travel-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
|
||||||
|
{{ tag }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mt-2">
|
||||||
|
<div class="form-floating">
|
||||||
|
<input id="accounting-summary-helper-{{ summary_helper.type }}-travel-from" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
|
||||||
|
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-travel-from">{{ A_("From") }}</label>
|
||||||
|
<div id="accounting-summary-helper-{{ summary_helper.type }}-travel-from-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group-vertical ms-1 me-1">
|
||||||
|
<button class="btn btn-primary accounting-summary-helper-{{ summary_helper.type }}-travel-direction accounting-default" type="button" tabindex="-1" data-arrow="→">→</button>
|
||||||
|
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-travel-direction" type="button" tabindex="-1" data-arrow="↔">↔</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-floating">
|
||||||
|
<input id="accounting-summary-helper-{{ summary_helper.type }}-travel-to" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
|
||||||
|
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-travel-to">{{ A_("To") }}</label>
|
||||||
|
<div id="accounting-summary-helper-{{ summary_helper.type }}-travel-to-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# A bus trip with the route name or route number, the origin and distination #}
|
||||||
|
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-bus" data-tab-id="bus">
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<div class="form-floating me-2">
|
||||||
|
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-tag" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
|
||||||
|
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-tag">{{ A_("Tag") }}</label>
|
||||||
|
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-tag-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-floating">
|
||||||
|
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-route" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
|
||||||
|
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-route">{{ A_("Route") }}</label>
|
||||||
|
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-route-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{% for tag in summary_helper.bus.tags %}
|
||||||
|
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-btn-tag accounting-summary-helper-{{ summary_helper.type }}-bus-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
|
||||||
|
{{ tag }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mt-2">
|
||||||
|
<div class="form-floating me-2">
|
||||||
|
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-from" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
|
||||||
|
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-from">{{ A_("From") }}</label>
|
||||||
|
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-from-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-floating">
|
||||||
|
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-to" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
|
||||||
|
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-to">{{ A_("To") }}</label>
|
||||||
|
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-to-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# A regular income/payment #}
|
||||||
|
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-regular" data-tab-id="regular">
|
||||||
|
{# TODO: To be done #}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# The number of items #}
|
||||||
|
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-number" data-tab-id="number">
|
||||||
|
<div class="form-floating">
|
||||||
|
<input id="accounting-summary-helper-{{ summary_helper.type }}-number" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="number" min="1" value="" placeholder=" ">
|
||||||
|
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-number">{{ A_("The number of items") }}</label>
|
||||||
|
<div id="accounting-summary-helper-{{ summary_helper.type }}-number-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# The suggested accounts #}
|
||||||
|
<div class="mt-3">
|
||||||
|
{% for account in summary_helper.accounts %}
|
||||||
|
<button class="btn btn-outline-primary d-none accounting-summary-helper-{{ summary_helper.type }}-account" type="button" data-code="{{ account.code }}" data-text="{{ account }}">
|
||||||
|
{{ account }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary accounting-summary-helper-{{ summary_helper.type }}-close" type="button" data-bs-toggle="modal" data-bs-target="">{{ A_("Cancel") }}</button>
|
||||||
|
<button id="accounting-summary-helper-{{ summary_helper.type }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
@ -0,0 +1,28 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
create.html: The cash income transaction creation form
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/25
|
||||||
|
#}
|
||||||
|
{% extends "accounting/transaction/income/include/form.html" %}
|
||||||
|
|
||||||
|
{% 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 action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}
|
@ -0,0 +1,60 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
detail.html: The account detail
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/26
|
||||||
|
#}
|
||||||
|
{% extends "accounting/transaction/include/detail.html" %}
|
||||||
|
|
||||||
|
{% block to_transfer %}
|
||||||
|
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_txn_to_transfer|accounting_inherit_next }}">
|
||||||
|
<i class="fa-solid fa-bars-staggered"></i>
|
||||||
|
{{ A_("To Transfer") }}
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block transaction_currencies %}
|
||||||
|
{% for currency in obj.currencies %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
|
||||||
|
|
||||||
|
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
|
||||||
|
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li>
|
||||||
|
{% for entry in currency.credit %}
|
||||||
|
<li class="list-group-item accounting-transaction-entry">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<div class="small">{{ entry.account }}</div>
|
||||||
|
{% if entry.summary is not none %}
|
||||||
|
<div>{{ entry.summary }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>{{ entry.amount|accounting_txn_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>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
@ -0,0 +1,28 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
edit.html: The cash income transaction edit form
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/25
|
||||||
|
#}
|
||||||
|
{% extends "accounting/transaction/income/include/form.html" %}
|
||||||
|
|
||||||
|
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
|
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %}
|
||||||
|
|
||||||
|
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %}
|
@ -0,0 +1,83 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
currency-sub-form.html: The currency sub-form in the cash income transaction form
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/25
|
||||||
|
#}
|
||||||
|
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}">
|
||||||
|
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
|
||||||
|
<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() %}
|
||||||
|
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label>
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button id="accounting-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
|
||||||
|
<i class="fas fa-minus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-credit" class="form-control accounting-material-text-field accounting-not-empty {% if credit_errors %} is-invalid {% endif %}">
|
||||||
|
<label class="form-label" for="accounting-currency-{{ currency_index }}-credit">{{ A_("Content") }}</label>
|
||||||
|
<ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list">
|
||||||
|
{% for entry_form in credit_forms %}
|
||||||
|
{% with currency_index = currency_index,
|
||||||
|
entry_type = "credit",
|
||||||
|
entry_index = loop.index,
|
||||||
|
only_one_entry_form = debit_forms|length == 1,
|
||||||
|
entry_id = entry_form.eid.data,
|
||||||
|
account_code_data = "" if entry_form.account_code.data is none else entry_form.account_code.data,
|
||||||
|
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_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_errors = entry_form.amount.errors,
|
||||||
|
amount_text = entry_form.amount.data|accounting_txn_format_amount,
|
||||||
|
entry_errors = entry_form.all_errors %}
|
||||||
|
{% include "accounting/transaction/include/form-entry-item.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<div>{{ A_("Total") }}</div>
|
||||||
|
<div><span id="accounting-currency-{{ currency_index }}-credit-total" class="badge rounded-pill bg-primary">{{ credit_total }}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
{{ A_("New") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-credit-error" class="invalid-feedback">{% if credit_errors %}{{ credit_errors[0] }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div>
|
||||||
|
</div>
|
@ -0,0 +1,56 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
form.html: The cash income transaction form
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/25
|
||||||
|
#}
|
||||||
|
{% extends "accounting/transaction/include/form.html" %}
|
||||||
|
|
||||||
|
{% block currency_sub_forms %}
|
||||||
|
{% if form.currencies %}
|
||||||
|
{% for currency_form in form.currencies %}
|
||||||
|
{% with currency_index = loop.index,
|
||||||
|
only_one_currency_form = form.currencies|length == 1,
|
||||||
|
currency_errors = currency_form.whole_form.errors,
|
||||||
|
currency_code_data = currency_form.code.data,
|
||||||
|
currency_code_errors = currency_form.code.errors,
|
||||||
|
credit_forms = currency_form.credit,
|
||||||
|
credit_errors = currency_form.credit_errors,
|
||||||
|
credit_total = currency_form.form.credit_total|accounting_txn_format_amount %}
|
||||||
|
{% include "accounting/transaction/income/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(),
|
||||||
|
credit_total = "-" %}
|
||||||
|
{% include "accounting/transaction/income/include/form-currency-item.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block form_modals %}
|
||||||
|
{% with summary_helper = form.summary_helper.credit %}
|
||||||
|
{% include "accounting/transaction/include/summary-helper-modal.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% with entry_type = "credit",
|
||||||
|
account_options = form.credit_account_options %}
|
||||||
|
{% include "accounting/transaction/include/account-selector-modal.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock %}
|
96
src/accounting/templates/accounting/transaction/list.html
Normal file
96
src/accounting/templates/accounting/transaction/list.html
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
list.html: The transaction list
|
||||||
|
|
||||||
|
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/18
|
||||||
|
#}
|
||||||
|
{% 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 %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Transaction Management") }}{% endif %}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="btn-group mb-2 d-none d-md-inline-flex">
|
||||||
|
{% if accounting_can_edit() %}
|
||||||
|
<div class="btn-group" 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>
|
||||||
|
{{ A_("New") }}
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=types.expense)|accounting_append_next }}">
|
||||||
|
{{ A_("Cash Expense") }}</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=types.income)|accounting_append_next }}">
|
||||||
|
{{ A_("Cash Income") }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=types.transfer)|accounting_append_next }}">
|
||||||
|
{{ A_("Transfer") }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.transaction.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">
|
||||||
|
<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.transaction.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") }}
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "accounting/transaction/include/add-new-material-fab.html" %}
|
||||||
|
|
||||||
|
{% if list %}
|
||||||
|
{% include "accounting/include/pagination.html" %}
|
||||||
|
|
||||||
|
<div class="list-group">
|
||||||
|
{% for item in list %}
|
||||||
|
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=item)|accounting_append_next }}">
|
||||||
|
{{ item.date|accounting_txn_format_date }} {{ item }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>{{ A_("There is no data.") }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
83
src/accounting/templates/accounting/transaction/order.html
Normal file
83
src/accounting/templates/accounting/transaction/order.html
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
order.html: The order of the transactions in a same day
|
||||||
|
|
||||||
|
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/26
|
||||||
|
#}
|
||||||
|
{% extends "accounting/base.html" %}
|
||||||
|
|
||||||
|
{% block accounting_scripts %}
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/transaction-order.js") }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}{% block title %}{{ A_("Transactions on %(date)s", date=date) }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="btn-group mb-3">
|
||||||
|
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.list")|accounting_or_next }}">
|
||||||
|
<i class="fa-solid fa-circle-chevron-left"></i>
|
||||||
|
{{ A_("Back") }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if list|length > 1 and accounting_can_edit() %}
|
||||||
|
<form action="{{ url_for("accounting.transaction.sort", txn_date=date) }}" 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"] }}">
|
||||||
|
{% endif %}
|
||||||
|
<ul id="accounting-order-list" class="list-group mb-3">
|
||||||
|
{% for item in list %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between" data-id="{{ item.id }}">
|
||||||
|
<input id="accounting-order-{{ item.id }}-no" type="hidden" name="{{ item.id }}-no" value="{{ loop.index }}">
|
||||||
|
<div>
|
||||||
|
{{ item }}
|
||||||
|
</div>
|
||||||
|
<i class="fa-solid fa-bars"></i>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="d-none d-md-block">
|
||||||
|
<button class="btn btn-primary" type="submit">
|
||||||
|
<i class="fa-solid fa-floppy-disk"></i>
|
||||||
|
{{ A_("Save") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-md-none accounting-material-fab">
|
||||||
|
<button class="btn btn-primary" type="submit">
|
||||||
|
<i class="fa-solid fa-floppy-disk"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% elif list %}
|
||||||
|
<ul class="list-group mb-3">
|
||||||
|
{% for item in list %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
{{ item }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p>{{ A_("There is no data.") }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -0,0 +1,28 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
create.html: The transfer transaction creation form
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/25
|
||||||
|
#}
|
||||||
|
{% extends "accounting/transaction/transfer/include/form.html" %}
|
||||||
|
|
||||||
|
{% block header %}{% block title %}{{ A_("Add a New Transfer Transaction") }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
|
{% block back_url %}{{ request.args.get("next") or url_for("accounting.transaction.list") }}{% endblock %}
|
||||||
|
|
||||||
|
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}
|
@ -0,0 +1,84 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
detail.html: The account detail
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/26
|
||||||
|
#}
|
||||||
|
{% extends "accounting/transaction/include/detail.html" %}
|
||||||
|
|
||||||
|
{% block transaction_currencies %}
|
||||||
|
{% for currency in obj.currencies %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
{# The debit entries #}
|
||||||
|
<div class="col-sm-6 mb-2">
|
||||||
|
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
|
||||||
|
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Debit") }}</li>
|
||||||
|
{% for entry in currency.debit %}
|
||||||
|
<li class="list-group-item accounting-transaction-entry">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<div class="small">{{ entry.account }}</div>
|
||||||
|
{% if entry.summary is not none %}
|
||||||
|
<div>{{ entry.summary }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>{{ entry.amount|accounting_txn_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>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# The credit entries #}
|
||||||
|
<div class="col-sm-6 mb-2">
|
||||||
|
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
|
||||||
|
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Credit") }}</li>
|
||||||
|
{% for entry in currency.credit %}
|
||||||
|
<li class="list-group-item accounting-transaction-entry">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<div class="small">{{ entry.account }}</div>
|
||||||
|
{% if entry.summary is not none %}
|
||||||
|
<div>{{ entry.summary }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>{{ entry.amount|accounting_txn_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>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
@ -0,0 +1,28 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
edit.html: The transfer transaction edit form
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/25
|
||||||
|
#}
|
||||||
|
{% extends "accounting/transaction/transfer/include/form.html" %}
|
||||||
|
|
||||||
|
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
|
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %}
|
||||||
|
|
||||||
|
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %}
|
@ -0,0 +1,126 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
currency-sub-form.html: The currency sub-form in the transfer transaction form
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/25
|
||||||
|
#}
|
||||||
|
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}">
|
||||||
|
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
|
||||||
|
<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() %}
|
||||||
|
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label>
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button id="accounting-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
|
||||||
|
<i class="fas fa-minus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
{# The debit entries #}
|
||||||
|
<div class="col-sm-6 mb-3">
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-debit" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}">
|
||||||
|
<label class="form-label" for="accounting-currency-{{ currency_index }}-debit">{{ A_("Debit") }}</label>
|
||||||
|
<ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-entry-list accounting-currency-{{ currency_index }}-entry-list">
|
||||||
|
{% for entry_form in debit_forms %}
|
||||||
|
{% with currency_index = currency_index,
|
||||||
|
entry_type = "debit",
|
||||||
|
entry_index = loop.index,
|
||||||
|
only_one_entry_form = debit_forms|length == 1,
|
||||||
|
entry_id = entry_form.eid.data,
|
||||||
|
account_code_data = "" if entry_form.account_code.data is none else entry_form.account_code.data,
|
||||||
|
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_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_errors = entry_form.amount.errors,
|
||||||
|
amount_text = entry_form.amount.data|accounting_txn_format_amount,
|
||||||
|
entry_errors = entry_form.all_errors %}
|
||||||
|
{% include "accounting/transaction/include/form-entry-item.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<div>{{ A_("Total") }}</div>
|
||||||
|
<div><span id="accounting-currency-{{ currency_index }}-debit-total" class="badge rounded-pill bg-primary">{{ debit_total }}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
{{ A_("New") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-debit-error" class="invalid-feedback">{% if debit_errors %}{{ debit_errors[0] }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# The credit entries #}
|
||||||
|
<div class="col-sm-6 mb-3">
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-credit" class="form-control accounting-material-text-field accounting-not-empty {% if credit_errors %} is-invalid {% endif %}">
|
||||||
|
<label class="form-label" for="accounting-currency-{{ currency_index }}-credit">{{ A_("Credit") }}</label>
|
||||||
|
<ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list">
|
||||||
|
{% for entry_form in credit_forms %}
|
||||||
|
{% with currency_index = currency_index,
|
||||||
|
entry_id = entry_form.eid.data,
|
||||||
|
entry_type = "credit",
|
||||||
|
entry_index = loop.index,
|
||||||
|
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_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_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_errors = entry_form.amount.errors,
|
||||||
|
amount_text = entry_form.amount.data|accounting_txn_format_amount,
|
||||||
|
entry_errors = entry_form.all_errors %}
|
||||||
|
{% include "accounting/transaction/include/form-entry-item.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<div>{{ A_("Total") }}</div>
|
||||||
|
<div><span id="accounting-currency-{{ currency_index }}-credit-total" class="badge rounded-pill bg-primary">{{ credit_total }}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
{{ A_("New") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-credit-error" class="invalid-feedback">{% if credit_errors %}{{ credit_errors[0] }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div>
|
||||||
|
</div>
|
@ -0,0 +1,67 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
form.html: The transfer transaction form
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/25
|
||||||
|
#}
|
||||||
|
{% extends "accounting/transaction/include/form.html" %}
|
||||||
|
|
||||||
|
{% block currency_sub_forms %}
|
||||||
|
{% if form.currencies %}
|
||||||
|
{% for currency_form in form.currencies %}
|
||||||
|
{% with currency_index = loop.index,
|
||||||
|
only_one_currency_form = form.currencies|length == 1,
|
||||||
|
currency_errors = currency_form.whole_form.errors,
|
||||||
|
currency_code_data = currency_form.code.data,
|
||||||
|
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,
|
||||||
|
credit_forms = currency_form.credit,
|
||||||
|
credit_errors = currency_form.credit_errors,
|
||||||
|
credit_total = currency_form.form.credit_total|accounting_txn_format_amount %}
|
||||||
|
{% include "accounting/transaction/transfer/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(),
|
||||||
|
debit_total = "-",
|
||||||
|
credit_total = "-" %}
|
||||||
|
{% include "accounting/transaction/transfer/include/form-currency-item.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block form_modals %}
|
||||||
|
{% with summary_helper = form.summary_helper.debit %}
|
||||||
|
{% include "accounting/transaction/include/summary-helper-modal.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% with summary_helper = form.summary_helper.credit %}
|
||||||
|
{% include "accounting/transaction/include/summary-helper-modal.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% with entry_type = "debit",
|
||||||
|
account_options = form.debit_account_options %}
|
||||||
|
{% include "accounting/transaction/include/account-selector-modal.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% with entry_type = "credit",
|
||||||
|
account_options = form.credit_account_options %}
|
||||||
|
{% include "accounting/transaction/include/account-selector-modal.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock %}
|
37
src/accounting/transaction/__init__.py
Normal file
37
src/accounting/transaction/__init__.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
||||||
|
|
||||||
|
# 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 transaction 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 TransactionConverter, TransactionTypeConverter, \
|
||||||
|
DateConverter
|
||||||
|
app.url_map.converters["transaction"] = TransactionConverter
|
||||||
|
app.url_map.converters["transactionType"] = TransactionTypeConverter
|
||||||
|
app.url_map.converters["date"] = DateConverter
|
||||||
|
|
||||||
|
from .views import bp as transaction_bp
|
||||||
|
bp.register_blueprint(transaction_bp, url_prefix="/transactions")
|
100
src/accounting/transaction/converters.py
Normal file
100
src/accounting/transaction/converters.py
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
|
||||||
|
|
||||||
|
# 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 transaction management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from flask import abort
|
||||||
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.models import Transaction
|
||||||
|
from accounting.transaction.dispatcher import TransactionType, \
|
||||||
|
TXN_TYPE_DICT
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionConverter(BaseConverter):
|
||||||
|
"""The transaction converter to convert the transaction ID from and to the
|
||||||
|
corresponding transaction in the routes."""
|
||||||
|
|
||||||
|
def to_python(self, value: str) -> Transaction:
|
||||||
|
"""Converts a transaction ID to a transaction.
|
||||||
|
|
||||||
|
:param value: The transaction ID.
|
||||||
|
:return: The corresponding transaction.
|
||||||
|
"""
|
||||||
|
transaction: Transaction | None = db.session.get(Transaction, value)
|
||||||
|
if transaction is None:
|
||||||
|
abort(404)
|
||||||
|
return transaction
|
||||||
|
|
||||||
|
def to_url(self, value: Transaction) -> str:
|
||||||
|
"""Converts a transaction to its ID.
|
||||||
|
|
||||||
|
:param value: The transaction.
|
||||||
|
:return: The ID.
|
||||||
|
"""
|
||||||
|
return str(value.id)
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionTypeConverter(BaseConverter):
|
||||||
|
"""The transaction converter to convert the transaction type ID from and to
|
||||||
|
the corresponding transaction type in the routes."""
|
||||||
|
|
||||||
|
def to_python(self, value: str) -> TransactionType:
|
||||||
|
"""Converts a transaction ID to a transaction.
|
||||||
|
|
||||||
|
:param value: The transaction ID.
|
||||||
|
:return: The corresponding transaction.
|
||||||
|
"""
|
||||||
|
txn_type: TransactionType | None = TXN_TYPE_DICT.get(value)
|
||||||
|
if txn_type is None:
|
||||||
|
abort(404)
|
||||||
|
return txn_type
|
||||||
|
|
||||||
|
def to_url(self, value: TransactionType) -> str:
|
||||||
|
"""Converts a transaction type to its ID.
|
||||||
|
|
||||||
|
:param value: The transaction type.
|
||||||
|
:return: The ID.
|
||||||
|
"""
|
||||||
|
return str(value.ID)
|
||||||
|
|
||||||
|
|
||||||
|
class DateConverter(BaseConverter):
|
||||||
|
"""The date converter to convert the ISO date from and to the
|
||||||
|
corresponding date in the routes."""
|
||||||
|
|
||||||
|
def to_python(self, value: str) -> date:
|
||||||
|
"""Converts an ISO date to a date.
|
||||||
|
|
||||||
|
:param value: The ISO date.
|
||||||
|
:return: The corresponding date.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
def to_url(self, value: date) -> str:
|
||||||
|
"""Converts a date to its ISO date.
|
||||||
|
|
||||||
|
:param value: The date.
|
||||||
|
:return: The ISO date.
|
||||||
|
"""
|
||||||
|
return value.isoformat()
|
344
src/accounting/transaction/dispatcher.py
Normal file
344
src/accounting/transaction/dispatcher.py
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
|
||||||
|
|
||||||
|
# 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 view dispatcher for different transaction types.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import typing as t
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from flask import render_template, request, abort
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
|
||||||
|
from accounting.models import Transaction
|
||||||
|
from .forms import TransactionForm, IncomeTransactionForm, \
|
||||||
|
ExpenseTransactionForm, TransferTransactionForm
|
||||||
|
from .template import default_currency_code
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionType(ABC):
|
||||||
|
"""An abstract transaction type."""
|
||||||
|
ID: str = ""
|
||||||
|
"""The transaction type ID."""
|
||||||
|
CHECK_ORDER: int = -1
|
||||||
|
"""The order when checking the transaction type."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def form(self) -> t.Type[TransactionForm]:
|
||||||
|
"""Returns the form class.
|
||||||
|
|
||||||
|
:return: The form class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def render_create_template(self, form: FlaskForm) -> str:
|
||||||
|
"""Renders the template for the form to create a transaction.
|
||||||
|
|
||||||
|
:param form: The transaction form.
|
||||||
|
:return: the form to create a transaction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def render_detail_template(self, txn: Transaction) -> str:
|
||||||
|
"""Renders the template for the detail page.
|
||||||
|
|
||||||
|
:param txn: The transaction.
|
||||||
|
:return: the detail page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def render_edit_template(self, txn: Transaction, form: FlaskForm) -> str:
|
||||||
|
"""Renders the template for the form to edit a transaction.
|
||||||
|
|
||||||
|
:param txn: The transaction.
|
||||||
|
:param form: The form.
|
||||||
|
:return: the form to edit a transaction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def is_my_type(self, txn: Transaction) -> bool:
|
||||||
|
"""Checks and returns whether the transaction belongs to the type.
|
||||||
|
|
||||||
|
:param txn: The transaction.
|
||||||
|
:return: True if the transaction belongs to the type, or False
|
||||||
|
otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _entry_template(self) -> str:
|
||||||
|
"""Renders and returns the template for the journal entry sub-form.
|
||||||
|
|
||||||
|
:return: The template for the journal entry sub-form.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/transaction/include/form-entry-item.html",
|
||||||
|
currency_index="CURRENCY_INDEX",
|
||||||
|
entry_type="ENTRY_TYPE",
|
||||||
|
entry_index="ENTRY_INDEX")
|
||||||
|
|
||||||
|
|
||||||
|
class IncomeTransaction(TransactionType):
|
||||||
|
"""An income transaction."""
|
||||||
|
ID: str = "income"
|
||||||
|
"""The transaction type ID."""
|
||||||
|
CHECK_ORDER: int = 2
|
||||||
|
"""The order when checking the transaction type."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def form(self) -> t.Type[TransactionForm]:
|
||||||
|
"""Returns the form class.
|
||||||
|
|
||||||
|
:return: The form class.
|
||||||
|
"""
|
||||||
|
return IncomeTransactionForm
|
||||||
|
|
||||||
|
def render_create_template(self, form: IncomeTransactionForm) -> str:
|
||||||
|
"""Renders the template for the form to create a transaction.
|
||||||
|
|
||||||
|
:param form: The transaction form.
|
||||||
|
:return: the form to create a transaction.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/transaction/income/create.html",
|
||||||
|
form=form, txn_type=self,
|
||||||
|
currency_template=self.__currency_template,
|
||||||
|
entry_template=self._entry_template)
|
||||||
|
|
||||||
|
def render_detail_template(self, txn: Transaction) -> str:
|
||||||
|
"""Renders the template for the detail page.
|
||||||
|
|
||||||
|
:param txn: The transaction.
|
||||||
|
:return: the detail page.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/transaction/income/detail.html",
|
||||||
|
obj=txn)
|
||||||
|
|
||||||
|
def render_edit_template(self, txn: Transaction,
|
||||||
|
form: IncomeTransactionForm) -> str:
|
||||||
|
"""Renders the template for the form to edit a transaction.
|
||||||
|
|
||||||
|
:param txn: The transaction.
|
||||||
|
:param form: The form.
|
||||||
|
:return: the form to edit a transaction.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/transaction/income/edit.html",
|
||||||
|
txn=txn, form=form,
|
||||||
|
currency_template=self.__currency_template,
|
||||||
|
entry_template=self._entry_template)
|
||||||
|
|
||||||
|
def is_my_type(self, txn: Transaction) -> bool:
|
||||||
|
"""Checks and returns whether the transaction belongs to the type.
|
||||||
|
|
||||||
|
:param txn: The transaction.
|
||||||
|
:return: True if the transaction belongs to the type, or False
|
||||||
|
otherwise.
|
||||||
|
"""
|
||||||
|
return txn.is_cash_income
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __currency_template(self) -> str:
|
||||||
|
"""Renders and returns the template for the currency sub-form.
|
||||||
|
|
||||||
|
:return: The template for the currency sub-form.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/transaction/income/include/form-currency-item.html",
|
||||||
|
currency_index="CURRENCY_INDEX",
|
||||||
|
currency_code_data=default_currency_code(),
|
||||||
|
credit_total="-")
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseTransaction(TransactionType):
|
||||||
|
"""An expense transaction."""
|
||||||
|
ID: str = "expense"
|
||||||
|
"""The transaction type ID."""
|
||||||
|
CHECK_ORDER: int = 1
|
||||||
|
"""The order when checking the transaction type."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def form(self) -> t.Type[TransactionForm]:
|
||||||
|
"""Returns the form class.
|
||||||
|
|
||||||
|
:return: The form class.
|
||||||
|
"""
|
||||||
|
return ExpenseTransactionForm
|
||||||
|
|
||||||
|
def render_create_template(self, form: ExpenseTransactionForm) -> str:
|
||||||
|
"""Renders the template for the form to create a transaction.
|
||||||
|
|
||||||
|
:param form: The transaction form.
|
||||||
|
:return: the form to create a transaction.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/transaction/expense/create.html",
|
||||||
|
form=form, txn_type=self,
|
||||||
|
currency_template=self.__currency_template,
|
||||||
|
entry_template=self._entry_template)
|
||||||
|
|
||||||
|
def render_detail_template(self, txn: Transaction) -> str:
|
||||||
|
"""Renders the template for the detail page.
|
||||||
|
|
||||||
|
:param txn: The transaction.
|
||||||
|
:return: the detail page.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/transaction/expense/detail.html",
|
||||||
|
obj=txn)
|
||||||
|
|
||||||
|
def render_edit_template(self, txn: Transaction,
|
||||||
|
form: ExpenseTransactionForm) -> str:
|
||||||
|
"""Renders the template for the form to edit a transaction.
|
||||||
|
|
||||||
|
:param txn: The transaction.
|
||||||
|
:param form: The form.
|
||||||
|
:return: the form to edit a transaction.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/transaction/expense/edit.html",
|
||||||
|
txn=txn, form=form,
|
||||||
|
currency_template=self.__currency_template,
|
||||||
|
entry_template=self._entry_template)
|
||||||
|
|
||||||
|
def is_my_type(self, txn: Transaction) -> bool:
|
||||||
|
"""Checks and returns whether the transaction belongs to the type.
|
||||||
|
|
||||||
|
:param txn: The transaction.
|
||||||
|
:return: True if the transaction belongs to the type, or False
|
||||||
|
otherwise.
|
||||||
|
"""
|
||||||
|
return txn.is_cash_expense
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __currency_template(self) -> str:
|
||||||
|
"""Renders and returns the template for the currency sub-form.
|
||||||
|
|
||||||
|
:return: The template for the currency sub-form.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/transaction/expense/include/form-currency-item.html",
|
||||||
|
currency_index="CURRENCY_INDEX",
|
||||||
|
currency_code_data=default_currency_code(),
|
||||||
|
debit_total="-")
|
||||||
|
|
||||||
|
|
||||||
|
class TransferTransaction(TransactionType):
|
||||||
|
"""A transfer transaction."""
|
||||||
|
ID: str = "transfer"
|
||||||
|
"""The transaction type ID."""
|
||||||
|
CHECK_ORDER: int = 3
|
||||||
|
"""The order when checking the transaction type."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def form(self) -> t.Type[TransactionForm]:
|
||||||
|
"""Returns the form class.
|
||||||
|
|
||||||
|
:return: The form class.
|
||||||
|
"""
|
||||||
|
return TransferTransactionForm
|
||||||
|
|
||||||
|
def render_create_template(self, form: TransferTransactionForm) -> str:
|
||||||
|
"""Renders the template for the form to create a transaction.
|
||||||
|
|
||||||
|
:param form: The transaction form.
|
||||||
|
:return: the form to create a transaction.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/transaction/transfer/create.html",
|
||||||
|
form=form, txn_type=self,
|
||||||
|
currency_template=self.__currency_template,
|
||||||
|
entry_template=self._entry_template)
|
||||||
|
|
||||||
|
def render_detail_template(self, txn: Transaction) -> str:
|
||||||
|
"""Renders the template for the detail page.
|
||||||
|
|
||||||
|
:param txn: The transaction.
|
||||||
|
:return: the detail page.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/transaction/transfer/detail.html",
|
||||||
|
obj=txn)
|
||||||
|
|
||||||
|
def render_edit_template(self, txn: Transaction,
|
||||||
|
form: TransferTransactionForm) -> str:
|
||||||
|
"""Renders the template for the form to edit a transaction.
|
||||||
|
|
||||||
|
:param txn: The transaction.
|
||||||
|
:param form: The form.
|
||||||
|
:return: the form to edit a transaction.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/transaction/transfer/edit.html",
|
||||||
|
txn=txn, form=form,
|
||||||
|
currency_template=self.__currency_template,
|
||||||
|
entry_template=self._entry_template)
|
||||||
|
|
||||||
|
def is_my_type(self, txn: Transaction) -> bool:
|
||||||
|
"""Checks and returns whether the transaction belongs to the type.
|
||||||
|
|
||||||
|
:param txn: The transaction.
|
||||||
|
:return: True if the transaction belongs to the type, or False
|
||||||
|
otherwise.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __currency_template(self) -> str:
|
||||||
|
"""Renders and returns the template for the currency sub-form.
|
||||||
|
|
||||||
|
:return: The template for the currency sub-form.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/transaction/transfer/include/form-currency-item.html",
|
||||||
|
currency_index="CURRENCY_INDEX",
|
||||||
|
currency_code_data=default_currency_code(),
|
||||||
|
debit_total="-", credit_total="-")
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionTypes:
|
||||||
|
"""The transaction types, as object properties."""
|
||||||
|
|
||||||
|
def __init__(self, income: IncomeTransaction, expense: ExpenseTransaction,
|
||||||
|
transfer: TransferTransaction):
|
||||||
|
"""Constructs the transaction types as object properties.
|
||||||
|
|
||||||
|
:param income: The income transaction type.
|
||||||
|
:param expense: The expense transaction type.
|
||||||
|
:param transfer: The transfer transaction type.
|
||||||
|
"""
|
||||||
|
self.income: IncomeTransaction = income
|
||||||
|
self.expense: ExpenseTransaction = expense
|
||||||
|
self.transfer: TransferTransaction = transfer
|
||||||
|
|
||||||
|
|
||||||
|
TXN_TYPE_DICT: dict[str, TransactionType] \
|
||||||
|
= {x.ID: x() for x in {IncomeTransaction,
|
||||||
|
ExpenseTransaction,
|
||||||
|
TransferTransaction}}
|
||||||
|
"""The transaction types, as a dictionary."""
|
||||||
|
TXN_TYPE_OBJ: TransactionTypes = TransactionTypes(**TXN_TYPE_DICT)
|
||||||
|
"""The transaction types, as an object."""
|
||||||
|
|
||||||
|
|
||||||
|
def get_txn_type(txn: Transaction) -> TransactionType:
|
||||||
|
"""Returns the transaction type that may be specified in the "as" query
|
||||||
|
parameter. If it is not specified, check the transaction type from the
|
||||||
|
transaction.
|
||||||
|
|
||||||
|
:param txn: The transaction.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
if "as" in request.args:
|
||||||
|
if request.args["as"] not in TXN_TYPE_DICT:
|
||||||
|
abort(404)
|
||||||
|
return TXN_TYPE_DICT[request.args["as"]]
|
||||||
|
for txn_type in sorted(TXN_TYPE_DICT.values(),
|
||||||
|
key=lambda x: x.CHECK_ORDER):
|
||||||
|
if txn_type.is_my_type(txn):
|
||||||
|
return txn_type
|
860
src/accounting/transaction/forms.py
Normal file
860
src/accounting/transaction/forms.py
Normal file
@ -0,0 +1,860 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
||||||
|
|
||||||
|
# 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 forms for the transaction management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import typing as t
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import request
|
||||||
|
from flask_babel import LazyString
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import DateField, StringField, FieldList, FormField, \
|
||||||
|
IntegerField, TextAreaField, DecimalField, BooleanField
|
||||||
|
from wtforms.validators import DataRequired, ValidationError
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import lazy_gettext
|
||||||
|
from accounting.models import Transaction, Account, JournalEntry, \
|
||||||
|
TransactionCurrency, Currency
|
||||||
|
from accounting.transaction.summary_helper import SummaryHelper
|
||||||
|
from accounting.utils.random_id import new_id
|
||||||
|
from accounting.utils.strip_text import strip_text, strip_multiline_text
|
||||||
|
from accounting.utils.user import get_current_user_pk
|
||||||
|
|
||||||
|
MISSING_CURRENCY: LazyString = lazy_gettext("Please select the currency.")
|
||||||
|
"""The error message when the currency code is empty."""
|
||||||
|
MISSING_ACCOUNT: LazyString = lazy_gettext("Please select the account.")
|
||||||
|
"""The error message when the account code is empty."""
|
||||||
|
|
||||||
|
|
||||||
|
class NeedSomeCurrencies:
|
||||||
|
"""The validator to check if there is any currency sub-form."""
|
||||||
|
|
||||||
|
def __call__(self, form: CurrencyForm, field: FieldList) \
|
||||||
|
-> None:
|
||||||
|
if len(field) == 0:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"Please add some currencies."))
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyExists:
|
||||||
|
"""The validator to check if the account exists."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
if db.session.get(Currency, field.data) is None:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The currency does not exist."))
|
||||||
|
|
||||||
|
|
||||||
|
class NeedSomeJournalEntries:
|
||||||
|
"""The validator to check if there is any journal entry sub-form."""
|
||||||
|
|
||||||
|
def __call__(self, form: TransferCurrencyForm, field: FieldList) \
|
||||||
|
-> None:
|
||||||
|
if len(field) == 0:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"Please add some journal entries."))
|
||||||
|
|
||||||
|
|
||||||
|
class AccountExists:
|
||||||
|
"""The validator to check if the account exists."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
if Account.find_by_code(field.data) is None:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The account does not exist."))
|
||||||
|
|
||||||
|
|
||||||
|
class PositiveAmount:
|
||||||
|
"""The validator to check if the amount is positive."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
if field.data <= 0:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"Please fill in a positive amount."))
|
||||||
|
|
||||||
|
|
||||||
|
class IsDebitAccount:
|
||||||
|
"""The validator to check if the account is for debit journal entries."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
if re.match(r"^(?:[1235689]|7[5678])", field.data) \
|
||||||
|
and not field.data.startswith("3351-") \
|
||||||
|
and not field.data.startswith("3353-"):
|
||||||
|
return
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"This account is not for debit entries."))
|
||||||
|
|
||||||
|
|
||||||
|
class AccountOption:
|
||||||
|
"""An account option."""
|
||||||
|
|
||||||
|
def __init__(self, account: Account):
|
||||||
|
"""Constructs an account option.
|
||||||
|
|
||||||
|
:param account: The account.
|
||||||
|
"""
|
||||||
|
self.__account: Account = account
|
||||||
|
self.id: str = account.id
|
||||||
|
self.code: str = account.code
|
||||||
|
self.is_in_use: bool = False
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Returns the string representation of the account option.
|
||||||
|
|
||||||
|
:return: The string representation of the account option.
|
||||||
|
"""
|
||||||
|
return str(self.__account)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def query_values(self) -> list[str]:
|
||||||
|
"""Returns the values to be queried.
|
||||||
|
|
||||||
|
:return: The values to be queried.
|
||||||
|
"""
|
||||||
|
return self.__account.query_values
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryForm(FlaskForm):
|
||||||
|
"""The base form to create or edit a journal entry."""
|
||||||
|
eid = IntegerField()
|
||||||
|
"""The existing journal entry ID."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order in the currency."""
|
||||||
|
account_code = StringField()
|
||||||
|
"""The account code."""
|
||||||
|
amount = DecimalField()
|
||||||
|
"""The amount."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_text(self) -> str:
|
||||||
|
"""Returns the text representation of the account.
|
||||||
|
|
||||||
|
:return: The text representation of the account.
|
||||||
|
"""
|
||||||
|
if self.account_code.data is None:
|
||||||
|
return ""
|
||||||
|
account: Account | None = Account.find_by_code(self.account_code.data)
|
||||||
|
if account is None:
|
||||||
|
return ""
|
||||||
|
return str(account)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def all_errors(self) -> list[str | LazyString]:
|
||||||
|
"""Returns all the errors of the form.
|
||||||
|
|
||||||
|
:return: All the errors of the form.
|
||||||
|
"""
|
||||||
|
all_errors: list[str | LazyString] = []
|
||||||
|
for key in self.errors:
|
||||||
|
if key != "csrf_token":
|
||||||
|
all_errors.extend(self.errors[key])
|
||||||
|
return all_errors
|
||||||
|
|
||||||
|
|
||||||
|
class DebitEntryForm(JournalEntryForm):
|
||||||
|
"""The form to create or edit a debit journal entry."""
|
||||||
|
eid = IntegerField()
|
||||||
|
"""The existing journal entry ID."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order in the currency."""
|
||||||
|
account_code = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[DataRequired(MISSING_ACCOUNT),
|
||||||
|
AccountExists(),
|
||||||
|
IsDebitAccount()])
|
||||||
|
"""The account code."""
|
||||||
|
summary = StringField(filters=[strip_text])
|
||||||
|
"""The summary."""
|
||||||
|
amount = DecimalField(validators=[PositiveAmount()])
|
||||||
|
"""The amount."""
|
||||||
|
|
||||||
|
def populate_obj(self, obj: JournalEntry) -> None:
|
||||||
|
"""Populates the form data into a journal entry object.
|
||||||
|
|
||||||
|
:param obj: The journal entry object.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
is_new: bool = obj.id is None
|
||||||
|
if is_new:
|
||||||
|
obj.id = new_id(JournalEntry)
|
||||||
|
obj.account_id = Account.find_by_code(self.account_code.data).id
|
||||||
|
obj.summary = self.summary.data
|
||||||
|
obj.is_debit = True
|
||||||
|
obj.amount = self.amount.data
|
||||||
|
if is_new:
|
||||||
|
current_user_pk: int = get_current_user_pk()
|
||||||
|
obj.created_by_id = current_user_pk
|
||||||
|
obj.updated_by_id = current_user_pk
|
||||||
|
|
||||||
|
|
||||||
|
class IsCreditAccount:
|
||||||
|
"""The validator to check if the account is for credit journal entries."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
if re.match(r"^(?:[123489]|7[1234])", field.data) \
|
||||||
|
and not field.data.startswith("3351-") \
|
||||||
|
and not field.data.startswith("3353-"):
|
||||||
|
return
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"This account is not for credit entries."))
|
||||||
|
|
||||||
|
|
||||||
|
class CreditEntryForm(JournalEntryForm):
|
||||||
|
"""The form to create or edit a credit journal entry."""
|
||||||
|
eid = IntegerField()
|
||||||
|
"""The existing journal entry ID."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order in the currency."""
|
||||||
|
account_code = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[DataRequired(MISSING_ACCOUNT),
|
||||||
|
AccountExists(),
|
||||||
|
IsCreditAccount()])
|
||||||
|
"""The account code."""
|
||||||
|
summary = StringField(filters=[strip_text])
|
||||||
|
"""The summary."""
|
||||||
|
amount = DecimalField(validators=[PositiveAmount()])
|
||||||
|
"""The amount."""
|
||||||
|
|
||||||
|
def populate_obj(self, obj: JournalEntry) -> None:
|
||||||
|
"""Populates the form data into a journal entry object.
|
||||||
|
|
||||||
|
:param obj: The journal entry object.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
is_new: bool = obj.id is None
|
||||||
|
if is_new:
|
||||||
|
obj.id = new_id(JournalEntry)
|
||||||
|
obj.account_id = Account.find_by_code(self.account_code.data).id
|
||||||
|
obj.summary = self.summary.data
|
||||||
|
obj.is_debit = False
|
||||||
|
obj.amount = self.amount.data
|
||||||
|
if is_new:
|
||||||
|
current_user_pk: int = get_current_user_pk()
|
||||||
|
obj.created_by_id = current_user_pk
|
||||||
|
obj.updated_by_id = current_user_pk
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyForm(FlaskForm):
|
||||||
|
"""The form to create or edit a currency in a transaction."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order in the transaction."""
|
||||||
|
code = StringField()
|
||||||
|
"""The currency code."""
|
||||||
|
whole_form = BooleanField()
|
||||||
|
"""The pseudo field for the whole form validators."""
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionForm(FlaskForm):
|
||||||
|
"""The base form to create or edit a transaction."""
|
||||||
|
date = DateField()
|
||||||
|
"""The date."""
|
||||||
|
currencies = FieldList(FormField(CurrencyForm))
|
||||||
|
"""The journal entries categorized by their currencies."""
|
||||||
|
note = TextAreaField()
|
||||||
|
"""The note."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Constructs a base transaction form.
|
||||||
|
|
||||||
|
:param args: The arguments.
|
||||||
|
:param kwargs: The keyword arguments.
|
||||||
|
"""
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.is_modified: bool = False
|
||||||
|
"""Whether the transaction is modified during populate_obj()."""
|
||||||
|
self.collector: t.Type[JournalEntryCollector] = JournalEntryCollector
|
||||||
|
"""The journal entry collector. The default is the base abstract
|
||||||
|
collector only to provide the correct type. The subclass forms should
|
||||||
|
provide their own collectors."""
|
||||||
|
self.__in_use_account_id: set[int] | None = None
|
||||||
|
"""The ID of the accounts that are in use."""
|
||||||
|
|
||||||
|
def populate_obj(self, obj: Transaction) -> None:
|
||||||
|
"""Populates the form data into a transaction object.
|
||||||
|
|
||||||
|
:param obj: The transaction object.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
is_new: bool = obj.id is None
|
||||||
|
if is_new:
|
||||||
|
obj.id = new_id(Transaction)
|
||||||
|
self.__set_date(obj, self.date.data)
|
||||||
|
obj.note = self.note.data
|
||||||
|
|
||||||
|
collector_cls: t.Type[JournalEntryCollector] = self.collector
|
||||||
|
collector: collector_cls = collector_cls(self, obj)
|
||||||
|
collector.collect()
|
||||||
|
|
||||||
|
to_delete: set[int] = {x.id for x in obj.entries
|
||||||
|
if x.id not in collector.to_keep}
|
||||||
|
if len(to_delete) > 0:
|
||||||
|
JournalEntry.query.filter(JournalEntry.id.in_(to_delete)).delete()
|
||||||
|
self.is_modified = True
|
||||||
|
|
||||||
|
if is_new or db.session.is_modified(obj):
|
||||||
|
self.is_modified = True
|
||||||
|
|
||||||
|
if is_new:
|
||||||
|
current_user_pk: int = get_current_user_pk()
|
||||||
|
obj.created_by_id = current_user_pk
|
||||||
|
obj.updated_by_id = current_user_pk
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __set_date(obj: Transaction, new_date: date) -> None:
|
||||||
|
"""Sets the transaction date and number.
|
||||||
|
|
||||||
|
:param obj: The transaction object.
|
||||||
|
:param new_date: The new date.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
if obj.date is None or obj.date != new_date:
|
||||||
|
if obj.date is not None:
|
||||||
|
sort_transactions_in(obj.date, obj.id)
|
||||||
|
sort_transactions_in(new_date, obj.id)
|
||||||
|
count: int = Transaction.query\
|
||||||
|
.filter(Transaction.date == new_date).count()
|
||||||
|
obj.date = new_date
|
||||||
|
obj.no = count + 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def debit_account_options(self) -> list[AccountOption]:
|
||||||
|
"""The selectable debit accounts.
|
||||||
|
|
||||||
|
:return: The selectable debit accounts.
|
||||||
|
"""
|
||||||
|
accounts: list[AccountOption] \
|
||||||
|
= [AccountOption(x) for x in Account.debit()]
|
||||||
|
in_use: set[int] = set(db.session.scalars(
|
||||||
|
sa.select(JournalEntry.account_id)
|
||||||
|
.filter(JournalEntry.is_debit)
|
||||||
|
.group_by(JournalEntry.account_id)).all())
|
||||||
|
for account in accounts:
|
||||||
|
account.is_in_use = account.id in in_use
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credit_account_options(self) -> list[AccountOption]:
|
||||||
|
"""The selectable credit accounts.
|
||||||
|
|
||||||
|
:return: The selectable credit accounts.
|
||||||
|
"""
|
||||||
|
accounts: list[AccountOption] \
|
||||||
|
= [AccountOption(x) for x in Account.credit()]
|
||||||
|
in_use: set[int] = set(db.session.scalars(
|
||||||
|
sa.select(JournalEntry.account_id)
|
||||||
|
.filter(sa.not_(JournalEntry.is_debit))
|
||||||
|
.group_by(JournalEntry.account_id)).all())
|
||||||
|
for account in accounts:
|
||||||
|
account.is_in_use = account.id in in_use
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
@property
|
||||||
|
def currencies_errors(self) -> list[str | LazyString]:
|
||||||
|
"""Returns the currency errors, without the errors in their sub-forms.
|
||||||
|
|
||||||
|
:return: The currency errors, without the errors in their sub-forms.
|
||||||
|
"""
|
||||||
|
return [x for x in self.currencies.errors
|
||||||
|
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def summary_helper(self) -> SummaryHelper:
|
||||||
|
"""Returns the summary helper.
|
||||||
|
|
||||||
|
:return: The summary helper.
|
||||||
|
"""
|
||||||
|
return SummaryHelper()
|
||||||
|
|
||||||
|
|
||||||
|
T = t.TypeVar("T", bound=TransactionForm)
|
||||||
|
"""A transaction form variant."""
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryCollector(t.Generic[T], ABC):
|
||||||
|
"""The journal entry collector."""
|
||||||
|
|
||||||
|
def __init__(self, form: T, obj: Transaction):
|
||||||
|
"""Constructs the journal entry collector.
|
||||||
|
|
||||||
|
:param form: The transaction form.
|
||||||
|
:param obj: The transaction.
|
||||||
|
"""
|
||||||
|
self.form: T = form
|
||||||
|
"""The transaction form."""
|
||||||
|
self.__obj: Transaction = obj
|
||||||
|
"""The transaction object."""
|
||||||
|
self.__entries: list[JournalEntry] = list(obj.entries)
|
||||||
|
"""The existing journal entries."""
|
||||||
|
self.__entries_by_id: dict[int, JournalEntry] \
|
||||||
|
= {x.id: x for x in self.__entries}
|
||||||
|
"""A dictionary from the entry ID to their entries."""
|
||||||
|
self.__no_by_id: dict[int, int] = {x.id: x.no for x in self.__entries}
|
||||||
|
"""A dictionary from the entry number to their entries."""
|
||||||
|
self.__currencies: list[TransactionCurrency] = obj.currencies
|
||||||
|
"""The currencies in the transaction."""
|
||||||
|
self._debit_no: int = 1
|
||||||
|
"""The number index for the debit entries."""
|
||||||
|
self._credit_no: int = 1
|
||||||
|
"""The number index for the credit entries."""
|
||||||
|
self.to_keep: set[int] = set()
|
||||||
|
"""The ID of the existing journal entries to keep."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def collect(self) -> set[int]:
|
||||||
|
"""Collects the journal entries.
|
||||||
|
|
||||||
|
:return: The ID of the journal entries to keep.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _add_entry(self, form: JournalEntryForm, currency_code: str, no: int) \
|
||||||
|
-> None:
|
||||||
|
"""Composes a journal entry from the form.
|
||||||
|
|
||||||
|
:param form: The journal entry form.
|
||||||
|
:param currency_code: The code of the currency.
|
||||||
|
:param no: The number of the entry.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
entry: JournalEntry | None = self.__entries_by_id.get(form.eid.data)
|
||||||
|
if entry is not None:
|
||||||
|
entry.currency_code = currency_code
|
||||||
|
form.populate_obj(entry)
|
||||||
|
entry.no = no
|
||||||
|
if db.session.is_modified(entry):
|
||||||
|
self.form.is_modified = True
|
||||||
|
else:
|
||||||
|
entry = JournalEntry()
|
||||||
|
entry.currency_code = currency_code
|
||||||
|
form.populate_obj(entry)
|
||||||
|
entry.no = no
|
||||||
|
self.__obj.entries.append(entry)
|
||||||
|
self.form.is_modified = True
|
||||||
|
self.to_keep.add(entry.id)
|
||||||
|
|
||||||
|
def _make_cash_entry(self, forms: list[JournalEntryForm], is_debit: bool,
|
||||||
|
currency_code: str, no: int) -> None:
|
||||||
|
"""Composes the cash journal entry at the other side of the cash
|
||||||
|
transaction.
|
||||||
|
|
||||||
|
:param forms: The journal entry forms in the same currency.
|
||||||
|
:param is_debit: True for a cash income transaction, or False for a
|
||||||
|
cash expense transaction.
|
||||||
|
:param currency_code: The code of the currency.
|
||||||
|
:param no: The number of the entry.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
candidates: list[JournalEntry] = [x for x in self.__entries
|
||||||
|
if x.is_debit == is_debit
|
||||||
|
and x.currency_code == currency_code]
|
||||||
|
entry: JournalEntry
|
||||||
|
if len(candidates) > 0:
|
||||||
|
candidates.sort(key=lambda x: x.no)
|
||||||
|
entry = candidates[0]
|
||||||
|
entry.account_id = Account.cash().id
|
||||||
|
entry.summary = None
|
||||||
|
entry.amount = sum([x.amount.data for x in forms])
|
||||||
|
entry.no = no
|
||||||
|
if db.session.is_modified(entry):
|
||||||
|
self.form.is_modified = True
|
||||||
|
else:
|
||||||
|
entry = JournalEntry()
|
||||||
|
entry.id = new_id(JournalEntry)
|
||||||
|
entry.is_debit = is_debit
|
||||||
|
entry.currency_code = currency_code
|
||||||
|
entry.account_id = Account.cash().id
|
||||||
|
entry.summary = None
|
||||||
|
entry.amount = sum([x.amount.data for x in forms])
|
||||||
|
entry.no = no
|
||||||
|
self.__obj.entries.append(entry)
|
||||||
|
self.form.is_modified = True
|
||||||
|
self.to_keep.add(entry.id)
|
||||||
|
|
||||||
|
def _sort_entry_forms(self, forms: list[JournalEntryForm]) -> None:
|
||||||
|
"""Sorts the journal entry forms.
|
||||||
|
|
||||||
|
:param forms: The journal entry forms.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
missing_no: int = 100 if len(self.__no_by_id) == 0 \
|
||||||
|
else max(self.__no_by_id.values()) + 100
|
||||||
|
ord_by_form: dict[JournalEntryForm, int] \
|
||||||
|
= {forms[i]: i for i in range(len(forms))}
|
||||||
|
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
|
||||||
|
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
|
||||||
|
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
|
||||||
|
missing_no if x.eid.data is None else
|
||||||
|
self.__no_by_id.get(x.eid.data, missing_no),
|
||||||
|
ord_by_form.get(x)))
|
||||||
|
|
||||||
|
def _sort_currency_forms(self, forms: list[CurrencyForm]) -> None:
|
||||||
|
"""Sorts the currency forms.
|
||||||
|
|
||||||
|
:param forms: The currency forms.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
missing_no: int = len(self.__currencies) + 100
|
||||||
|
no_by_code: dict[str, int] = {self.__currencies[i].code: i
|
||||||
|
for i in range(len(self.__currencies))}
|
||||||
|
ord_by_form: dict[CurrencyForm, int] \
|
||||||
|
= {forms[i]: i for i in range(len(forms))}
|
||||||
|
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
|
||||||
|
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
|
||||||
|
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
|
||||||
|
no_by_code.get(x.code.data, missing_no),
|
||||||
|
ord_by_form.get(x)))
|
||||||
|
|
||||||
|
|
||||||
|
class IncomeCurrencyForm(CurrencyForm):
|
||||||
|
"""The form to create or edit a currency in a cash income transaction."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order in the transaction."""
|
||||||
|
code = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[DataRequired(MISSING_CURRENCY),
|
||||||
|
CurrencyExists()])
|
||||||
|
"""The currency code."""
|
||||||
|
credit = FieldList(FormField(CreditEntryForm),
|
||||||
|
validators=[NeedSomeJournalEntries()])
|
||||||
|
"""The credit entries."""
|
||||||
|
whole_form = BooleanField()
|
||||||
|
"""The pseudo field for the whole form validators."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credit_total(self) -> Decimal:
|
||||||
|
"""Returns the total amount of the credit journal entries.
|
||||||
|
|
||||||
|
:return: The total amount of the credit journal entries.
|
||||||
|
"""
|
||||||
|
return sum([x.amount.data for x in self.credit
|
||||||
|
if x.amount.data is not None])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credit_errors(self) -> list[str | LazyString]:
|
||||||
|
"""Returns the credit journal entry errors, without the errors in their
|
||||||
|
sub-forms.
|
||||||
|
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return [x for x in self.credit.errors
|
||||||
|
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||||
|
|
||||||
|
|
||||||
|
class IncomeTransactionForm(TransactionForm):
|
||||||
|
"""The form to create or edit a cash income transaction."""
|
||||||
|
date = DateField(default=date.today())
|
||||||
|
"""The date."""
|
||||||
|
currencies = FieldList(FormField(IncomeCurrencyForm), name="currency",
|
||||||
|
validators=[NeedSomeCurrencies()])
|
||||||
|
"""The journal entries categorized by their currencies."""
|
||||||
|
note = TextAreaField(filters=[strip_multiline_text])
|
||||||
|
"""The note."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
class Collector(JournalEntryCollector[IncomeTransactionForm]):
|
||||||
|
"""The journal entry collector for the cash income transactions."""
|
||||||
|
|
||||||
|
def collect(self) -> None:
|
||||||
|
currencies: list[IncomeCurrencyForm] \
|
||||||
|
= [x.form for x in self.form.currencies]
|
||||||
|
self._sort_currency_forms(currencies)
|
||||||
|
for currency in currencies:
|
||||||
|
# The debit cash entry
|
||||||
|
self._make_cash_entry(list(currency.credit), True,
|
||||||
|
currency.code.data, self._debit_no)
|
||||||
|
self._debit_no = self._debit_no + 1
|
||||||
|
|
||||||
|
# The credit forms
|
||||||
|
credit_forms: list[CreditEntryForm] \
|
||||||
|
= [x.form for x in currency.credit]
|
||||||
|
self._sort_entry_forms(credit_forms)
|
||||||
|
for credit_form in credit_forms:
|
||||||
|
self._add_entry(credit_form, currency.code.data,
|
||||||
|
self._credit_no)
|
||||||
|
self._credit_no = self._credit_no + 1
|
||||||
|
|
||||||
|
self.collector = Collector
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseCurrencyForm(CurrencyForm):
|
||||||
|
"""The form to create or edit a currency in a cash expense transaction."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order in the transaction."""
|
||||||
|
code = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[DataRequired(MISSING_CURRENCY),
|
||||||
|
CurrencyExists()])
|
||||||
|
"""The currency code."""
|
||||||
|
debit = FieldList(FormField(DebitEntryForm),
|
||||||
|
validators=[NeedSomeJournalEntries()])
|
||||||
|
"""The debit entries."""
|
||||||
|
whole_form = BooleanField()
|
||||||
|
"""The pseudo field for the whole form validators."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def debit_total(self) -> Decimal:
|
||||||
|
"""Returns the total amount of the debit journal entries.
|
||||||
|
|
||||||
|
:return: The total amount of the debit journal entries.
|
||||||
|
"""
|
||||||
|
return sum([x.amount.data for x in self.debit
|
||||||
|
if x.amount.data is not None])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def debit_errors(self) -> list[str | LazyString]:
|
||||||
|
"""Returns the debit journal entry errors, without the errors in their
|
||||||
|
sub-forms.
|
||||||
|
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return [x for x in self.debit.errors
|
||||||
|
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseTransactionForm(TransactionForm):
|
||||||
|
"""The form to create or edit a cash expense transaction."""
|
||||||
|
date = DateField(default=date.today())
|
||||||
|
"""The date."""
|
||||||
|
currencies = FieldList(FormField(ExpenseCurrencyForm), name="currency",
|
||||||
|
validators=[NeedSomeCurrencies()])
|
||||||
|
"""The journal entries categorized by their currencies."""
|
||||||
|
note = TextAreaField(filters=[strip_multiline_text])
|
||||||
|
"""The note."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
class Collector(JournalEntryCollector[ExpenseTransactionForm]):
|
||||||
|
"""The journal entry collector for the cash expense
|
||||||
|
transactions."""
|
||||||
|
|
||||||
|
def collect(self) -> None:
|
||||||
|
currencies: list[ExpenseCurrencyForm] \
|
||||||
|
= [x.form for x in self.form.currencies]
|
||||||
|
self._sort_currency_forms(currencies)
|
||||||
|
for currency in currencies:
|
||||||
|
# The debit forms
|
||||||
|
debit_forms: list[DebitEntryForm] \
|
||||||
|
= [x.form for x in currency.debit]
|
||||||
|
self._sort_entry_forms(debit_forms)
|
||||||
|
for debit_form in debit_forms:
|
||||||
|
self._add_entry(debit_form, currency.code.data,
|
||||||
|
self._debit_no)
|
||||||
|
self._debit_no = self._debit_no + 1
|
||||||
|
|
||||||
|
# The credit forms
|
||||||
|
self._make_cash_entry(list(currency.debit), False,
|
||||||
|
currency.code.data, self._credit_no)
|
||||||
|
self._credit_no = self._credit_no + 1
|
||||||
|
|
||||||
|
self.collector = Collector
|
||||||
|
|
||||||
|
|
||||||
|
class TransferCurrencyForm(CurrencyForm):
|
||||||
|
"""The form to create or edit a currency in a transfer transaction."""
|
||||||
|
|
||||||
|
class IsBalanced:
|
||||||
|
"""The validator to check that the total amount of the debit and credit
|
||||||
|
entries are equal."""
|
||||||
|
def __call__(self, form: TransferCurrencyForm, field: BooleanField)\
|
||||||
|
-> None:
|
||||||
|
if len(form.debit) == 0 or len(form.credit) == 0:
|
||||||
|
return
|
||||||
|
if form.debit_total != form.credit_total:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The totals of the debit and credit amounts do not"
|
||||||
|
" match."))
|
||||||
|
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order in the transaction."""
|
||||||
|
code = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[DataRequired(MISSING_CURRENCY),
|
||||||
|
CurrencyExists()])
|
||||||
|
"""The currency code."""
|
||||||
|
debit = FieldList(FormField(DebitEntryForm),
|
||||||
|
validators=[NeedSomeJournalEntries()])
|
||||||
|
"""The debit entries."""
|
||||||
|
credit = FieldList(FormField(CreditEntryForm),
|
||||||
|
validators=[NeedSomeJournalEntries()])
|
||||||
|
"""The credit entries."""
|
||||||
|
whole_form = BooleanField(validators=[IsBalanced()])
|
||||||
|
"""The pseudo field for the whole form validators."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def debit_total(self) -> Decimal:
|
||||||
|
"""Returns the total amount of the debit journal entries.
|
||||||
|
|
||||||
|
:return: The total amount of the debit journal entries.
|
||||||
|
"""
|
||||||
|
return sum([x.amount.data for x in self.debit
|
||||||
|
if x.amount.data is not None])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credit_total(self) -> Decimal:
|
||||||
|
"""Returns the total amount of the credit journal entries.
|
||||||
|
|
||||||
|
:return: The total amount of the credit journal entries.
|
||||||
|
"""
|
||||||
|
return sum([x.amount.data for x in self.credit
|
||||||
|
if x.amount.data is not None])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def debit_errors(self) -> list[str | LazyString]:
|
||||||
|
"""Returns the debit journal entry errors, without the errors in their
|
||||||
|
sub-forms.
|
||||||
|
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return [x for x in self.debit.errors
|
||||||
|
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credit_errors(self) -> list[str | LazyString]:
|
||||||
|
"""Returns the credit journal entry errors, without the errors in their
|
||||||
|
sub-forms.
|
||||||
|
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return [x for x in self.credit.errors
|
||||||
|
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||||
|
|
||||||
|
|
||||||
|
class TransferTransactionForm(TransactionForm):
|
||||||
|
"""The form to create or edit a transfer transaction."""
|
||||||
|
date = DateField(default=date.today())
|
||||||
|
"""The date."""
|
||||||
|
currencies = FieldList(FormField(TransferCurrencyForm), name="currency",
|
||||||
|
validators=[NeedSomeCurrencies()])
|
||||||
|
"""The journal entries categorized by their currencies."""
|
||||||
|
note = TextAreaField(filters=[strip_multiline_text])
|
||||||
|
"""The note."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
class Collector(JournalEntryCollector[TransferTransactionForm]):
|
||||||
|
"""The journal entry collector for the transfer transactions."""
|
||||||
|
|
||||||
|
def collect(self) -> None:
|
||||||
|
currencies: list[TransferCurrencyForm] \
|
||||||
|
= [x.form for x in self.form.currencies]
|
||||||
|
self._sort_currency_forms(currencies)
|
||||||
|
for currency in currencies:
|
||||||
|
# The debit forms
|
||||||
|
debit_forms: list[DebitEntryForm] \
|
||||||
|
= [x.form for x in currency.debit]
|
||||||
|
self._sort_entry_forms(debit_forms)
|
||||||
|
for debit_form in debit_forms:
|
||||||
|
self._add_entry(debit_form, currency.code.data,
|
||||||
|
self._debit_no)
|
||||||
|
self._debit_no = self._debit_no + 1
|
||||||
|
|
||||||
|
# The credit forms
|
||||||
|
credit_forms: list[CreditEntryForm] \
|
||||||
|
= [x.form for x in currency.credit]
|
||||||
|
self._sort_entry_forms(credit_forms)
|
||||||
|
for credit_form in credit_forms:
|
||||||
|
self._add_entry(credit_form, currency.code.data,
|
||||||
|
self._credit_no)
|
||||||
|
self._credit_no = self._credit_no + 1
|
||||||
|
|
||||||
|
self.collector = Collector
|
||||||
|
|
||||||
|
|
||||||
|
def sort_transactions_in(txn_date: date, exclude: int) -> None:
|
||||||
|
"""Sorts the transactions under a date after changing the date or deleting
|
||||||
|
a transaction.
|
||||||
|
|
||||||
|
:param txn_date: The date of the transaction.
|
||||||
|
:param exclude: The transaction ID to exclude.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
transactions: list[Transaction] = Transaction.query\
|
||||||
|
.filter(Transaction.date == txn_date,
|
||||||
|
Transaction.id != exclude)\
|
||||||
|
.order_by(Transaction.no).all()
|
||||||
|
for i in range(len(transactions)):
|
||||||
|
if transactions[i].no != i + 1:
|
||||||
|
transactions[i].no = i + 1
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionReorderForm:
|
||||||
|
"""The form to reorder the transactions."""
|
||||||
|
|
||||||
|
def __init__(self, txn_date: date):
|
||||||
|
"""Constructs the form to reorder the transactions in a day.
|
||||||
|
|
||||||
|
:param txn_date: The date.
|
||||||
|
"""
|
||||||
|
self.date: date = txn_date
|
||||||
|
self.is_modified: bool = False
|
||||||
|
|
||||||
|
def save_order(self) -> None:
|
||||||
|
"""Saves the order of the account.
|
||||||
|
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
transactions: list[Transaction] = Transaction.query\
|
||||||
|
.filter(Transaction.date == self.date).all()
|
||||||
|
|
||||||
|
# Collects the specified order.
|
||||||
|
orders: dict[Transaction, int] = {}
|
||||||
|
for txn in transactions:
|
||||||
|
if f"{txn.id}-no" in request.form:
|
||||||
|
try:
|
||||||
|
orders[txn] = int(request.form[f"{txn.id}-no"])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Missing and invalid orders are appended to the end.
|
||||||
|
missing: list[Transaction] \
|
||||||
|
= [x for x in transactions if x not in orders]
|
||||||
|
if len(missing) > 0:
|
||||||
|
next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
|
||||||
|
for txn in missing:
|
||||||
|
orders[txn] = next_no
|
||||||
|
|
||||||
|
# Sort by the specified order first, and their original order.
|
||||||
|
transactions.sort(key=lambda x: (orders[x], x.no))
|
||||||
|
|
||||||
|
# Update the orders.
|
||||||
|
with db.session.no_autoflush:
|
||||||
|
for i in range(len(transactions)):
|
||||||
|
if transactions[i].no != i + 1:
|
||||||
|
transactions[i].no = i + 1
|
||||||
|
self.is_modified = True
|
65
src/accounting/transaction/query.py
Normal file
65
src/accounting/transaction/query.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
||||||
|
|
||||||
|
# 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 transaction query.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import request
|
||||||
|
|
||||||
|
from accounting.models import Transaction
|
||||||
|
from accounting.utils.query import parse_query_keywords
|
||||||
|
|
||||||
|
|
||||||
|
def get_transaction_query() -> list[Transaction]:
|
||||||
|
"""Returns the transactions, optionally filtered by the query.
|
||||||
|
|
||||||
|
:return: The transactions.
|
||||||
|
"""
|
||||||
|
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
||||||
|
if len(keywords) == 0:
|
||||||
|
return Transaction.query\
|
||||||
|
.order_by(Transaction.date, Transaction.no).all()
|
||||||
|
conditions: list[sa.BinaryExpression] = []
|
||||||
|
for k in keywords:
|
||||||
|
sub_conditions: list[sa.BinaryExpression] \
|
||||||
|
= [Transaction.note.contains(k)]
|
||||||
|
date: datetime
|
||||||
|
try:
|
||||||
|
date = datetime.strptime(k, "%Y")
|
||||||
|
sub_conditions.append(
|
||||||
|
sa.extract("year", Transaction.date) == date.year)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
date = datetime.strptime(k, "%Y/%m")
|
||||||
|
sub_conditions.append(sa.and_(
|
||||||
|
sa.extract("year", Transaction.date) == date.year,
|
||||||
|
sa.extract("month", Transaction.date) == date.month))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
|
||||||
|
sub_conditions.append(sa.and_(
|
||||||
|
sa.extract("month", Transaction.date) == date.month,
|
||||||
|
sa.extract("day", Transaction.date) == date.day))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
conditions.append(sa.or_(*sub_conditions))
|
||||||
|
return Transaction.query.filter(*conditions)\
|
||||||
|
.order_by(Transaction.date, Transaction.no).all()
|
255
src/accounting/transaction/summary_helper.py
Normal file
255
src/accounting/transaction/summary_helper.py
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The summary helper.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import typing as t
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.models import Account, JournalEntry
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryAccount:
|
||||||
|
"""An account for a summary tag."""
|
||||||
|
|
||||||
|
def __init__(self, account: Account, freq: int):
|
||||||
|
"""Constructs an account for a summary tag.
|
||||||
|
|
||||||
|
:param account: The account.
|
||||||
|
:param freq: The frequency of the tag with the account.
|
||||||
|
"""
|
||||||
|
self.account: Account = account
|
||||||
|
"""The account."""
|
||||||
|
self.id: int = account.id
|
||||||
|
"""The account ID."""
|
||||||
|
self.code: str = account.code
|
||||||
|
"""The account code."""
|
||||||
|
self.freq: int = freq
|
||||||
|
"""The frequency of the tag with the account."""
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Returns the string representation of the account.
|
||||||
|
|
||||||
|
:return: The string representation of the account.
|
||||||
|
"""
|
||||||
|
return str(self.account)
|
||||||
|
|
||||||
|
def add_freq(self, freq: int) -> None:
|
||||||
|
"""Adds the frequency of an account.
|
||||||
|
|
||||||
|
:param freq: The frequency of the tag name with the account.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.freq = self.freq + freq
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryTag:
|
||||||
|
"""A summary tag."""
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
"""Constructs a summary tag.
|
||||||
|
|
||||||
|
:param name: The tag name.
|
||||||
|
"""
|
||||||
|
self.name: str = name
|
||||||
|
"""The tag name."""
|
||||||
|
self.__account_dict: dict[int, SummaryAccount] = {}
|
||||||
|
"""The accounts that come with the tag, in the order of their
|
||||||
|
frequency."""
|
||||||
|
self.freq: int = 0
|
||||||
|
"""The frequency of the tag."""
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Returns the string representation of the tag.
|
||||||
|
|
||||||
|
:return: The string representation of the tag.
|
||||||
|
"""
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def add_account(self, account: Account, freq: int):
|
||||||
|
"""Adds an account.
|
||||||
|
|
||||||
|
:param account: The associated account.
|
||||||
|
:param freq: The frequency of the tag name with the account.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.__account_dict[account.id] = SummaryAccount(account, freq)
|
||||||
|
self.freq = self.freq + freq
|
||||||
|
|
||||||
|
@property
|
||||||
|
def accounts(self) -> list[SummaryAccount]:
|
||||||
|
"""Returns the accounts by the order of their frequencies.
|
||||||
|
|
||||||
|
:return: The accounts by the order of their frequencies.
|
||||||
|
"""
|
||||||
|
return sorted(self.__account_dict.values(), key=lambda x: -x.freq)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_codes(self) -> list[str]:
|
||||||
|
"""Returns the account codes by the order of their frequencies.
|
||||||
|
|
||||||
|
:return: The account codes by the order of their frequencies.
|
||||||
|
"""
|
||||||
|
return [x.code for x in self.accounts]
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryType:
|
||||||
|
"""A summary type"""
|
||||||
|
|
||||||
|
def __init__(self, type_id: t.Literal["general", "travel", "bus"]):
|
||||||
|
"""Constructs a summary type.
|
||||||
|
|
||||||
|
:param type_id: The type ID, either "general", "travel", or "bus".
|
||||||
|
"""
|
||||||
|
self.id: t.Literal["general", "travel", "bus"] = type_id
|
||||||
|
"""The type ID."""
|
||||||
|
self.__tag_dict: dict[str, SummaryTag] = {}
|
||||||
|
"""A dictionary from the tag name to their corresponding tag."""
|
||||||
|
|
||||||
|
def add_tag(self, name: str, account: Account, freq: int) -> None:
|
||||||
|
"""Adds a tag.
|
||||||
|
|
||||||
|
:param name: The tag name.
|
||||||
|
:param account: The associated account.
|
||||||
|
:param freq: The frequency of the tag name with the account.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
if name not in self.__tag_dict:
|
||||||
|
self.__tag_dict[name] = SummaryTag(name)
|
||||||
|
self.__tag_dict[name].add_account(account, freq)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tags(self) -> list[SummaryTag]:
|
||||||
|
"""Returns the tags by the order of their frequencies.
|
||||||
|
|
||||||
|
:return: The tags by the order of their frequencies.
|
||||||
|
"""
|
||||||
|
return sorted(self.__tag_dict.values(), key=lambda x: -x.freq)
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryEntryType:
|
||||||
|
"""A summary type"""
|
||||||
|
|
||||||
|
def __init__(self, entry_type_id: t.Literal["debit", "credit"]):
|
||||||
|
"""Constructs a summary entry type.
|
||||||
|
|
||||||
|
:param entry_type_id: The entry type ID, either "debit" or "credit".
|
||||||
|
"""
|
||||||
|
self.type: t.Literal["debit", "credit"] = entry_type_id
|
||||||
|
"""The entry type."""
|
||||||
|
self.general: SummaryType = SummaryType("general")
|
||||||
|
"""The general tags."""
|
||||||
|
self.travel: SummaryType = SummaryType("travel")
|
||||||
|
"""The travel tags."""
|
||||||
|
self.bus: SummaryType = SummaryType("bus")
|
||||||
|
"""The bus tags."""
|
||||||
|
self.__type_dict: dict[t.Literal["general", "travel", "bus"],
|
||||||
|
SummaryType] \
|
||||||
|
= {x.id: x for x in {self.general, self.travel, self.bus}}
|
||||||
|
"""A dictionary from the type ID to the corresponding tags."""
|
||||||
|
|
||||||
|
def add_tag(self, tag_type: t.Literal["general", "travel", "bus"],
|
||||||
|
name: str, account: Account, freq: int) -> None:
|
||||||
|
"""Adds a tag.
|
||||||
|
|
||||||
|
:param tag_type: The tag type, either "general", "travel", or "bus".
|
||||||
|
:param name: The name.
|
||||||
|
:param account: The associated account.
|
||||||
|
:param freq: The frequency of the tag name with the account.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.__type_dict[tag_type].add_tag(name, account, freq)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def accounts(self) -> list[SummaryAccount]:
|
||||||
|
"""Returns the suggested accounts of all tags in the summary helper in
|
||||||
|
the entry type, in their frequency order.
|
||||||
|
|
||||||
|
:return: The suggested accounts of all tags, in their frequency order.
|
||||||
|
"""
|
||||||
|
accounts: dict[int, SummaryAccount] = {}
|
||||||
|
freq: dict[int, int] = {}
|
||||||
|
for tag_type in self.__type_dict.values():
|
||||||
|
for tag in tag_type.tags:
|
||||||
|
for account in tag.accounts:
|
||||||
|
accounts[account.id] = account
|
||||||
|
if account.id not in freq:
|
||||||
|
freq[account.id] = 0
|
||||||
|
freq[account.id] \
|
||||||
|
= freq[account.id] + account.freq
|
||||||
|
return [accounts[y] for y in sorted(freq.keys(),
|
||||||
|
key=lambda x: -freq[x])]
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryHelper:
|
||||||
|
"""The summary helper."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Constructs the summary helper."""
|
||||||
|
self.debit: SummaryEntryType = SummaryEntryType("debit")
|
||||||
|
"""The debit tags."""
|
||||||
|
self.credit: SummaryEntryType = SummaryEntryType("credit")
|
||||||
|
"""The credit tags."""
|
||||||
|
entry_type: sa.Label = sa.case((JournalEntry.is_debit, "debit"),
|
||||||
|
else_="credit").label("entry_type")
|
||||||
|
tag_type: sa.Label = sa.case(
|
||||||
|
(JournalEntry.summary.like("_%—_%—_%→_%"), "bus"),
|
||||||
|
(sa.or_(JournalEntry.summary.like("_%—_%→_%"),
|
||||||
|
JournalEntry.summary.like("_%—_%↔_%")), "travel"),
|
||||||
|
else_="general").label("tag_type")
|
||||||
|
tag: sa.Label = get_prefix(JournalEntry.summary, "—").label("tag")
|
||||||
|
select: sa.Select = sa.Select(entry_type, tag_type, tag,
|
||||||
|
JournalEntry.account_id,
|
||||||
|
sa.func.count().label("freq"))\
|
||||||
|
.filter(JournalEntry.summary.is_not(None),
|
||||||
|
JournalEntry.summary.like("_%—_%"))\
|
||||||
|
.group_by(entry_type, tag_type, tag, JournalEntry.account_id)
|
||||||
|
result: list[sa.Row] = db.session.execute(select).all()
|
||||||
|
accounts: dict[int, Account] \
|
||||||
|
= {x.id: x for x in Account.query
|
||||||
|
.filter(Account.id.in_({x.account_id for x in result})).all()}
|
||||||
|
entry_type_dict: dict[t.Literal["debit", "credit"], SummaryEntryType] \
|
||||||
|
= {x.type: x for x in {self.debit, self.credit}}
|
||||||
|
for row in result:
|
||||||
|
entry_type_dict[row.entry_type].add_tag(
|
||||||
|
row.tag_type, row.tag, accounts[row.account_id], row.freq)
|
||||||
|
|
||||||
|
|
||||||
|
def get_prefix(string: str | sa.Column, separator: str | sa.Column) \
|
||||||
|
-> sa.Function:
|
||||||
|
"""Returns the SQL function to find the prefix of a string.
|
||||||
|
|
||||||
|
:param string: The string.
|
||||||
|
:param separator: The separator.
|
||||||
|
:return: The position of the substring, starting from 1.
|
||||||
|
"""
|
||||||
|
return sa.func.substr(string, 0, get_position(string, separator))
|
||||||
|
|
||||||
|
|
||||||
|
def get_position(string: str | sa.Column, substring: str | sa.Column) \
|
||||||
|
-> sa.Function:
|
||||||
|
"""Returns the SQL function to find the position of a substring.
|
||||||
|
|
||||||
|
:param string: The string.
|
||||||
|
:param substring: The substring.
|
||||||
|
:return: The position of the substring, starting from 1.
|
||||||
|
"""
|
||||||
|
if db.engine.name == "postgresql":
|
||||||
|
return sa.func.strpos(string, substring)
|
||||||
|
return sa.func.instr(string, substring)
|
145
src/accounting/transaction/template.py
Normal file
145
src/accounting/transaction/template.py
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
# 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 and globals for the transaction management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
from html import escape
|
||||||
|
from urllib.parse import ParseResult, urlparse, parse_qsl, urlencode, \
|
||||||
|
urlunparse
|
||||||
|
|
||||||
|
from flask import request, current_app
|
||||||
|
from flask_babel import get_locale
|
||||||
|
|
||||||
|
from accounting.locale import gettext
|
||||||
|
from accounting.models import Currency
|
||||||
|
|
||||||
|
|
||||||
|
def with_type(uri: str) -> str:
|
||||||
|
"""Adds the transaction type to the URI, if it is specified.
|
||||||
|
|
||||||
|
:param uri: The URI.
|
||||||
|
:return: The result URL, optionally with the transaction type added.
|
||||||
|
"""
|
||||||
|
if "as" not in request.args:
|
||||||
|
return uri
|
||||||
|
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", request.args["as"]))
|
||||||
|
parts: list[str] = list(uri_p)
|
||||||
|
parts[4] = urlencode(params)
|
||||||
|
return urlunparse(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def to_transfer(uri: str) -> str:
|
||||||
|
"""Adds the transfer transaction type to the URI.
|
||||||
|
|
||||||
|
:param uri: The URI.
|
||||||
|
:return: The result URL, with the transfer transaction type added.
|
||||||
|
"""
|
||||||
|
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", "transfer"))
|
||||||
|
parts: list[str] = list(uri_p)
|
||||||
|
parts[4] = urlencode(params)
|
||||||
|
return urlunparse(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def format_amount(value: Decimal | None) -> str:
|
||||||
|
"""Formats an amount for readability.
|
||||||
|
|
||||||
|
:param value: The amount.
|
||||||
|
:return: The formatted amount text.
|
||||||
|
"""
|
||||||
|
if value is None or value == 0:
|
||||||
|
return "-"
|
||||||
|
whole: int = int(value)
|
||||||
|
frac: Decimal = (value - whole).normalize()
|
||||||
|
return "{:,}".format(whole) + str(frac)[1:]
|
||||||
|
|
||||||
|
|
||||||
|
def format_amount_input(value: Decimal) -> str:
|
||||||
|
"""Format an amount for an input value.
|
||||||
|
|
||||||
|
:param value: The amount.
|
||||||
|
:return: The formatted amount text for an input value.
|
||||||
|
"""
|
||||||
|
whole: int = int(value)
|
||||||
|
frac: Decimal = (value - whole).normalize()
|
||||||
|
return str(whole) + str(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 text2html(value: str) -> str:
|
||||||
|
"""Converts plain text into HTML.
|
||||||
|
|
||||||
|
:param value: The plain text.
|
||||||
|
:return: The HTML.
|
||||||
|
"""
|
||||||
|
s: str = escape(value)
|
||||||
|
s = s.replace("\n", "<br>")
|
||||||
|
s = s.replace(" ", " ")
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
227
src/accounting/transaction/views.py
Normal file
227
src/accounting/transaction/views.py
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
||||||
|
|
||||||
|
# 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 transaction management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
from urllib.parse import parse_qsl, urlencode
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import Blueprint, render_template, session, redirect, request, \
|
||||||
|
flash, url_for
|
||||||
|
from werkzeug.datastructures import ImmutableMultiDict
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import lazy_gettext
|
||||||
|
from accounting.models import Transaction
|
||||||
|
from accounting.utils.flash_errors import flash_form_errors
|
||||||
|
from accounting.utils.next_uri import inherit_next, or_next
|
||||||
|
from accounting.utils.pagination import Pagination
|
||||||
|
from accounting.utils.permission import has_permission, can_view, can_edit
|
||||||
|
from accounting.utils.user import get_current_user_pk
|
||||||
|
from .dispatcher import TransactionType, get_txn_type, TXN_TYPE_OBJ
|
||||||
|
from .forms import sort_transactions_in, TransactionReorderForm
|
||||||
|
from .query import get_transaction_query
|
||||||
|
from .template import with_type, to_transfer, format_amount, \
|
||||||
|
format_amount_input, format_date, text2html, currency_options, \
|
||||||
|
default_currency_code
|
||||||
|
|
||||||
|
bp: Blueprint = Blueprint("transaction", __name__)
|
||||||
|
"""The view blueprint for the transaction management."""
|
||||||
|
bp.add_app_template_filter(with_type, "accounting_txn_with_type")
|
||||||
|
bp.add_app_template_filter(to_transfer, "accounting_txn_to_transfer")
|
||||||
|
bp.add_app_template_filter(format_amount, "accounting_txn_format_amount")
|
||||||
|
bp.add_app_template_filter(format_amount_input,
|
||||||
|
"accounting_txn_format_amount_input")
|
||||||
|
bp.add_app_template_filter(format_date, "accounting_txn_format_date")
|
||||||
|
bp.add_app_template_filter(text2html, "accounting_txn_text2html")
|
||||||
|
bp.add_app_template_global(currency_options, "accounting_txn_currency_options")
|
||||||
|
bp.add_app_template_global(default_currency_code,
|
||||||
|
"accounting_txn_default_currency_code")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("", endpoint="list")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def list_transactions() -> str:
|
||||||
|
"""Lists the transactions.
|
||||||
|
|
||||||
|
:return: The transaction list.
|
||||||
|
"""
|
||||||
|
transactions: list[Transaction] = get_transaction_query()
|
||||||
|
pagination: Pagination = Pagination[Transaction](transactions)
|
||||||
|
return render_template("accounting/transaction/list.html",
|
||||||
|
list=pagination.list, pagination=pagination,
|
||||||
|
types=TXN_TYPE_OBJ)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/create/<transactionType:txn_type>", endpoint="create")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def show_add_transaction_form(txn_type: TransactionType) -> str:
|
||||||
|
"""Shows the form to add a transaction.
|
||||||
|
|
||||||
|
:param txn_type: The transaction type.
|
||||||
|
:return: The form to add a transaction.
|
||||||
|
"""
|
||||||
|
form: txn_type.form
|
||||||
|
if "form" in session:
|
||||||
|
form = txn_type.form(ImmutableMultiDict(parse_qsl(session["form"])))
|
||||||
|
del session["form"]
|
||||||
|
form.validate()
|
||||||
|
else:
|
||||||
|
form = txn_type.form()
|
||||||
|
return txn_type.render_create_template(form)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/store/<transactionType:txn_type>", endpoint="store")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def add_transaction(txn_type: TransactionType) -> redirect:
|
||||||
|
"""Adds a transaction.
|
||||||
|
|
||||||
|
:param txn_type: The transaction type.
|
||||||
|
:return: The redirection to the transaction detail on success, or the
|
||||||
|
transaction creation form on error.
|
||||||
|
"""
|
||||||
|
form: txn_type.form = txn_type.form(request.form)
|
||||||
|
if not form.validate():
|
||||||
|
flash_form_errors(form)
|
||||||
|
session["form"] = urlencode(list(request.form.items()))
|
||||||
|
return redirect(inherit_next(with_type(
|
||||||
|
url_for("accounting.transaction.create", txn_type=txn_type))))
|
||||||
|
txn: Transaction = Transaction()
|
||||||
|
form.populate_obj(txn)
|
||||||
|
db.session.add(txn)
|
||||||
|
db.session.commit()
|
||||||
|
flash(lazy_gettext("The transaction is added successfully"), "success")
|
||||||
|
return redirect(inherit_next(__get_detail_uri(txn)))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/<transaction:txn>", endpoint="detail")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def show_transaction_detail(txn: Transaction) -> str:
|
||||||
|
"""Shows the transaction detail.
|
||||||
|
|
||||||
|
:param txn: The transaction.
|
||||||
|
:return: The detail.
|
||||||
|
"""
|
||||||
|
txn_type: TransactionType = get_txn_type(txn)
|
||||||
|
return txn_type.render_detail_template(txn)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/<transaction:txn>/edit", endpoint="edit")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def show_transaction_edit_form(txn: Transaction) -> str:
|
||||||
|
"""Shows the form to edit a transaction.
|
||||||
|
|
||||||
|
:param txn: The transaction.
|
||||||
|
:return: The form to edit the transaction.
|
||||||
|
"""
|
||||||
|
txn_type: TransactionType = get_txn_type(txn)
|
||||||
|
form: txn_type.form
|
||||||
|
if "form" in session:
|
||||||
|
form = txn_type.form(ImmutableMultiDict(parse_qsl(session["form"])))
|
||||||
|
del session["form"]
|
||||||
|
form.validate()
|
||||||
|
else:
|
||||||
|
form = txn_type.form(obj=txn)
|
||||||
|
return txn_type.render_edit_template(txn, form)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/<transaction:txn>/update", endpoint="update")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def update_transaction(txn: Transaction) -> redirect:
|
||||||
|
"""Updates a transaction.
|
||||||
|
|
||||||
|
:param txn: The transaction.
|
||||||
|
:return: The redirection to the transaction detail on success, or the
|
||||||
|
transaction edit form on error.
|
||||||
|
"""
|
||||||
|
txn_type: TransactionType = get_txn_type(txn)
|
||||||
|
form: txn_type.form = txn_type.form(request.form)
|
||||||
|
if not form.validate():
|
||||||
|
flash_form_errors(form)
|
||||||
|
session["form"] = urlencode(list(request.form.items()))
|
||||||
|
return redirect(inherit_next(with_type(
|
||||||
|
url_for("accounting.transaction.edit", txn=txn))))
|
||||||
|
with db.session.no_autoflush:
|
||||||
|
form.populate_obj(txn)
|
||||||
|
if not form.is_modified:
|
||||||
|
flash(lazy_gettext("The transaction was not modified."), "success")
|
||||||
|
return redirect(inherit_next(with_type(__get_detail_uri(txn))))
|
||||||
|
txn.updated_by_id = get_current_user_pk()
|
||||||
|
txn.updated_at = sa.func.now()
|
||||||
|
db.session.commit()
|
||||||
|
flash(lazy_gettext("The transaction is updated successfully."), "success")
|
||||||
|
return redirect(inherit_next(with_type(__get_detail_uri(txn))))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/<transaction:txn>/delete", endpoint="delete")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def delete_transaction(txn: Transaction) -> redirect:
|
||||||
|
"""Deletes a transaction.
|
||||||
|
|
||||||
|
:param txn: The transaction.
|
||||||
|
:return: The redirection to the transaction list on success, or the
|
||||||
|
transaction detail on error.
|
||||||
|
"""
|
||||||
|
txn.delete()
|
||||||
|
sort_transactions_in(txn.date, txn.id)
|
||||||
|
db.session.commit()
|
||||||
|
flash(lazy_gettext("The transaction is deleted successfully."), "success")
|
||||||
|
return redirect(or_next(with_type(url_for("accounting.transaction.list"))))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/dates/<date:txn_date>", endpoint="order")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def show_transaction_order(txn_date: date) -> str:
|
||||||
|
"""Shows the order of the transactions in a same date.
|
||||||
|
|
||||||
|
:param txn_date: The date.
|
||||||
|
:return: The order of the transactions in the date.
|
||||||
|
"""
|
||||||
|
transactions: list[Transaction] = Transaction.query\
|
||||||
|
.filter(Transaction.date == txn_date)\
|
||||||
|
.order_by(Transaction.no).all()
|
||||||
|
return render_template("accounting/transaction/order.html",
|
||||||
|
date=txn_date, list=transactions)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/dates/<date:txn_date>", endpoint="sort")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def sort_accounts(txn_date: date) -> redirect:
|
||||||
|
"""Reorders the transactions in a date.
|
||||||
|
|
||||||
|
:param txn_date: The date.
|
||||||
|
:return: The redirection to the incoming account or the account list. The
|
||||||
|
reordering operation does not fail.
|
||||||
|
"""
|
||||||
|
form: TransactionReorderForm = TransactionReorderForm(txn_date)
|
||||||
|
form.save_order()
|
||||||
|
if not form.is_modified:
|
||||||
|
flash(lazy_gettext("The order was not modified."), "success")
|
||||||
|
return redirect(or_next(url_for("accounting.account.list")))
|
||||||
|
db.session.commit()
|
||||||
|
flash(lazy_gettext("The order is updated successfully."), "success")
|
||||||
|
return redirect(or_next(url_for("accounting.account.list")))
|
||||||
|
|
||||||
|
|
||||||
|
def __get_detail_uri(txn: Transaction) -> str:
|
||||||
|
"""Returns the detail URI of a transaction.
|
||||||
|
|
||||||
|
:param txn: The transaction.
|
||||||
|
:return: The detail URI of the transaction.
|
||||||
|
"""
|
||||||
|
return url_for("accounting.transaction.detail", txn=txn)
|
@ -8,8 +8,8 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
|
"Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
|
||||||
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
|
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
|
||||||
"POT-Creation-Date: 2023-02-07 16:22+0800\n"
|
"POT-Creation-Date: 2023-03-01 00:51+0800\n"
|
||||||
"PO-Revision-Date: 2023-02-07 18:04+0800\n"
|
"PO-Revision-Date: 2023-03-01 00:51+0800\n"
|
||||||
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
|
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
|
||||||
"Language: zh_Hant\n"
|
"Language: zh_Hant\n"
|
||||||
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
|
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
|
||||||
@ -19,6 +19,21 @@ msgstr ""
|
|||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Generated-By: Babel 2.11.0\n"
|
"Generated-By: Babel 2.11.0\n"
|
||||||
|
|
||||||
|
#: src/accounting/models.py:575
|
||||||
|
#, python-format
|
||||||
|
msgid "Cash Expense Transaction#%(id)s"
|
||||||
|
msgstr "現金支出傳票#%(id)s"
|
||||||
|
|
||||||
|
#: src/accounting/models.py:577
|
||||||
|
#, python-format
|
||||||
|
msgid "Cash Income Transaction#%(id)s"
|
||||||
|
msgstr "現金收入傳票#%(id)s"
|
||||||
|
|
||||||
|
#: src/accounting/models.py:578
|
||||||
|
#, python-format
|
||||||
|
msgid "Transfer Transaction#%(id)s"
|
||||||
|
msgstr "轉帳傳票#%(id)s"
|
||||||
|
|
||||||
#: src/accounting/account/forms.py:41
|
#: src/accounting/account/forms.py:41
|
||||||
msgid "The base account does not exist."
|
msgid "The base account does not exist."
|
||||||
msgstr "沒有這個基本科目。"
|
msgstr "沒有這個基本科目。"
|
||||||
@ -28,7 +43,7 @@ msgid "The base account is not available."
|
|||||||
msgstr "不能選這個基本科目。"
|
msgstr "不能選這個基本科目。"
|
||||||
|
|
||||||
#: src/accounting/account/forms.py:61
|
#: src/accounting/account/forms.py:61
|
||||||
#: src/accounting/static/js/account-form.js:110
|
#: src/accounting/static/js/account-form.js:157
|
||||||
msgid "Please select the base account."
|
msgid "Please select the base account."
|
||||||
msgstr "請選擇基本科目。"
|
msgstr "請選擇基本科目。"
|
||||||
|
|
||||||
@ -38,79 +53,128 @@ msgstr "請填上標題。"
|
|||||||
|
|
||||||
#: src/accounting/account/query.py:50
|
#: src/accounting/account/query.py:50
|
||||||
#: src/accounting/templates/accounting/account/detail.html:90
|
#: src/accounting/templates/accounting/account/detail.html:90
|
||||||
#: src/accounting/templates/accounting/account/list.html:62
|
#: src/accounting/templates/accounting/account/list.html:74
|
||||||
msgid "Offset needed"
|
msgid "Pay-off needed"
|
||||||
msgstr "逐筆核銷"
|
msgstr "逐筆核銷"
|
||||||
|
|
||||||
#: src/accounting/account/views.py:88
|
#: src/accounting/account/views.py:89
|
||||||
msgid "The account is added successfully"
|
msgid "The account is added successfully"
|
||||||
msgstr "科目加好了。"
|
msgstr "科目加好了。"
|
||||||
|
|
||||||
#: src/accounting/account/views.py:143
|
#: src/accounting/account/views.py:141
|
||||||
msgid "The account was not modified."
|
msgid "The account was not modified."
|
||||||
msgstr "科目未異動。"
|
msgstr "科目未異動。"
|
||||||
|
|
||||||
#: src/accounting/account/views.py:148
|
#: src/accounting/account/views.py:146
|
||||||
msgid "The account is updated successfully."
|
msgid "The account is updated successfully."
|
||||||
msgstr "科目存好了。"
|
msgstr "科目存好了。"
|
||||||
|
|
||||||
#: src/accounting/account/views.py:165
|
#: src/accounting/account/views.py:162
|
||||||
msgid "The account is deleted successfully."
|
msgid "The account is deleted successfully."
|
||||||
msgstr "科目刪掉了"
|
msgstr "科目刪掉了"
|
||||||
|
|
||||||
#: src/accounting/account/views.py:192
|
#: src/accounting/account/views.py:189 src/accounting/transaction/views.py:214
|
||||||
msgid "The order was not modified."
|
msgid "The order was not modified."
|
||||||
msgstr "順序未異動。"
|
msgstr "順序未異動。"
|
||||||
|
|
||||||
#: src/accounting/account/views.py:195
|
#: src/accounting/account/views.py:192 src/accounting/transaction/views.py:217
|
||||||
msgid "The order is updated successfully."
|
msgid "The order is updated successfully."
|
||||||
msgstr "順序存好了。"
|
msgstr "順序存好了。"
|
||||||
|
|
||||||
#: src/accounting/currency/forms.py:47
|
#: src/accounting/currency/forms.py:46
|
||||||
#: src/accounting/static/js/currency-form.js:136
|
#: src/accounting/static/js/currency-form.js:136
|
||||||
msgid "Code conflicts with another currency."
|
msgid "Code conflicts with another currency."
|
||||||
msgstr "代碼與其它貨幣重複。"
|
msgstr "代碼與其它貨幣重複。"
|
||||||
|
|
||||||
#: src/accounting/currency/forms.py:52
|
#: src/accounting/currency/forms.py:51
|
||||||
#: src/accounting/static/js/currency-form.js:92
|
#: src/accounting/static/js/currency-form.js:92
|
||||||
msgid "Please fill in the code."
|
msgid "Please fill in the code."
|
||||||
msgstr "請填上代碼。"
|
msgstr "請填上代碼。"
|
||||||
|
|
||||||
#: src/accounting/currency/forms.py:54
|
#: src/accounting/currency/forms.py:53
|
||||||
#: src/accounting/static/js/currency-form.js:103
|
#: src/accounting/static/js/currency-form.js:103
|
||||||
msgid "Code can only be composed of 3 upper-cased letters."
|
msgid "Code can only be composed of 3 upper-cased letters."
|
||||||
msgstr "代碼限為三個大寫英文字母。"
|
msgstr "代碼限為三個大寫英文字母。"
|
||||||
|
|
||||||
#: src/accounting/currency/forms.py:57
|
#: src/accounting/currency/forms.py:56
|
||||||
#: src/accounting/static/js/currency-form.js:98
|
#: src/accounting/static/js/currency-form.js:98
|
||||||
msgid "This code is not available."
|
msgid "This code is not available."
|
||||||
msgstr "不能用這個代碼。"
|
msgstr "不能用這個代碼。"
|
||||||
|
|
||||||
#: src/accounting/currency/forms.py:63
|
#: src/accounting/currency/forms.py:62
|
||||||
#: src/accounting/static/js/currency-form.js:168
|
#: src/accounting/static/js/currency-form.js:168
|
||||||
msgid "Please fill in the name."
|
msgid "Please fill in the name."
|
||||||
msgstr "請填上名稱。"
|
msgstr "請填上名稱。"
|
||||||
|
|
||||||
#: src/accounting/currency/views.py:90
|
#: src/accounting/currency/views.py:91
|
||||||
msgid "The currency is added successfully"
|
msgid "The currency is added successfully"
|
||||||
msgstr "貨幣加好了。"
|
msgstr "貨幣加好了。"
|
||||||
|
|
||||||
#: src/accounting/currency/views.py:146
|
#: src/accounting/currency/views.py:144
|
||||||
msgid "The currency was not modified."
|
msgid "The currency was not modified."
|
||||||
msgstr "貨幣未異動。"
|
msgstr "貨幣未異動。"
|
||||||
|
|
||||||
#: src/accounting/currency/views.py:151
|
#: src/accounting/currency/views.py:149
|
||||||
msgid "The currency is updated successfully."
|
msgid "The currency is updated successfully."
|
||||||
msgstr "貨幣存好了。"
|
msgstr "貨幣存好了。"
|
||||||
|
|
||||||
#: src/accounting/currency/views.py:167
|
#: src/accounting/currency/views.py:164
|
||||||
msgid "The currency is deleted successfully."
|
msgid "The currency is deleted successfully."
|
||||||
msgstr "貨幣刪掉了"
|
msgstr "貨幣刪掉了"
|
||||||
|
|
||||||
#: src/accounting/static/js/account-form.js:130
|
#: src/accounting/static/js/account-form.js:177
|
||||||
msgid "Please fill in the title."
|
msgid "Please fill in the title."
|
||||||
msgstr "請填上標題。"
|
msgstr "請填上標題。"
|
||||||
|
|
||||||
|
#: src/accounting/static/js/summary-helper.js:441
|
||||||
|
#: src/accounting/static/js/summary-helper.js:512
|
||||||
|
msgid "Please fill in the tag."
|
||||||
|
msgstr "請填上標籤。"
|
||||||
|
|
||||||
|
#: src/accounting/static/js/summary-helper.js:460
|
||||||
|
#: src/accounting/static/js/summary-helper.js:550
|
||||||
|
msgid "Please fill in the origin."
|
||||||
|
msgstr "請填上起點。"
|
||||||
|
|
||||||
|
#: src/accounting/static/js/summary-helper.js:479
|
||||||
|
#: src/accounting/static/js/summary-helper.js:569
|
||||||
|
msgid "Please fill in the destination."
|
||||||
|
msgstr "請填上終點。"
|
||||||
|
|
||||||
|
#: src/accounting/static/js/summary-helper.js:531
|
||||||
|
msgid "Please fill in the route."
|
||||||
|
msgstr "請填上路線名稱。"
|
||||||
|
|
||||||
|
#: src/accounting/static/js/transaction-form.js:289
|
||||||
|
#: src/accounting/static/js/transaction-form.js:611
|
||||||
|
#: src/accounting/transaction/forms.py:47
|
||||||
|
msgid "Please select the account."
|
||||||
|
msgstr "請選擇科目。"
|
||||||
|
|
||||||
|
#: src/accounting/static/js/transaction-form.js:324
|
||||||
|
#: src/accounting/static/js/transaction-form.js:616
|
||||||
|
msgid "Please fill in the amount."
|
||||||
|
msgstr "請填上金額。"
|
||||||
|
|
||||||
|
#: src/accounting/static/js/transaction-form.js:488
|
||||||
|
msgid "Please fill in the date."
|
||||||
|
msgstr "請填上日期。"
|
||||||
|
|
||||||
|
#: src/accounting/static/js/transaction-form.js:523
|
||||||
|
#: src/accounting/transaction/forms.py:57
|
||||||
|
msgid "Please add some currencies."
|
||||||
|
msgstr "請加上貨幣。"
|
||||||
|
|
||||||
|
#: src/accounting/static/js/transaction-form.js:589
|
||||||
|
#: src/accounting/transaction/forms.py:78
|
||||||
|
msgid "Please add some journal entries."
|
||||||
|
msgstr "請加上分錄。"
|
||||||
|
|
||||||
|
#: src/accounting/static/js/transaction-form.js:654
|
||||||
|
#: src/accounting/transaction/forms.py:672
|
||||||
|
msgid "The totals of the debit and credit amounts do not match."
|
||||||
|
msgstr "借方貸方合計不符。 "
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/create.html:24
|
#: src/accounting/templates/accounting/account/create.html:24
|
||||||
msgid "Add a New Account"
|
msgid "Add a New Account"
|
||||||
msgstr "新增科目"
|
msgstr "新增科目"
|
||||||
@ -121,20 +185,26 @@ msgstr "新增科目"
|
|||||||
#: src/accounting/templates/accounting/base-account/detail.html:31
|
#: src/accounting/templates/accounting/base-account/detail.html:31
|
||||||
#: src/accounting/templates/accounting/currency/detail.html:31
|
#: src/accounting/templates/accounting/currency/detail.html:31
|
||||||
#: src/accounting/templates/accounting/currency/include/form.html:33
|
#: src/accounting/templates/accounting/currency/include/form.html:33
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/detail.html:31
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/form.html:36
|
||||||
|
#: src/accounting/templates/accounting/transaction/order.html:36
|
||||||
msgid "Back"
|
msgid "Back"
|
||||||
msgstr "回上頁"
|
msgstr "回上頁"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/detail.html:36
|
#: src/accounting/templates/accounting/account/detail.html:36
|
||||||
#: src/accounting/templates/accounting/currency/detail.html:36
|
#: src/accounting/templates/accounting/currency/detail.html:36
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/detail.html:36
|
||||||
msgid "Settings"
|
msgid "Settings"
|
||||||
msgstr "設定"
|
msgstr "設定"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/detail.html:41
|
#: src/accounting/templates/accounting/account/detail.html:41
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/detail.html:41
|
||||||
msgid "Order"
|
msgid "Order"
|
||||||
msgstr "次序"
|
msgstr "次序"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/detail.html:46
|
#: src/accounting/templates/accounting/account/detail.html:46
|
||||||
#: src/accounting/templates/accounting/currency/detail.html:42
|
#: src/accounting/templates/accounting/currency/detail.html:42
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/detail.html:47
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr "刪除"
|
msgstr "刪除"
|
||||||
|
|
||||||
@ -142,28 +212,45 @@ msgstr "刪除"
|
|||||||
msgid "Delete Account Confirmation"
|
msgid "Delete Account Confirmation"
|
||||||
msgstr "科目刪除確認"
|
msgstr "科目刪除確認"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/account/detail.html:70
|
||||||
|
#: src/accounting/templates/accounting/account/include/form.html:91
|
||||||
|
#: src/accounting/templates/accounting/currency/detail.html:66
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:27
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/detail.html:71
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:28
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:30
|
||||||
|
msgid "Close"
|
||||||
|
msgstr "關閉"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/detail.html:73
|
#: src/accounting/templates/accounting/account/detail.html:73
|
||||||
msgid "Do you really want to delete this account?"
|
msgid "Do you really want to delete this account?"
|
||||||
msgstr "你確定要刪掉這個科目嗎?"
|
msgstr "你確定要刪掉這個科目嗎?"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/detail.html:76
|
#: src/accounting/templates/accounting/account/detail.html:76
|
||||||
#: src/accounting/templates/accounting/account/include/form.html:111
|
#: src/accounting/templates/accounting/account/include/form.html:112
|
||||||
#: src/accounting/templates/accounting/currency/detail.html:72
|
#: src/accounting/templates/accounting/currency/detail.html:72
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:49
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/detail.html:77
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:54
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:175
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "取消"
|
msgstr "取消"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/detail.html:77
|
#: src/accounting/templates/accounting/account/detail.html:77
|
||||||
#: src/accounting/templates/accounting/currency/detail.html:73
|
#: src/accounting/templates/accounting/currency/detail.html:73
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/detail.html:78
|
||||||
msgid "Confirm"
|
msgid "Confirm"
|
||||||
msgstr "確定"
|
msgstr "確定"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/detail.html:94
|
#: src/accounting/templates/accounting/account/detail.html:94
|
||||||
#: src/accounting/templates/accounting/currency/detail.html:85
|
#: src/accounting/templates/accounting/currency/detail.html:85
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/detail.html:107
|
||||||
msgid "Created"
|
msgid "Created"
|
||||||
msgstr "建檔"
|
msgstr "建檔"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/detail.html:95
|
#: src/accounting/templates/accounting/account/detail.html:95
|
||||||
#: src/accounting/templates/accounting/currency/detail.html:86
|
#: src/accounting/templates/accounting/currency/detail.html:86
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/detail.html:108
|
||||||
msgid "Updated"
|
msgid "Updated"
|
||||||
msgstr "更新"
|
msgstr "更新"
|
||||||
|
|
||||||
@ -175,6 +262,7 @@ msgstr "%(account)s設定"
|
|||||||
#: src/accounting/templates/accounting/account/list.html:24
|
#: src/accounting/templates/accounting/account/list.html:24
|
||||||
#: src/accounting/templates/accounting/base-account/list.html:24
|
#: src/accounting/templates/accounting/base-account/list.html:24
|
||||||
#: src/accounting/templates/accounting/currency/list.html:24
|
#: src/accounting/templates/accounting/currency/list.html:24
|
||||||
|
#: src/accounting/templates/accounting/transaction/list.html:28
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Search Result for \"%(query)s\""
|
msgid "Search Result for \"%(query)s\""
|
||||||
msgstr "「%(query)s」搜尋結果"
|
msgstr "「%(query)s」搜尋結果"
|
||||||
@ -185,20 +273,48 @@ msgstr "科目管理"
|
|||||||
|
|
||||||
#: src/accounting/templates/accounting/account/list.html:32
|
#: src/accounting/templates/accounting/account/list.html:32
|
||||||
#: src/accounting/templates/accounting/currency/list.html:32
|
#: src/accounting/templates/accounting/currency/list.html:32
|
||||||
|
#: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:75
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/form.html:62
|
||||||
|
#: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:75
|
||||||
|
#: src/accounting/templates/accounting/transaction/list.html:37
|
||||||
|
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:77
|
||||||
|
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:117
|
||||||
msgid "New"
|
msgid "New"
|
||||||
msgstr "新增"
|
msgstr "新增"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/account/list.html:35
|
||||||
|
#: src/accounting/templates/accounting/currency/list.html:35
|
||||||
|
#: src/accounting/templates/accounting/transaction/list.html:57
|
||||||
|
msgid "Search for Desktop"
|
||||||
|
msgstr "桌機版檢索"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/include/form.html:98
|
#: src/accounting/templates/accounting/account/include/form.html:98
|
||||||
#: src/accounting/templates/accounting/account/list.html:40
|
#: src/accounting/templates/accounting/account/list.html:40
|
||||||
|
#: src/accounting/templates/accounting/account/list.html:52
|
||||||
|
#: src/accounting/templates/accounting/base-account/list.html:29
|
||||||
#: src/accounting/templates/accounting/base-account/list.html:34
|
#: src/accounting/templates/accounting/base-account/list.html:34
|
||||||
#: src/accounting/templates/accounting/currency/list.html:40
|
#: src/accounting/templates/accounting/currency/list.html:40
|
||||||
|
#: src/accounting/templates/accounting/currency/list.html:52
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:34
|
||||||
|
#: src/accounting/templates/accounting/transaction/list.html:62
|
||||||
|
#: src/accounting/templates/accounting/transaction/list.html:74
|
||||||
msgid "Search"
|
msgid "Search"
|
||||||
msgstr "搜尋"
|
msgstr "搜尋"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/list.html:68
|
#: src/accounting/templates/accounting/account/list.html:47
|
||||||
|
#: src/accounting/templates/accounting/currency/list.html:47
|
||||||
|
#: src/accounting/templates/accounting/transaction/list.html:69
|
||||||
|
msgid "Search for Mobile"
|
||||||
|
msgstr "行動版檢索"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/account/include/form.html:109
|
||||||
|
#: src/accounting/templates/accounting/account/list.html:80
|
||||||
#: src/accounting/templates/accounting/account/order.html:81
|
#: src/accounting/templates/accounting/account/order.html:81
|
||||||
#: src/accounting/templates/accounting/base-account/list.html:51
|
#: src/accounting/templates/accounting/base-account/list.html:51
|
||||||
#: src/accounting/templates/accounting/currency/list.html:57
|
#: src/accounting/templates/accounting/currency/list.html:77
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:46
|
||||||
|
#: src/accounting/templates/accounting/transaction/list.html:93
|
||||||
|
#: src/accounting/templates/accounting/transaction/order.html:80
|
||||||
msgid "There is no data."
|
msgid "There is no data."
|
||||||
msgstr "沒有資料。"
|
msgstr "沒有資料。"
|
||||||
|
|
||||||
@ -210,6 +326,10 @@ msgstr "%(base)s下的科目"
|
|||||||
#: src/accounting/templates/accounting/account/include/form.html:75
|
#: src/accounting/templates/accounting/account/include/form.html:75
|
||||||
#: src/accounting/templates/accounting/account/order.html:62
|
#: src/accounting/templates/accounting/account/order.html:62
|
||||||
#: src/accounting/templates/accounting/currency/include/form.html:57
|
#: src/accounting/templates/accounting/currency/include/form.html:57
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:55
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/form.html:78
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:176
|
||||||
|
#: src/accounting/templates/accounting/transaction/order.html:61
|
||||||
msgid "Save"
|
msgid "Save"
|
||||||
msgstr "儲存"
|
msgstr "儲存"
|
||||||
|
|
||||||
@ -226,15 +346,16 @@ msgid "Title"
|
|||||||
msgstr "標題"
|
msgstr "標題"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/include/form.html:68
|
#: src/accounting/templates/accounting/account/include/form.html:68
|
||||||
msgid "The entries in the account need offsets."
|
msgid "The entries in the account need pay-off."
|
||||||
msgstr "帳目要逐筆核銷。"
|
msgstr "帳目要逐筆核銷。"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/include/form.html:90
|
#: src/accounting/templates/accounting/account/include/form.html:90
|
||||||
msgid "Select Base Account"
|
msgid "Select Base Account"
|
||||||
msgstr "選擇基本科目"
|
msgstr "選擇基本科目"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/include/form.html:113
|
#: src/accounting/templates/accounting/account/include/form.html:114
|
||||||
#: src/accounting/templates/accounting/account/include/form.html:115
|
#: src/accounting/templates/accounting/account/include/form.html:116
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:50
|
||||||
msgid "Clear"
|
msgid "Clear"
|
||||||
msgstr "清除"
|
msgstr "清除"
|
||||||
|
|
||||||
@ -271,22 +392,264 @@ msgstr "代碼"
|
|||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "名稱"
|
msgstr "名稱"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/include/nav.html:26
|
#: src/accounting/templates/accounting/include/nav.html:27
|
||||||
msgid "Accounting"
|
msgid "Accounting"
|
||||||
msgstr "記帳"
|
msgstr "記帳"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/include/nav.html:32
|
#: src/accounting/templates/accounting/include/nav.html:33
|
||||||
|
msgid "Transactions"
|
||||||
|
msgstr "傳票"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/include/nav.html:39
|
||||||
msgid "Accounts"
|
msgid "Accounts"
|
||||||
msgstr "科目"
|
msgstr "科目"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/include/nav.html:38
|
#: src/accounting/templates/accounting/include/nav.html:45
|
||||||
msgid "Base Accounts"
|
msgid "Base Accounts"
|
||||||
msgstr "基本科目"
|
msgstr "基本科目"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/include/nav.html:44
|
#: src/accounting/templates/accounting/include/nav.html:51
|
||||||
msgid "Currencies"
|
msgid "Currencies"
|
||||||
msgstr "貨幣"
|
msgstr "貨幣"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/include/pagination.html:23
|
||||||
|
msgid "Page navigation"
|
||||||
|
msgstr "分頁瀏覽"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/list.html:28
|
||||||
|
msgid "Transaction Management"
|
||||||
|
msgstr "傳票管理"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/list.html:42
|
||||||
|
msgid "Cash Expense"
|
||||||
|
msgstr "現金支出"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/list.html:46
|
||||||
|
msgid "Cash Income"
|
||||||
|
msgstr "現金收入"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/add-new-material-fab.html:32
|
||||||
|
#: src/accounting/templates/accounting/transaction/list.html:51
|
||||||
|
msgid "Transfer"
|
||||||
|
msgstr "轉帳"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/order.html:29
|
||||||
|
#, python-format
|
||||||
|
msgid "Transactions on %(date)s"
|
||||||
|
msgstr "%(date)s的傳票"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/expense/create.html:24
|
||||||
|
msgid "Add a New Cash Expense Transaction"
|
||||||
|
msgstr "新增現金支出傳票"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/expense/detail.html:27
|
||||||
|
#: src/accounting/templates/accounting/transaction/income/detail.html:27
|
||||||
|
msgid "To Transfer"
|
||||||
|
msgstr "改轉帳"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/expense/detail.html:37
|
||||||
|
#: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:45
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/form.html:54
|
||||||
|
#: src/accounting/templates/accounting/transaction/income/detail.html:37
|
||||||
|
#: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:45
|
||||||
|
msgid "Content"
|
||||||
|
msgstr "內容"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/expense/detail.html:53
|
||||||
|
#: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:68
|
||||||
|
#: src/accounting/templates/accounting/transaction/income/detail.html:53
|
||||||
|
#: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:68
|
||||||
|
#: src/accounting/templates/accounting/transaction/transfer/detail.html:49
|
||||||
|
#: src/accounting/templates/accounting/transaction/transfer/detail.html:75
|
||||||
|
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:70
|
||||||
|
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:110
|
||||||
|
msgid "Total"
|
||||||
|
msgstr "合計"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/expense/edit.html:24
|
||||||
|
#: src/accounting/templates/accounting/transaction/income/edit.html:24
|
||||||
|
#: src/accounting/templates/accounting/transaction/transfer/edit.html:24
|
||||||
|
#, python-format
|
||||||
|
msgid "Editing %(txn)s"
|
||||||
|
msgstr "編輯%(txn)s"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:32
|
||||||
|
#: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:32
|
||||||
|
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:32
|
||||||
|
msgid "Currency"
|
||||||
|
msgstr "貨幣"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:26
|
||||||
|
msgid "Select Account"
|
||||||
|
msgstr "選擇科目"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:44
|
||||||
|
msgid "More…"
|
||||||
|
msgstr "更多…"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/add-new-material-fab.html:26
|
||||||
|
msgid "Cash expense"
|
||||||
|
msgstr "現金支出"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/add-new-material-fab.html:29
|
||||||
|
msgid "Cash income"
|
||||||
|
msgstr "現金收入"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/detail.html:70
|
||||||
|
msgid "Delete Transaction Confirmation"
|
||||||
|
msgstr "傳票刪除確認"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/detail.html:74
|
||||||
|
msgid "Do you really want to delete this transaction?"
|
||||||
|
msgstr "你確定要刪掉這張傳票嗎?"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:27
|
||||||
|
msgid "Journal Entry Content"
|
||||||
|
msgstr "分錄內容"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:33
|
||||||
|
msgid "Account"
|
||||||
|
msgstr "科目"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:41
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:28
|
||||||
|
msgid "Summary"
|
||||||
|
msgstr "摘要"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:49
|
||||||
|
msgid "Amount"
|
||||||
|
msgstr "金額"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/form.html:48
|
||||||
|
msgid "Date"
|
||||||
|
msgstr "日期"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/form.html:71
|
||||||
|
msgid "Note"
|
||||||
|
msgstr "備註"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:39
|
||||||
|
msgid "General"
|
||||||
|
msgstr "一般"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:44
|
||||||
|
msgid "Travel"
|
||||||
|
msgstr "差旅"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:49
|
||||||
|
msgid "Bus"
|
||||||
|
msgstr "公車"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:54
|
||||||
|
msgid "Regular"
|
||||||
|
msgstr "帳單"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:59
|
||||||
|
msgid "Number"
|
||||||
|
msgstr "數量"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:67
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:84
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:119
|
||||||
|
msgid "Tag"
|
||||||
|
msgstr "標籤"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:99
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:140
|
||||||
|
msgid "From"
|
||||||
|
msgstr "從"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:108
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:145
|
||||||
|
msgid "To"
|
||||||
|
msgstr "至"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:124
|
||||||
|
msgid "Route"
|
||||||
|
msgstr "路線"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:160
|
||||||
|
msgid "The number of items"
|
||||||
|
msgstr "數量"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/income/create.html:24
|
||||||
|
msgid "Add a New Cash Income Transaction"
|
||||||
|
msgstr "新增現金收入傳票"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/transfer/create.html:24
|
||||||
|
msgid "Add a New Transfer Transaction"
|
||||||
|
msgstr "新增轉帳傳票"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/transfer/detail.html:33
|
||||||
|
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:47
|
||||||
|
msgid "Debit"
|
||||||
|
msgstr "借方"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/transaction/transfer/detail.html:59
|
||||||
|
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:87
|
||||||
|
msgid "Credit"
|
||||||
|
msgstr "貸方"
|
||||||
|
|
||||||
|
#: src/accounting/transaction/forms.py:45
|
||||||
|
msgid "Please select the currency."
|
||||||
|
msgstr "請選擇貨幣。"
|
||||||
|
|
||||||
|
#: src/accounting/transaction/forms.py:68
|
||||||
|
msgid "The currency does not exist."
|
||||||
|
msgstr "沒有這個貨幣。"
|
||||||
|
|
||||||
|
#: src/accounting/transaction/forms.py:89
|
||||||
|
msgid "The account does not exist."
|
||||||
|
msgstr "沒有這個科目。"
|
||||||
|
|
||||||
|
#: src/accounting/transaction/forms.py:100
|
||||||
|
msgid "Please fill in a positive amount."
|
||||||
|
msgstr "金額請填正數。"
|
||||||
|
|
||||||
|
#: src/accounting/transaction/forms.py:114
|
||||||
|
msgid "This account is not for debit entries."
|
||||||
|
msgstr "科目不是借方科目。"
|
||||||
|
|
||||||
|
#: src/accounting/transaction/forms.py:201
|
||||||
|
msgid "This account is not for credit entries."
|
||||||
|
msgstr "科目不是貸方科目。"
|
||||||
|
|
||||||
|
#: src/accounting/transaction/template.py:97
|
||||||
|
msgid "Today"
|
||||||
|
msgstr "今天"
|
||||||
|
|
||||||
|
#: src/accounting/transaction/template.py:99
|
||||||
|
msgid "Yesterday"
|
||||||
|
msgstr "昨天"
|
||||||
|
|
||||||
|
#: src/accounting/transaction/template.py:101
|
||||||
|
msgid "Tomorrow"
|
||||||
|
msgstr "明天"
|
||||||
|
|
||||||
|
#: src/accounting/transaction/template.py:105
|
||||||
|
msgid "The day before yesterday"
|
||||||
|
msgstr "前天"
|
||||||
|
|
||||||
|
#: src/accounting/transaction/template.py:107
|
||||||
|
msgid "The day after tomorrow"
|
||||||
|
msgstr "後天"
|
||||||
|
|
||||||
|
#: src/accounting/transaction/views.py:108
|
||||||
|
msgid "The transaction is added successfully"
|
||||||
|
msgstr "傳票加好了。"
|
||||||
|
|
||||||
|
#: src/accounting/transaction/views.py:162
|
||||||
|
msgid "The transaction was not modified."
|
||||||
|
msgstr "傳票未異動。"
|
||||||
|
|
||||||
|
#: src/accounting/transaction/views.py:167
|
||||||
|
msgid "The transaction is updated successfully."
|
||||||
|
msgstr "傳票存好了。"
|
||||||
|
|
||||||
|
#: src/accounting/transaction/views.py:183
|
||||||
|
msgid "The transaction is deleted successfully."
|
||||||
|
msgstr "傳票刪掉了"
|
||||||
|
|
||||||
#: src/accounting/utils/pagination.py:206
|
#: src/accounting/utils/pagination.py:206
|
||||||
msgctxt "Pagination|"
|
msgctxt "Pagination|"
|
||||||
msgid "Previous"
|
msgid "Previous"
|
||||||
|
50
src/accounting/utils/flash_errors.py
Normal file
50
src/accounting/utils/flash_errors.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
||||||
|
|
||||||
|
# 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 flash all errors from the forms.
|
||||||
|
|
||||||
|
This module should not import any other module from the application.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import typing as t
|
||||||
|
|
||||||
|
from flask import flash
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
|
||||||
|
|
||||||
|
def flash_form_errors(form: FlaskForm) -> None:
|
||||||
|
"""Flash all errors from a form recursively.
|
||||||
|
|
||||||
|
:param form: The form.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
__flash_errors(form.errors)
|
||||||
|
|
||||||
|
|
||||||
|
def __flash_errors(error: t.Any) -> None:
|
||||||
|
"""Flash all errors recursively.
|
||||||
|
|
||||||
|
:param error: The errors.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
if isinstance(error, dict):
|
||||||
|
for key in error:
|
||||||
|
__flash_errors(error[key])
|
||||||
|
elif isinstance(error, list):
|
||||||
|
for e in error:
|
||||||
|
__flash_errors(e)
|
||||||
|
else:
|
||||||
|
flash(error, "error")
|
@ -14,7 +14,7 @@
|
|||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
"""The utilities to handle the next URL.
|
"""The utilities to handle the next URI.
|
||||||
|
|
||||||
This module should not import any other module from the application.
|
This module should not import any other module from the application.
|
||||||
|
|
||||||
@ -68,7 +68,7 @@ def __set_next(uri: str, next_uri: str) -> str:
|
|||||||
"""
|
"""
|
||||||
uri_p: ParseResult = urlparse(uri)
|
uri_p: ParseResult = urlparse(uri)
|
||||||
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
|
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
|
||||||
params = [x for x in params if x[0] == "next"]
|
params = [x for x in params if x[0] != "next"]
|
||||||
params.append(("next", next_uri))
|
params.append(("next", next_uri))
|
||||||
parts: list[str] = list(uri_p)
|
parts: list[str] = list(uri_p)
|
||||||
parts[4] = urlencode(params)
|
parts[4] = urlencode(params)
|
@ -115,7 +115,7 @@ class EmptyPagination(AbstractPagination[T]):
|
|||||||
|
|
||||||
class NonEmptyPagination(AbstractPagination[T]):
|
class NonEmptyPagination(AbstractPagination[T]):
|
||||||
"""The pagination with real data."""
|
"""The pagination with real data."""
|
||||||
PAGE_SIZE_OPTIONS: list[int] = [10, 100, 200]
|
PAGE_SIZE_OPTION_VALUES: list[int] = [10, 100, 200]
|
||||||
"""The page size options."""
|
"""The page size options."""
|
||||||
|
|
||||||
def __init__(self, items: list[T], is_reversed: bool = False):
|
def __init__(self, items: list[T], is_reversed: bool = False):
|
||||||
@ -278,7 +278,7 @@ class NonEmptyPagination(AbstractPagination[T]):
|
|||||||
return []
|
return []
|
||||||
return [Link(str(x), self.__uri_size(x),
|
return [Link(str(x), self.__uri_size(x),
|
||||||
is_current=x == self.page_size)
|
is_current=x == self.page_size)
|
||||||
for x in self.PAGE_SIZE_OPTIONS]
|
for x in self.PAGE_SIZE_OPTION_VALUES]
|
||||||
|
|
||||||
def __uri_size(self, page_size: int) -> str:
|
def __uri_size(self, page_size: int) -> str:
|
||||||
"""Returns the URI of a page size.
|
"""Returns the URI of a page size.
|
||||||
|
@ -19,6 +19,7 @@
|
|||||||
This module should not import any other module from the application.
|
This module should not import any other module from the application.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
def strip_text(s: str | None) -> str | None:
|
def strip_text(s: str | None) -> str | None:
|
||||||
@ -29,4 +30,17 @@ def strip_text(s: str | None) -> str | None:
|
|||||||
"""
|
"""
|
||||||
if s is None:
|
if s is None:
|
||||||
return None
|
return None
|
||||||
return s.strip()
|
s = s.strip()
|
||||||
|
return s if s != "" else None
|
||||||
|
|
||||||
|
|
||||||
|
def strip_multiline_text(s: str | None) -> str | None:
|
||||||
|
"""The filter to strip a piece of multi-line text.
|
||||||
|
|
||||||
|
:param s: The text input string.
|
||||||
|
:return: The filtered string.
|
||||||
|
"""
|
||||||
|
if s is None:
|
||||||
|
return None
|
||||||
|
s = re.sub(r"^\s*\n", "", s.rstrip())
|
||||||
|
return s if s != "" else None
|
||||||
|
@ -17,8 +17,8 @@
|
|||||||
"""The test for the account management.
|
"""The test for the account management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import time
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@ -26,9 +26,12 @@ from click.testing import Result
|
|||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask.testing import FlaskCliRunner
|
from flask.testing import FlaskCliRunner
|
||||||
|
|
||||||
from test_site import create_app
|
from test_site import create_app, db
|
||||||
from testlib import get_client, set_locale
|
from testlib import get_client, set_locale
|
||||||
|
|
||||||
|
NEXT_URI: str = "/_next"
|
||||||
|
"""The next URI."""
|
||||||
|
|
||||||
|
|
||||||
class AccountData:
|
class AccountData:
|
||||||
"""The account data."""
|
"""The account data."""
|
||||||
@ -75,7 +78,6 @@ class AccountCommandTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
runner: FlaskCliRunner = self.app.test_cli_runner()
|
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||||
with self.app.app_context():
|
with self.app.app_context():
|
||||||
from accounting import db
|
|
||||||
from accounting.models import BaseAccount, Account, AccountL10n
|
from accounting.models import BaseAccount, Account, AccountL10n
|
||||||
result: Result
|
result: Result
|
||||||
result = runner.invoke(args="init-db")
|
result = runner.invoke(args="init-db")
|
||||||
@ -129,7 +131,6 @@ class AccountTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
runner: FlaskCliRunner = self.app.test_cli_runner()
|
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||||
with self.app.app_context():
|
with self.app.app_context():
|
||||||
from accounting import db
|
|
||||||
from accounting.models import BaseAccount, Account, AccountL10n
|
from accounting.models import BaseAccount, Account, AccountL10n
|
||||||
result: Result
|
result: Result
|
||||||
result = runner.invoke(args="init-db")
|
result = runner.invoke(args="init-db")
|
||||||
@ -205,7 +206,7 @@ class AccountTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
response = client.post(f"{PREFIX}/bases/{bank.base_code}",
|
response = client.post(f"{PREFIX}/bases/{bank.base_code}",
|
||||||
data={"csrf_token": csrf_token,
|
data={"csrf_token": csrf_token,
|
||||||
"next": "/next",
|
"next": NEXT_URI,
|
||||||
f"{bank_id}-no": "5"})
|
f"{bank_id}-no": "5"})
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
@ -254,7 +255,7 @@ class AccountTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
response = client.post(f"{PREFIX}/bases/{bank.base_code}",
|
response = client.post(f"{PREFIX}/bases/{bank.base_code}",
|
||||||
data={"csrf_token": csrf_token,
|
data={"csrf_token": csrf_token,
|
||||||
"next": "/next",
|
"next": NEXT_URI,
|
||||||
f"{bank_id}-no": "5"})
|
f"{bank_id}-no": "5"})
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
@ -306,17 +307,16 @@ class AccountTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
response = self.client.post(f"{PREFIX}/bases/{bank.base_code}",
|
response = self.client.post(f"{PREFIX}/bases/{bank.base_code}",
|
||||||
data={"csrf_token": self.csrf_token,
|
data={"csrf_token": self.csrf_token,
|
||||||
"next": "/next",
|
"next": NEXT_URI,
|
||||||
f"{bank_id}-no": "5"})
|
f"{bank_id}-no": "5"})
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.headers["Location"], "/next")
|
self.assertEqual(response.headers["Location"], NEXT_URI)
|
||||||
|
|
||||||
def test_add(self) -> None:
|
def test_add(self) -> None:
|
||||||
"""Tests to add the currencies.
|
"""Tests to add the currencies.
|
||||||
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
from accounting import db
|
|
||||||
from accounting.models import Account
|
from accounting.models import Account
|
||||||
create_uri: str = f"{PREFIX}/create"
|
create_uri: str = f"{PREFIX}/create"
|
||||||
store_uri: str = f"{PREFIX}/store"
|
store_uri: str = f"{PREFIX}/store"
|
||||||
@ -401,13 +401,13 @@ class AccountTestCase(unittest.TestCase):
|
|||||||
"title": stock.title})
|
"title": stock.title})
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.headers["Location"],
|
self.assertEqual(response.headers["Location"],
|
||||||
f"{PREFIX}/{stock.base_code}-067")
|
f"{PREFIX}/{stock.base_code}-003")
|
||||||
|
|
||||||
with self.app.app_context():
|
with self.app.app_context():
|
||||||
self.assertEqual({x.code for x in Account.query.all()},
|
self.assertEqual({x.code for x in Account.query.all()},
|
||||||
{cash.code, bank.code, stock.code,
|
{cash.code, bank.code, stock.code,
|
||||||
f"{stock.base_code}-066",
|
f"{stock.base_code}-002",
|
||||||
f"{stock.base_code}-067"})
|
f"{stock.base_code}-003"})
|
||||||
|
|
||||||
stock_account: Account = Account.find_by_code(stock.code)
|
stock_account: Account = Account.find_by_code(stock.code)
|
||||||
self.assertEqual(stock_account.base_code, stock.base_code)
|
self.assertEqual(stock_account.base_code, stock.base_code)
|
||||||
@ -492,8 +492,8 @@ class AccountTestCase(unittest.TestCase):
|
|||||||
from accounting.models import Account
|
from accounting.models import Account
|
||||||
detail_uri: str = f"{PREFIX}/{cash.code}"
|
detail_uri: str = f"{PREFIX}/{cash.code}"
|
||||||
update_uri: str = f"{PREFIX}/{cash.code}/update"
|
update_uri: str = f"{PREFIX}/{cash.code}/update"
|
||||||
|
cash_account: Account
|
||||||
response: httpx.Response
|
response: httpx.Response
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
response = self.client.post(update_uri,
|
response = self.client.post(update_uri,
|
||||||
data={"csrf_token": self.csrf_token,
|
data={"csrf_token": self.csrf_token,
|
||||||
@ -503,9 +503,12 @@ class AccountTestCase(unittest.TestCase):
|
|||||||
self.assertEqual(response.headers["Location"], detail_uri)
|
self.assertEqual(response.headers["Location"], detail_uri)
|
||||||
|
|
||||||
with self.app.app_context():
|
with self.app.app_context():
|
||||||
cash_account: Account = Account.find_by_code(cash.code)
|
cash_account = Account.find_by_code(cash.code)
|
||||||
self.assertIsNotNone(cash_account)
|
self.assertIsNotNone(cash_account)
|
||||||
self.assertEqual(cash_account.created_at, cash_account.updated_at)
|
cash_account.created_at \
|
||||||
|
= cash_account.created_at - timedelta(seconds=5)
|
||||||
|
cash_account.updated_at = cash_account.created_at
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
response = self.client.post(update_uri,
|
response = self.client.post(update_uri,
|
||||||
data={"csrf_token": self.csrf_token,
|
data={"csrf_token": self.csrf_token,
|
||||||
@ -515,10 +518,10 @@ class AccountTestCase(unittest.TestCase):
|
|||||||
self.assertEqual(response.headers["Location"], detail_uri)
|
self.assertEqual(response.headers["Location"], detail_uri)
|
||||||
|
|
||||||
with self.app.app_context():
|
with self.app.app_context():
|
||||||
cash_account: Account = Account.find_by_code(cash.code)
|
cash_account = Account.find_by_code(cash.code)
|
||||||
self.assertIsNotNone(cash_account)
|
self.assertIsNotNone(cash_account)
|
||||||
self.assertNotEqual(cash_account.created_at,
|
self.assertLess(cash_account.created_at,
|
||||||
cash_account.updated_at)
|
cash_account.updated_at)
|
||||||
|
|
||||||
def test_created_updated_by(self) -> None:
|
def test_created_updated_by(self) -> None:
|
||||||
"""Tests the created-by and updated-by record.
|
"""Tests the created-by and updated-by record.
|
||||||
@ -643,12 +646,62 @@ class AccountTestCase(unittest.TestCase):
|
|||||||
data={"csrf_token": self.csrf_token})
|
data={"csrf_token": self.csrf_token})
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_change_base_code(self) -> None:
|
||||||
|
"""Tests to change the base code of an account.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
from accounting.models import Account
|
||||||
|
response: httpx.Response
|
||||||
|
|
||||||
|
for i in range(2, 6):
|
||||||
|
response = self.client.post(f"{PREFIX}/store",
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": "1111",
|
||||||
|
"title": "Title"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"],
|
||||||
|
f"{PREFIX}/1111-00{i}")
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
account_1: Account = Account.find_by_code("1111-001")
|
||||||
|
id_1: int = account_1.id
|
||||||
|
account_2: Account = Account.find_by_code("1111-002")
|
||||||
|
id_2: int = account_2.id
|
||||||
|
account_3: Account = Account.find_by_code("1111-003")
|
||||||
|
id_3: int = account_3.id
|
||||||
|
account_4: Account = Account.find_by_code("1111-004")
|
||||||
|
id_4: int = account_4.id
|
||||||
|
account_5: Account = Account.find_by_code("1111-005")
|
||||||
|
id_5: int = account_5.id
|
||||||
|
account_1.no = 3
|
||||||
|
account_2.no = 5
|
||||||
|
account_3.no = 8
|
||||||
|
account_4.base_code = "1112"
|
||||||
|
account_4.no = 2
|
||||||
|
account_5.base_code = "1112"
|
||||||
|
account_5.no = 6
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
response = self.client.post(f"{PREFIX}/1111-005/update",
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": "1112",
|
||||||
|
"title": "Title"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], f"{PREFIX}/1112-003")
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
self.assertEqual(db.session.get(Account, id_1).no, 1)
|
||||||
|
self.assertEqual(db.session.get(Account, id_2).no, 3)
|
||||||
|
self.assertEqual(db.session.get(Account, id_3).no, 2)
|
||||||
|
self.assertEqual(db.session.get(Account, id_4).no, 1)
|
||||||
|
self.assertEqual(db.session.get(Account, id_5).no, 2)
|
||||||
|
|
||||||
def test_reorder(self) -> None:
|
def test_reorder(self) -> None:
|
||||||
"""Tests to reorder the accounts under a same base account.
|
"""Tests to reorder the accounts under a same base account.
|
||||||
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
from accounting import db
|
|
||||||
from accounting.models import Account
|
from accounting.models import Account
|
||||||
response: httpx.Response
|
response: httpx.Response
|
||||||
|
|
||||||
@ -671,14 +724,14 @@ class AccountTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
response = self.client.post(f"{PREFIX}/bases/1111",
|
response = self.client.post(f"{PREFIX}/bases/1111",
|
||||||
data={"csrf_token": self.csrf_token,
|
data={"csrf_token": self.csrf_token,
|
||||||
"next": "/next",
|
"next": NEXT_URI,
|
||||||
f"{id_1}-no": "4",
|
f"{id_1}-no": "4",
|
||||||
f"{id_2}-no": "1",
|
f"{id_2}-no": "1",
|
||||||
f"{id_3}-no": "5",
|
f"{id_3}-no": "5",
|
||||||
f"{id_4}-no": "2",
|
f"{id_4}-no": "2",
|
||||||
f"{id_5}-no": "3"})
|
f"{id_5}-no": "3"})
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.headers["Location"], f"/next")
|
self.assertEqual(response.headers["Location"], NEXT_URI)
|
||||||
|
|
||||||
with self.app.app_context():
|
with self.app.app_context():
|
||||||
self.assertEqual(db.session.get(Account, id_1).code, "1111-004")
|
self.assertEqual(db.session.get(Account, id_1).code, "1111-004")
|
||||||
@ -698,12 +751,12 @@ class AccountTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
response = self.client.post(f"{PREFIX}/bases/1111",
|
response = self.client.post(f"{PREFIX}/bases/1111",
|
||||||
data={"csrf_token": self.csrf_token,
|
data={"csrf_token": self.csrf_token,
|
||||||
"next": "/next",
|
"next": NEXT_URI,
|
||||||
f"{id_2}-no": "3a",
|
f"{id_2}-no": "3a",
|
||||||
f"{id_3}-no": "5",
|
f"{id_3}-no": "5",
|
||||||
f"{id_4}-no": "2"})
|
f"{id_4}-no": "2"})
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.headers["Location"], f"/next")
|
self.assertEqual(response.headers["Location"], NEXT_URI)
|
||||||
|
|
||||||
with self.app.app_context():
|
with self.app.app_context():
|
||||||
self.assertEqual(db.session.get(Account, id_1).code, "1111-003")
|
self.assertEqual(db.session.get(Account, id_1).code, "1111-003")
|
||||||
|
@ -29,6 +29,11 @@ from flask.testing import FlaskCliRunner
|
|||||||
from test_site import create_app
|
from test_site import create_app
|
||||||
from testlib import get_client
|
from testlib import get_client
|
||||||
|
|
||||||
|
LIST_URI: str = "/accounting/base-accounts"
|
||||||
|
"""The list URI."""
|
||||||
|
DETAIL_URI: str = "/accounting/base-accounts/1111"
|
||||||
|
"""The detail URI."""
|
||||||
|
|
||||||
|
|
||||||
class BaseAccountCommandTestCase(unittest.TestCase):
|
class BaseAccountCommandTestCase(unittest.TestCase):
|
||||||
"""The base account console command test case."""
|
"""The base account console command test case."""
|
||||||
@ -111,10 +116,10 @@ class BaseAccountTestCase(unittest.TestCase):
|
|||||||
client, csrf_token = get_client(self.app, "nobody")
|
client, csrf_token = get_client(self.app, "nobody")
|
||||||
response: httpx.Response
|
response: httpx.Response
|
||||||
|
|
||||||
response = client.get("/accounting/base-accounts")
|
response = client.get(LIST_URI)
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
response = client.get("/accounting/base-accounts/1111")
|
response = client.get(DETAIL_URI)
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
def test_viewer(self) -> None:
|
def test_viewer(self) -> None:
|
||||||
@ -125,10 +130,10 @@ class BaseAccountTestCase(unittest.TestCase):
|
|||||||
client, csrf_token = get_client(self.app, "viewer")
|
client, csrf_token = get_client(self.app, "viewer")
|
||||||
response: httpx.Response
|
response: httpx.Response
|
||||||
|
|
||||||
response = client.get("/accounting/base-accounts")
|
response = client.get(LIST_URI)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
response = client.get("/accounting/base-accounts/1111")
|
response = client.get(DETAIL_URI)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_editor(self) -> None:
|
def test_editor(self) -> None:
|
||||||
@ -139,8 +144,8 @@ class BaseAccountTestCase(unittest.TestCase):
|
|||||||
client, csrf_token = get_client(self.app, "editor")
|
client, csrf_token = get_client(self.app, "editor")
|
||||||
response: httpx.Response
|
response: httpx.Response
|
||||||
|
|
||||||
response = client.get("/accounting/base-accounts")
|
response = client.get(LIST_URI)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
response = client.get("/accounting/base-accounts/1111")
|
response = client.get(DETAIL_URI)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
@ -18,16 +18,16 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import csv
|
import csv
|
||||||
import time
|
|
||||||
import typing as t
|
import typing as t
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from click.testing import Result
|
from click.testing import Result
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask.testing import FlaskCliRunner
|
from flask.testing import FlaskCliRunner
|
||||||
|
|
||||||
from test_site import create_app
|
from test_site import create_app, db
|
||||||
from testlib import get_client, set_locale
|
from testlib import get_client, set_locale
|
||||||
|
|
||||||
|
|
||||||
@ -71,7 +71,6 @@ class CurrencyCommandTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
runner: FlaskCliRunner = self.app.test_cli_runner()
|
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||||
with self.app.app_context():
|
with self.app.app_context():
|
||||||
from accounting import db
|
|
||||||
from accounting.models import Currency, CurrencyL10n
|
from accounting.models import Currency, CurrencyL10n
|
||||||
result: Result
|
result: Result
|
||||||
result = runner.invoke(args="init-db")
|
result = runner.invoke(args="init-db")
|
||||||
@ -128,7 +127,6 @@ class CurrencyTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
runner: FlaskCliRunner = self.app.test_cli_runner()
|
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||||
with self.app.app_context():
|
with self.app.app_context():
|
||||||
from accounting import db
|
|
||||||
from accounting.models import Currency, CurrencyL10n
|
from accounting.models import Currency, CurrencyL10n
|
||||||
result: Result
|
result: Result
|
||||||
result = runner.invoke(args="init-db")
|
result = runner.invoke(args="init-db")
|
||||||
@ -270,7 +268,6 @@ class CurrencyTestCase(unittest.TestCase):
|
|||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
from accounting.models import Currency
|
from accounting.models import Currency
|
||||||
from test_site import db
|
|
||||||
create_uri: str = f"{PREFIX}/create"
|
create_uri: str = f"{PREFIX}/create"
|
||||||
store_uri: str = f"{PREFIX}/store"
|
store_uri: str = f"{PREFIX}/store"
|
||||||
detail_uri: str = f"{PREFIX}/{zzc.code}"
|
detail_uri: str = f"{PREFIX}/{zzc.code}"
|
||||||
@ -355,7 +352,6 @@ class CurrencyTestCase(unittest.TestCase):
|
|||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
from accounting.models import Currency
|
from accounting.models import Currency
|
||||||
from test_site import db
|
|
||||||
detail_uri: str = f"{PREFIX}/{zza.code}"
|
detail_uri: str = f"{PREFIX}/{zza.code}"
|
||||||
edit_uri: str = f"{PREFIX}/{zza.code}/edit"
|
edit_uri: str = f"{PREFIX}/{zza.code}/edit"
|
||||||
update_uri: str = f"{PREFIX}/{zza.code}/update"
|
update_uri: str = f"{PREFIX}/{zza.code}/update"
|
||||||
@ -435,11 +431,10 @@ class CurrencyTestCase(unittest.TestCase):
|
|||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
from accounting.models import Currency
|
from accounting.models import Currency
|
||||||
from test_site import db
|
|
||||||
detail_uri: str = f"{PREFIX}/{zza.code}"
|
detail_uri: str = f"{PREFIX}/{zza.code}"
|
||||||
update_uri: str = f"{PREFIX}/{zza.code}/update"
|
update_uri: str = f"{PREFIX}/{zza.code}/update"
|
||||||
|
zza_currency: Currency
|
||||||
response: httpx.Response
|
response: httpx.Response
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
response = self.client.post(update_uri,
|
response = self.client.post(update_uri,
|
||||||
data={"csrf_token": self.csrf_token,
|
data={"csrf_token": self.csrf_token,
|
||||||
@ -449,9 +444,12 @@ class CurrencyTestCase(unittest.TestCase):
|
|||||||
self.assertEqual(response.headers["Location"], detail_uri)
|
self.assertEqual(response.headers["Location"], detail_uri)
|
||||||
|
|
||||||
with self.app.app_context():
|
with self.app.app_context():
|
||||||
zza_currency: Currency = db.session.get(Currency, zza.code)
|
zza_currency = db.session.get(Currency, zza.code)
|
||||||
self.assertIsNotNone(zza_currency)
|
self.assertIsNotNone(zza_currency)
|
||||||
self.assertEqual(zza_currency.created_at, zza_currency.updated_at)
|
zza_currency.created_at \
|
||||||
|
= zza_currency.created_at - timedelta(seconds=5)
|
||||||
|
zza_currency.updated_at = zza_currency.created_at
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
response = self.client.post(update_uri,
|
response = self.client.post(update_uri,
|
||||||
data={"csrf_token": self.csrf_token,
|
data={"csrf_token": self.csrf_token,
|
||||||
@ -461,10 +459,10 @@ class CurrencyTestCase(unittest.TestCase):
|
|||||||
self.assertEqual(response.headers["Location"], detail_uri)
|
self.assertEqual(response.headers["Location"], detail_uri)
|
||||||
|
|
||||||
with self.app.app_context():
|
with self.app.app_context():
|
||||||
zza_currency: Currency = db.session.get(Currency, zza.code)
|
zza_currency = db.session.get(Currency, zza.code)
|
||||||
self.assertIsNotNone(zza_currency)
|
self.assertIsNotNone(zza_currency)
|
||||||
self.assertNotEqual(zza_currency.created_at,
|
self.assertLess(zza_currency.created_at,
|
||||||
zza_currency.updated_at)
|
zza_currency.updated_at)
|
||||||
|
|
||||||
def test_created_updated_by(self) -> None:
|
def test_created_updated_by(self) -> None:
|
||||||
"""Tests the created-by and updated-by record.
|
"""Tests the created-by and updated-by record.
|
||||||
@ -472,7 +470,6 @@ class CurrencyTestCase(unittest.TestCase):
|
|||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
from accounting.models import Currency
|
from accounting.models import Currency
|
||||||
from test_site import db
|
|
||||||
editor_username, editor2_username = "editor", "editor2"
|
editor_username, editor2_username = "editor", "editor2"
|
||||||
client, csrf_token = get_client(self.app, editor2_username)
|
client, csrf_token = get_client(self.app, editor2_username)
|
||||||
detail_uri: str = f"{PREFIX}/{zza.code}"
|
detail_uri: str = f"{PREFIX}/{zza.code}"
|
||||||
@ -523,7 +520,6 @@ class CurrencyTestCase(unittest.TestCase):
|
|||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
from accounting.models import Currency
|
from accounting.models import Currency
|
||||||
from test_site import db
|
|
||||||
detail_uri: str = f"{PREFIX}/{zza.code}"
|
detail_uri: str = f"{PREFIX}/{zza.code}"
|
||||||
update_uri: str = f"{PREFIX}/{zza.code}/update"
|
update_uri: str = f"{PREFIX}/{zza.code}/update"
|
||||||
response: httpx.Response
|
response: httpx.Response
|
||||||
|
@ -29,8 +29,8 @@ First written: 2023/1/27
|
|||||||
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.2.1/css/all.min.css">
|
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.2.1/css/all.min.css">
|
||||||
{% block styles %}{% endblock %}
|
{% block styles %}{% endblock %}
|
||||||
<script src="{{ url_for("babel_catalog") }}"></script>
|
<script src="{{ url_for("babel_catalog") }}"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/decimal.js/9.0.0/decimal.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/decimal.js-light@2.5.1/decimal.min.js" integrity="sha384-QdsxGEq4Y0erX8WUIsZJDtfoSSyBF6dmNCnzRNYCa2AOM/xzNsyhHu0RbdFBAm+l" crossorigin="anonymous"></script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
<title>{% block title %}{% endblock %}</title>
|
<title>{% block title %}{% endblock %}</title>
|
||||||
</head>
|
</head>
|
||||||
|
@ -9,8 +9,8 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n"
|
"Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n"
|
||||||
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
|
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
|
||||||
"POT-Creation-Date: 2023-02-06 23:25+0800\n"
|
"POT-Creation-Date: 2023-02-27 10:07+0800\n"
|
||||||
"PO-Revision-Date: 2023-02-06 23:26+0800\n"
|
"PO-Revision-Date: 2023-02-27 10:08+0800\n"
|
||||||
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
|
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
|
||||||
"Language: zh_Hant\n"
|
"Language: zh_Hant\n"
|
||||||
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
|
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
|
||||||
@ -31,7 +31,7 @@ msgstr "首頁"
|
|||||||
|
|
||||||
#: tests/test_site/templates/base.html:68
|
#: tests/test_site/templates/base.html:68
|
||||||
msgid "Log Out"
|
msgid "Log Out"
|
||||||
msgstr ""
|
msgstr "登出"
|
||||||
|
|
||||||
#: tests/test_site/templates/base.html:78
|
#: tests/test_site/templates/base.html:78
|
||||||
#: tests/test_site/templates/login.html:24
|
#: tests/test_site/templates/login.html:24
|
||||||
|
327
tests/test_summary_helper.py
Normal file
327
tests/test_summary_helper.py
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/28
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The test for the summary helper.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import unittest
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from click.testing import Result
|
||||||
|
from flask import Flask
|
||||||
|
from flask.testing import FlaskCliRunner
|
||||||
|
|
||||||
|
from test_site import create_app
|
||||||
|
from testlib import get_client
|
||||||
|
from testlib_txn import Accounts, NEXT_URI, add_txn
|
||||||
|
|
||||||
|
|
||||||
|
class SummeryHelperTestCase(unittest.TestCase):
|
||||||
|
"""The summary helper test case."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
"""Sets up the test.
|
||||||
|
This is run once per test.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.app: Flask = create_app(is_testing=True)
|
||||||
|
|
||||||
|
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||||
|
with self.app.app_context():
|
||||||
|
from accounting.models import BaseAccount, Transaction, \
|
||||||
|
JournalEntry
|
||||||
|
result: Result
|
||||||
|
result = runner.invoke(args="init-db")
|
||||||
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
if BaseAccount.query.first() is None:
|
||||||
|
result = runner.invoke(args="accounting-init-base")
|
||||||
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
result = runner.invoke(args=["accounting-init-currencies",
|
||||||
|
"-u", "editor"])
|
||||||
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
result = runner.invoke(args=["accounting-init-accounts",
|
||||||
|
"-u", "editor"])
|
||||||
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
Transaction.query.delete()
|
||||||
|
JournalEntry.query.delete()
|
||||||
|
|
||||||
|
self.client, self.csrf_token = get_client(self.app, "editor")
|
||||||
|
|
||||||
|
def test_summary_helper(self) -> None:
|
||||||
|
"""Test the summary helper.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
from accounting.transaction.summary_helper import SummaryHelper
|
||||||
|
for form in get_form_data(self.csrf_token):
|
||||||
|
add_txn(self.client, form)
|
||||||
|
with self.app.app_context():
|
||||||
|
helper: SummaryHelper = SummaryHelper()
|
||||||
|
|
||||||
|
# Debit-General
|
||||||
|
self.assertEqual(len(helper.debit.general.tags), 2)
|
||||||
|
self.assertEqual(helper.debit.general.tags[0].name, "Lunch")
|
||||||
|
self.assertEqual(len(helper.debit.general.tags[0].accounts), 2)
|
||||||
|
self.assertEqual(helper.debit.general.tags[0].accounts[0].code,
|
||||||
|
Accounts.MEAL)
|
||||||
|
self.assertEqual(helper.debit.general.tags[0].accounts[1].code,
|
||||||
|
Accounts.PAYABLE)
|
||||||
|
self.assertEqual(helper.debit.general.tags[1].name, "Dinner")
|
||||||
|
self.assertEqual(len(helper.debit.general.tags[1].accounts), 2)
|
||||||
|
self.assertEqual(helper.debit.general.tags[1].accounts[0].code,
|
||||||
|
Accounts.MEAL)
|
||||||
|
self.assertEqual(helper.debit.general.tags[1].accounts[1].code,
|
||||||
|
Accounts.PAYABLE)
|
||||||
|
|
||||||
|
# Debit-Travel
|
||||||
|
self.assertEqual(len(helper.debit.travel.tags), 3)
|
||||||
|
self.assertEqual(helper.debit.travel.tags[0].name, "Bike")
|
||||||
|
self.assertEqual(len(helper.debit.travel.tags[0].accounts), 1)
|
||||||
|
self.assertEqual(helper.debit.travel.tags[0].accounts[0].code,
|
||||||
|
Accounts.TRAVEL)
|
||||||
|
self.assertEqual(helper.debit.travel.tags[1].name, "Taxi")
|
||||||
|
self.assertEqual(len(helper.debit.travel.tags[1].accounts), 1)
|
||||||
|
self.assertEqual(helper.debit.travel.tags[1].accounts[0].code,
|
||||||
|
Accounts.TRAVEL)
|
||||||
|
self.assertEqual(helper.debit.travel.tags[2].name, "Airplane")
|
||||||
|
self.assertEqual(len(helper.debit.travel.tags[2].accounts), 1)
|
||||||
|
self.assertEqual(helper.debit.travel.tags[2].accounts[0].code,
|
||||||
|
Accounts.TRAVEL)
|
||||||
|
|
||||||
|
# Debit-Bus
|
||||||
|
self.assertEqual(len(helper.debit.bus.tags), 2)
|
||||||
|
self.assertEqual(helper.debit.bus.tags[0].name, "Train")
|
||||||
|
self.assertEqual(len(helper.debit.bus.tags[0].accounts), 1)
|
||||||
|
self.assertEqual(helper.debit.bus.tags[0].accounts[0].code,
|
||||||
|
Accounts.TRAVEL)
|
||||||
|
self.assertEqual(helper.debit.bus.tags[1].name, "Bus")
|
||||||
|
self.assertEqual(len(helper.debit.bus.tags[1].accounts), 1)
|
||||||
|
self.assertEqual(helper.debit.bus.tags[1].accounts[0].code,
|
||||||
|
Accounts.TRAVEL)
|
||||||
|
|
||||||
|
# Credit-General
|
||||||
|
self.assertEqual(len(helper.credit.general.tags), 2)
|
||||||
|
self.assertEqual(helper.credit.general.tags[0].name, "Lunch")
|
||||||
|
self.assertEqual(len(helper.credit.general.tags[0].accounts), 3)
|
||||||
|
self.assertEqual(helper.credit.general.tags[0].accounts[0].code,
|
||||||
|
Accounts.PAYABLE)
|
||||||
|
self.assertEqual(helper.credit.general.tags[0].accounts[1].code,
|
||||||
|
Accounts.BANK)
|
||||||
|
self.assertEqual(helper.credit.general.tags[0].accounts[2].code,
|
||||||
|
Accounts.CASH)
|
||||||
|
self.assertEqual(helper.credit.general.tags[1].name, "Dinner")
|
||||||
|
self.assertEqual(len(helper.credit.general.tags[1].accounts), 2)
|
||||||
|
self.assertEqual(helper.credit.general.tags[1].accounts[0].code,
|
||||||
|
Accounts.BANK)
|
||||||
|
self.assertEqual(helper.credit.general.tags[1].accounts[1].code,
|
||||||
|
Accounts.PAYABLE)
|
||||||
|
|
||||||
|
# Credit-Travel
|
||||||
|
self.assertEqual(len(helper.credit.travel.tags), 2)
|
||||||
|
self.assertEqual(helper.credit.travel.tags[0].name, "Bike")
|
||||||
|
self.assertEqual(len(helper.credit.travel.tags[0].accounts), 2)
|
||||||
|
self.assertEqual(helper.credit.travel.tags[0].accounts[0].code,
|
||||||
|
Accounts.PAYABLE)
|
||||||
|
self.assertEqual(helper.credit.travel.tags[0].accounts[1].code,
|
||||||
|
Accounts.PREPAID)
|
||||||
|
self.assertEqual(helper.credit.travel.tags[1].name, "Taxi")
|
||||||
|
self.assertEqual(len(helper.credit.travel.tags[1].accounts), 2)
|
||||||
|
self.assertEqual(helper.credit.travel.tags[1].accounts[0].code,
|
||||||
|
Accounts.PAYABLE)
|
||||||
|
self.assertEqual(helper.credit.travel.tags[1].accounts[1].code,
|
||||||
|
Accounts.CASH)
|
||||||
|
|
||||||
|
# Credit-Bus
|
||||||
|
self.assertEqual(len(helper.credit.bus.tags), 2)
|
||||||
|
self.assertEqual(helper.credit.bus.tags[0].name, "Train")
|
||||||
|
self.assertEqual(len(helper.credit.bus.tags[0].accounts), 2)
|
||||||
|
self.assertEqual(helper.credit.bus.tags[0].accounts[0].code,
|
||||||
|
Accounts.PREPAID)
|
||||||
|
self.assertEqual(helper.credit.bus.tags[0].accounts[1].code,
|
||||||
|
Accounts.PAYABLE)
|
||||||
|
self.assertEqual(helper.credit.bus.tags[1].name, "Bus")
|
||||||
|
self.assertEqual(len(helper.credit.bus.tags[1].accounts), 1)
|
||||||
|
self.assertEqual(helper.credit.bus.tags[1].accounts[0].code,
|
||||||
|
Accounts.PREPAID)
|
||||||
|
|
||||||
|
|
||||||
|
def get_form_data(csrf_token: str) -> list[dict[str, str]]:
|
||||||
|
"""Returns the form data for multiple transaction forms.
|
||||||
|
|
||||||
|
:param csrf_token: The CSRF token.
|
||||||
|
:return: A list of the form data.
|
||||||
|
"""
|
||||||
|
txn_date: str = date.today().isoformat()
|
||||||
|
return [{"csrf_token": csrf_token,
|
||||||
|
"next": NEXT_URI,
|
||||||
|
"date": txn_date,
|
||||||
|
"currency-0-code": "USD",
|
||||||
|
"currency-0-credit-0-account_code": Accounts.SERVICE,
|
||||||
|
"currency-0-credit-0-summary": " Salary ",
|
||||||
|
"currency-0-credit-0-amount": "2500"},
|
||||||
|
{"csrf_token": csrf_token,
|
||||||
|
"next": NEXT_URI,
|
||||||
|
"date": txn_date,
|
||||||
|
"currency-0-code": "USD",
|
||||||
|
"currency-0-debit-0-account_code": Accounts.MEAL,
|
||||||
|
"currency-0-debit-0-summary": " Lunch—Fish ",
|
||||||
|
"currency-0-debit-0-amount": "4.7",
|
||||||
|
"currency-0-credit-0-account_code": Accounts.BANK,
|
||||||
|
"currency-0-credit-0-summary": " Lunch—Fish ",
|
||||||
|
"currency-0-credit-0-amount": "4.7",
|
||||||
|
"currency-0-debit-1-account_code": Accounts.MEAL,
|
||||||
|
"currency-0-debit-1-summary": " Lunch—Fries ",
|
||||||
|
"currency-0-debit-1-amount": "2.15",
|
||||||
|
"currency-0-credit-1-account_code": Accounts.PAYABLE,
|
||||||
|
"currency-0-credit-1-summary": " Lunch—Fries ",
|
||||||
|
"currency-0-credit-1-amount": "2.15",
|
||||||
|
"currency-0-debit-2-account_code": Accounts.MEAL,
|
||||||
|
"currency-0-debit-2-summary": " Dinner—Hamburger ",
|
||||||
|
"currency-0-debit-2-amount": "4.25",
|
||||||
|
"currency-0-credit-2-account_code": Accounts.BANK,
|
||||||
|
"currency-0-credit-2-summary": " Dinner—Hamburger ",
|
||||||
|
"currency-0-credit-2-amount": "4.25"},
|
||||||
|
{"csrf_token": csrf_token,
|
||||||
|
"next": NEXT_URI,
|
||||||
|
"date": txn_date,
|
||||||
|
"currency-0-code": "USD",
|
||||||
|
"currency-0-debit-0-account_code": Accounts.MEAL,
|
||||||
|
"currency-0-debit-0-summary": " Lunch—Salad ",
|
||||||
|
"currency-0-debit-0-amount": "4.99",
|
||||||
|
"currency-0-credit-0-account_code": Accounts.CASH,
|
||||||
|
"currency-0-credit-0-summary": " Lunch—Salad ",
|
||||||
|
"currency-0-credit-0-amount": "4.99",
|
||||||
|
"currency-0-debit-1-account_code": Accounts.MEAL,
|
||||||
|
"currency-0-debit-1-summary": " Dinner—Steak ",
|
||||||
|
"currency-0-debit-1-amount": "8.28",
|
||||||
|
"currency-0-credit-1-account_code": Accounts.PAYABLE,
|
||||||
|
"currency-0-credit-1-summary": " Dinner—Steak ",
|
||||||
|
"currency-0-credit-1-amount": "8.28"},
|
||||||
|
{"csrf_token": csrf_token,
|
||||||
|
"next": NEXT_URI,
|
||||||
|
"date": txn_date,
|
||||||
|
"currency-0-code": "USD",
|
||||||
|
"currency-0-debit-0-account_code": Accounts.MEAL,
|
||||||
|
"currency-0-debit-0-summary": " Lunch—Pizza ",
|
||||||
|
"currency-0-debit-0-amount": "5.49",
|
||||||
|
"currency-0-credit-0-account_code": Accounts.PAYABLE,
|
||||||
|
"currency-0-credit-0-summary": " Lunch—Pizza ",
|
||||||
|
"currency-0-credit-0-amount": "5.49",
|
||||||
|
"currency-0-debit-1-account_code": Accounts.MEAL,
|
||||||
|
"currency-0-debit-1-summary": " Lunch—Noodles ",
|
||||||
|
"currency-0-debit-1-amount": "7.47",
|
||||||
|
"currency-0-credit-1-account_code": Accounts.PAYABLE,
|
||||||
|
"currency-0-credit-1-summary": " Lunch—Noodles ",
|
||||||
|
"currency-0-credit-1-amount": "7.47"},
|
||||||
|
{"csrf_token": csrf_token,
|
||||||
|
"next": NEXT_URI,
|
||||||
|
"date": txn_date,
|
||||||
|
"currency-0-code": "USD",
|
||||||
|
"currency-0-debit-0-account_code": Accounts.TRAVEL,
|
||||||
|
"currency-0-debit-0-summary": " Airplane—Lake City↔Hill Town ",
|
||||||
|
"currency-0-debit-0-amount": "800"},
|
||||||
|
{"csrf_token": csrf_token,
|
||||||
|
"next": NEXT_URI,
|
||||||
|
"date": txn_date,
|
||||||
|
"currency-0-code": "USD",
|
||||||
|
"currency-0-debit-0-account_code": Accounts.TRAVEL,
|
||||||
|
"currency-0-debit-0-summary": " Bus—323—Downtown→Museum ",
|
||||||
|
"currency-0-debit-0-amount": "2.5",
|
||||||
|
"currency-0-credit-0-account_code": Accounts.PREPAID,
|
||||||
|
"currency-0-credit-0-summary": " Bus—323—Downtown→Museum ",
|
||||||
|
"currency-0-credit-0-amount": "2.5",
|
||||||
|
"currency-0-debit-1-account_code": Accounts.TRAVEL,
|
||||||
|
"currency-0-debit-1-summary": " Train—Blue—Museum→Central ",
|
||||||
|
"currency-0-debit-1-amount": "3.2",
|
||||||
|
"currency-0-credit-1-account_code": Accounts.PREPAID,
|
||||||
|
"currency-0-credit-1-summary": " Train—Blue—Museum→Central ",
|
||||||
|
"currency-0-credit-1-amount": "3.2",
|
||||||
|
"currency-0-debit-2-account_code": Accounts.TRAVEL,
|
||||||
|
"currency-0-debit-2-summary": " Train—Green—Central→Mall ",
|
||||||
|
"currency-0-debit-2-amount": "3.2",
|
||||||
|
"currency-0-credit-2-account_code": Accounts.PREPAID,
|
||||||
|
"currency-0-credit-2-summary": " Train—Green—Central→Mall ",
|
||||||
|
"currency-0-credit-2-amount": "3.2",
|
||||||
|
"currency-0-debit-3-account_code": Accounts.TRAVEL,
|
||||||
|
"currency-0-debit-3-summary": " Train—Red—Mall→Museum ",
|
||||||
|
"currency-0-debit-3-amount": "4.4",
|
||||||
|
"currency-0-credit-3-account_code": Accounts.PAYABLE,
|
||||||
|
"currency-0-credit-3-summary": " Train—Red—Mall→Museum ",
|
||||||
|
"currency-0-credit-3-amount": "4.4"},
|
||||||
|
{"csrf_token": csrf_token,
|
||||||
|
"next": NEXT_URI,
|
||||||
|
"date": txn_date,
|
||||||
|
"currency-0-code": "USD",
|
||||||
|
"currency-0-debit-0-account_code": Accounts.TRAVEL,
|
||||||
|
"currency-0-debit-0-summary": " Taxi—Museum→Office ",
|
||||||
|
"currency-0-debit-0-amount": "15.5",
|
||||||
|
"currency-0-credit-0-account_code": Accounts.CASH,
|
||||||
|
"currency-0-credit-0-summary": " Taxi—Museum→Office ",
|
||||||
|
"currency-0-credit-0-amount": "15.5",
|
||||||
|
"currency-0-debit-1-account_code": Accounts.TRAVEL,
|
||||||
|
"currency-0-debit-1-summary": " Taxi—Office→Restaurant ",
|
||||||
|
"currency-0-debit-1-amount": "12",
|
||||||
|
"currency-0-credit-1-account_code": Accounts.PAYABLE,
|
||||||
|
"currency-0-credit-1-summary": " Taxi—Office→Restaurant ",
|
||||||
|
"currency-0-credit-1-amount": "12",
|
||||||
|
"currency-0-debit-2-account_code": Accounts.TRAVEL,
|
||||||
|
"currency-0-debit-2-summary": " Taxi—Restaurant→City Hall ",
|
||||||
|
"currency-0-debit-2-amount": "8",
|
||||||
|
"currency-0-credit-2-account_code": Accounts.PAYABLE,
|
||||||
|
"currency-0-credit-2-summary": " Taxi—Restaurant→City Hall ",
|
||||||
|
"currency-0-credit-2-amount": "8",
|
||||||
|
"currency-0-debit-3-account_code": Accounts.TRAVEL,
|
||||||
|
"currency-0-debit-3-summary": " Bike—City Hall→Office ",
|
||||||
|
"currency-0-debit-3-amount": "3.5",
|
||||||
|
"currency-0-credit-3-account_code": Accounts.PAYABLE,
|
||||||
|
"currency-0-credit-3-summary": " Bike—City Hall→Office ",
|
||||||
|
"currency-0-credit-3-amount": "3.5",
|
||||||
|
"currency-0-debit-4-account_code": Accounts.TRAVEL,
|
||||||
|
"currency-0-debit-4-summary": " Bike—Restaurant→Office ",
|
||||||
|
"currency-0-debit-4-amount": "4",
|
||||||
|
"currency-0-credit-4-account_code": Accounts.PAYABLE,
|
||||||
|
"currency-0-credit-4-summary": " Bike—Restaurant→Office ",
|
||||||
|
"currency-0-credit-4-amount": "4",
|
||||||
|
"currency-0-debit-5-account_code": Accounts.TRAVEL,
|
||||||
|
"currency-0-debit-5-summary": " Bike—Office→Theatre ",
|
||||||
|
"currency-0-debit-5-amount": "1.5",
|
||||||
|
"currency-0-credit-5-account_code": Accounts.PAYABLE,
|
||||||
|
"currency-0-credit-5-summary": " Bike—Office→Theatre ",
|
||||||
|
"currency-0-credit-5-amount": "1.5",
|
||||||
|
"currency-0-debit-6-account_code": Accounts.TRAVEL,
|
||||||
|
"currency-0-debit-6-summary": " Bike—Theatre→Home ",
|
||||||
|
"currency-0-debit-6-amount": "5.5",
|
||||||
|
"currency-0-credit-6-account_code": Accounts.PREPAID,
|
||||||
|
"currency-0-credit-6-summary": " Bike—Theatre→Home ",
|
||||||
|
"currency-0-credit-6-amount": "5.5"},
|
||||||
|
{"csrf_token": csrf_token,
|
||||||
|
"next": NEXT_URI,
|
||||||
|
"date": txn_date,
|
||||||
|
"currency-0-code": "USD",
|
||||||
|
"currency-0-debit-0-account_code": Accounts.PAYABLE,
|
||||||
|
"currency-0-debit-0-summary": " Dinner—Steak ",
|
||||||
|
"currency-0-debit-0-amount": "8.28",
|
||||||
|
"currency-0-credit-0-account_code": Accounts.BANK,
|
||||||
|
"currency-0-credit-0-summary": " Dinner—Steak ",
|
||||||
|
"currency-0-credit-0-amount": "8.28",
|
||||||
|
"currency-0-debit-1-account_code": Accounts.PAYABLE,
|
||||||
|
"currency-0-debit-1-summary": " Lunch—Pizza ",
|
||||||
|
"currency-0-debit-1-amount": "5.49",
|
||||||
|
"currency-0-credit-1-account_code": Accounts.BANK,
|
||||||
|
"currency-0-credit-1-summary": " Lunch—Pizza ",
|
||||||
|
"currency-0-credit-1-amount": "5.49"}]
|
||||||
|
|
2167
tests/test_transaction.py
Normal file
2167
tests/test_transaction.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -21,68 +21,90 @@ import unittest
|
|||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from flask import Flask, request
|
from flask import Flask, request, render_template_string
|
||||||
|
|
||||||
from accounting.utils.next_url import append_next, inherit_next, or_next
|
from accounting.utils.next_uri import append_next, inherit_next, or_next
|
||||||
from accounting.utils.pagination import Pagination, DEFAULT_PAGE_SIZE
|
from accounting.utils.pagination import Pagination, DEFAULT_PAGE_SIZE
|
||||||
from accounting.utils.query import parse_query_keywords
|
from accounting.utils.query import parse_query_keywords
|
||||||
from test_site import create_app, csrf
|
from test_site import create_app
|
||||||
|
from testlib import TEST_SERVER
|
||||||
|
|
||||||
|
|
||||||
class NextUriTestCase(unittest.TestCase):
|
class NextUriTestCase(unittest.TestCase):
|
||||||
"""The test case for the next URI utilities."""
|
"""The test case for the next URI utilities."""
|
||||||
|
TARGET: str = "/target"
|
||||||
|
|
||||||
def test_next_uri(self) -> None:
|
def setUp(self) -> None:
|
||||||
"""Tests the next URI utilities.
|
"""Sets up the test.
|
||||||
|
This is run once per test.
|
||||||
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
app: Flask = create_app(is_testing=True)
|
self.app: Flask = create_app(is_testing=True)
|
||||||
target: str = "/target"
|
|
||||||
|
|
||||||
@app.route("/test-next", methods=["GET", "POST"])
|
@self.app.get("/test-csrf")
|
||||||
@csrf.exempt
|
def test_csrf() -> str:
|
||||||
def test_next_view() -> str:
|
"""The test view to return the CSRF token."""
|
||||||
|
return render_template_string("{{csrf_token()}}")
|
||||||
|
|
||||||
|
def test_next_uri(self) -> None:
|
||||||
|
"""Tests the next URI utilities with the next URI.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
def test_next_uri_view() -> str:
|
||||||
"""The test view with the next URI."""
|
"""The test view with the next URI."""
|
||||||
current_uri: str = request.full_path if request.query_string \
|
current_uri: str = request.full_path if request.query_string \
|
||||||
else request.path
|
else request.path
|
||||||
self.assertEqual(append_next(target),
|
self.assertEqual(append_next(self.TARGET),
|
||||||
f"{target}?next={quote_plus(current_uri)}")
|
f"{self.TARGET}?next={quote_plus(current_uri)}")
|
||||||
next_uri: str = request.form["next"] if request.method == "POST" \
|
next_uri: str = request.form["next"] if request.method == "POST" \
|
||||||
else request.args["next"]
|
else request.args["next"]
|
||||||
self.assertEqual(inherit_next(target),
|
self.assertEqual(inherit_next(self.TARGET),
|
||||||
f"{target}?next={quote_plus(next_uri)}")
|
f"{self.TARGET}?next={quote_plus(next_uri)}")
|
||||||
self.assertEqual(or_next(target), next_uri)
|
self.assertEqual(or_next(self.TARGET), next_uri)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@app.route("/test-no-next", methods=["GET", "POST"])
|
self.app.add_url_rule("/test-next", view_func=test_next_uri_view,
|
||||||
@csrf.exempt
|
methods=["GET", "POST"])
|
||||||
def test_no_next_view() -> str:
|
client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER)
|
||||||
"""The test view without the next URI."""
|
client.headers["Referer"] = TEST_SERVER
|
||||||
current_uri: str = request.full_path if request.query_string \
|
csrf_token: str = client.get("/test-csrf").text
|
||||||
else request.path
|
|
||||||
self.assertEqual(append_next(target),
|
|
||||||
f"{target}?next={quote_plus(current_uri)}")
|
|
||||||
self.assertEqual(inherit_next(target), target)
|
|
||||||
self.assertEqual(or_next(target), target)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
client: httpx.Client = httpx.Client(app=app,
|
|
||||||
base_url="https://testserver")
|
|
||||||
client.headers["Referer"] = "https://testserver"
|
|
||||||
response: httpx.Response
|
response: httpx.Response
|
||||||
|
|
||||||
# With the next URI
|
|
||||||
response = client.get("/test-next?next=/next&q=abc&page-no=4")
|
response = client.get("/test-next?next=/next&q=abc&page-no=4")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
response = client.post("/test-next", data={"next": "/next",
|
response = client.post("/test-next", data={"csrf_token": csrf_token,
|
||||||
|
"next": "/next",
|
||||||
"name": "viewer"})
|
"name": "viewer"})
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
# Without the next URI
|
def test_no_next_uri(self) -> None:
|
||||||
|
"""Tests the next URI utilities without the next URI.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
def test_no_next_uri_view() -> str:
|
||||||
|
"""The test view without the next URI."""
|
||||||
|
current_uri: str = request.full_path if request.query_string \
|
||||||
|
else request.path
|
||||||
|
self.assertEqual(append_next(self.TARGET),
|
||||||
|
f"{self.TARGET}?next={quote_plus(current_uri)}")
|
||||||
|
self.assertEqual(inherit_next(self.TARGET), self.TARGET)
|
||||||
|
self.assertEqual(or_next(self.TARGET), self.TARGET)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
self.app.add_url_rule("/test-no-next", view_func=test_no_next_uri_view,
|
||||||
|
methods=["GET", "POST"])
|
||||||
|
client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER)
|
||||||
|
client.headers["Referer"] = TEST_SERVER
|
||||||
|
csrf_token: str = client.get("/test-csrf").text
|
||||||
|
response: httpx.Response
|
||||||
|
|
||||||
response = client.get("/test-no-next?q=abc&page-no=4")
|
response = client.get("/test-no-next?q=abc&page-no=4")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
response = client.post("/test-no-next", data={"name": "viewer"})
|
response = client.post("/test-no-next", data={"csrf_token": csrf_token,
|
||||||
|
"name": "viewer"})
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
@ -165,8 +187,8 @@ class PaginationTestCase(unittest.TestCase):
|
|||||||
self.assertEqual(pagination.list, self.params.result)
|
self.assertEqual(pagination.list, self.params.result)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
self.client = httpx.Client(app=self.app, base_url="https://testserver")
|
self.client = httpx.Client(app=self.app, base_url=TEST_SERVER)
|
||||||
self.client.headers["Referer"] = "https://testserver"
|
self.client.headers["Referer"] = TEST_SERVER
|
||||||
|
|
||||||
def __test_success(self, query: str, items: range,
|
def __test_success(self, query: str, items: range,
|
||||||
result: range, is_paged: bool = True,
|
result: range, is_paged: bool = True,
|
||||||
@ -271,25 +293,28 @@ class PaginationTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
|
page_37: str = "q=word&page-no=37&next=%2F"
|
||||||
|
page_size_15_default: str = "q=word&page-size=15&next=%2F"
|
||||||
|
|
||||||
# A malformed page size
|
# A malformed page size
|
||||||
self.__test_malformed("q=word&page-size=100a&page-no=37&next=%2F",
|
self.__test_malformed("q=word&page-size=100a&page-no=37&next=%2F",
|
||||||
range(1, 691), "q=word&page-no=37&next=%2F")
|
range(1, 691), page_37)
|
||||||
# A default page size
|
# A default page size
|
||||||
self.__test_malformed(f"q=word&page-size={DEFAULT_PAGE_SIZE}"
|
self.__test_malformed(f"q=word&page-size={DEFAULT_PAGE_SIZE}"
|
||||||
"&page-no=37&next=%2F",
|
"&page-no=37&next=%2F",
|
||||||
range(1, 691), "q=word&page-no=37&next=%2F")
|
range(1, 691), page_37)
|
||||||
# An invalid page size
|
# An invalid page size
|
||||||
self.__test_malformed("q=word&page-size=0&page-no=37&next=%2F",
|
self.__test_malformed("q=word&page-size=0&page-no=37&next=%2F",
|
||||||
range(1, 691), "q=word&page-no=37&next=%2F")
|
range(1, 691), page_37)
|
||||||
# A malformed page number
|
# A malformed page number
|
||||||
self.__test_malformed("q=word&page-size=15&page-no=37a&next=%2F",
|
self.__test_malformed("q=word&page-size=15&page-no=37a&next=%2F",
|
||||||
range(1, 691), "q=word&page-size=15&next=%2F")
|
range(1, 691), page_size_15_default)
|
||||||
# A default page number
|
# A default page number
|
||||||
self.__test_malformed("q=word&page-size=15&page-no=1&next=%2F",
|
self.__test_malformed("q=word&page-size=15&page-no=1&next=%2F",
|
||||||
range(1, 691), "q=word&page-size=15&next=%2F")
|
range(1, 691), page_size_15_default)
|
||||||
# A default page number, on a reversed list
|
# A default page number, on a reversed list
|
||||||
self.__test_malformed("q=word&page-size=15&page-no=46&next=%2F",
|
self.__test_malformed("q=word&page-size=15&page-no=46&next=%2F",
|
||||||
range(1, 691), "q=word&page-size=15&next=%2F",
|
range(1, 691), page_size_15_default,
|
||||||
is_reversed=True)
|
is_reversed=True)
|
||||||
# A page number beyond the last page
|
# A page number beyond the last page
|
||||||
self.__test_malformed("q=word&page-size=15&page-no=100&next=%2F",
|
self.__test_malformed("q=word&page-size=15&page-no=100&next=%2F",
|
||||||
@ -298,11 +323,11 @@ class PaginationTestCase(unittest.TestCase):
|
|||||||
# A page number beyond the last page, on a reversed list
|
# A page number beyond the last page, on a reversed list
|
||||||
self.__test_malformed("q=word&page-size=15&page-no=100&next=%2F",
|
self.__test_malformed("q=word&page-size=15&page-no=100&next=%2F",
|
||||||
range(1, 691),
|
range(1, 691),
|
||||||
"q=word&page-size=15&next=%2F", is_reversed=True)
|
page_size_15_default, is_reversed=True)
|
||||||
# A page number before the first page
|
# A page number before the first page
|
||||||
self.__test_malformed("q=word&page-size=15&page-no=0&next=%2F",
|
self.__test_malformed("q=word&page-size=15&page-no=0&next=%2F",
|
||||||
range(1, 691),
|
range(1, 691),
|
||||||
"q=word&page-size=15&next=%2F")
|
page_size_15_default)
|
||||||
# A page number before the first page, on a reversed list
|
# A page number before the first page, on a reversed list
|
||||||
self.__test_malformed("q=word&page-size=15&page-no=0&next=%2F",
|
self.__test_malformed("q=word&page-size=15&page-no=0&next=%2F",
|
||||||
range(1, 691),
|
range(1, 691),
|
||||||
|
@ -23,6 +23,9 @@ from html.parser import HTMLParser
|
|||||||
import httpx
|
import httpx
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
|
TEST_SERVER: str = "https://testserver"
|
||||||
|
"""The test server URI."""
|
||||||
|
|
||||||
|
|
||||||
def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]:
|
def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]:
|
||||||
"""Returns a user client.
|
"""Returns a user client.
|
||||||
@ -31,8 +34,8 @@ def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]:
|
|||||||
:param username: The username.
|
:param username: The username.
|
||||||
:return: A tuple of the client and the CSRF token.
|
:return: A tuple of the client and the CSRF token.
|
||||||
"""
|
"""
|
||||||
client: httpx.Client = httpx.Client(app=app, base_url="https://testserver")
|
client: httpx.Client = httpx.Client(app=app, base_url=TEST_SERVER)
|
||||||
client.headers["Referer"] = "https://testserver"
|
client.headers["Referer"] = TEST_SERVER
|
||||||
csrf_token: str = get_csrf_token(client, "/login")
|
csrf_token: str = get_csrf_token(client, "/login")
|
||||||
response: httpx.Response = client.post("/login",
|
response: httpx.Response = client.post("/login",
|
||||||
data={"csrf_token": csrf_token,
|
data={"csrf_token": csrf_token,
|
||||||
|
466
tests/testlib_txn.py
Normal file
466
tests/testlib_txn.py
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The common test libraries for the transaction test cases.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from decimal import Decimal
|
||||||
|
from datetime import date
|
||||||
|
from secrets import randbelow
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from test_site import db
|
||||||
|
|
||||||
|
NEXT_URI: str = "/_next"
|
||||||
|
"""The next URI."""
|
||||||
|
NON_EMPTY_NOTE: str = " This is \n\na test."
|
||||||
|
"""The stripped content of an non-empty note."""
|
||||||
|
EMPTY_NOTE: str = " \n\n "
|
||||||
|
"""The empty note content."""
|
||||||
|
|
||||||
|
|
||||||
|
class Accounts:
|
||||||
|
"""The shortcuts to the common accounts."""
|
||||||
|
CASH: str = "1111-001"
|
||||||
|
BANK: str = "1113-001"
|
||||||
|
PREPAID: str = "1258-001"
|
||||||
|
PAYABLE: str = "2141-001"
|
||||||
|
SALES: str = "4111-001"
|
||||||
|
SERVICE: str = "4611-001"
|
||||||
|
AGENCY: str = "4711-001"
|
||||||
|
OFFICE: str = "6153-001"
|
||||||
|
TRAVEL: str = "6154-001"
|
||||||
|
MEAL: str = "6172-001"
|
||||||
|
INTEREST: str = "4111-001"
|
||||||
|
DONATION: str = "7481-001"
|
||||||
|
RENT: str = "7482-001"
|
||||||
|
|
||||||
|
|
||||||
|
def get_add_form(csrf_token: str) -> dict[str, str]:
|
||||||
|
"""Returns the form data to add a new transaction.
|
||||||
|
|
||||||
|
:param csrf_token: The CSRF token.
|
||||||
|
:return: The form data to add a new transaction.
|
||||||
|
"""
|
||||||
|
return {"csrf_token": csrf_token,
|
||||||
|
"next": NEXT_URI,
|
||||||
|
"date": date.today().isoformat(),
|
||||||
|
"currency-0-code": "USD",
|
||||||
|
"currency-0-debit-0-no": "16",
|
||||||
|
"currency-0-debit-0-account_code": Accounts.CASH,
|
||||||
|
"currency-0-debit-0-summary": " ",
|
||||||
|
"currency-0-debit-0-amount": " 495.26 ",
|
||||||
|
"currency-0-debit-6-no": "2",
|
||||||
|
"currency-0-debit-6-account_code": Accounts.BANK,
|
||||||
|
"currency-0-debit-6-summary": " Deposit ",
|
||||||
|
"currency-0-debit-6-amount": "6000",
|
||||||
|
"currency-0-debit-12-no": "2",
|
||||||
|
"currency-0-debit-12-account_code": Accounts.OFFICE,
|
||||||
|
"currency-0-debit-12-summary": " Pens ",
|
||||||
|
"currency-0-debit-12-amount": "4.99",
|
||||||
|
"currency-0-credit-2-no": "6",
|
||||||
|
"currency-0-credit-2-account_code": Accounts.SERVICE,
|
||||||
|
"currency-0-credit-2-summary": " ",
|
||||||
|
"currency-0-credit-2-amount": "5500",
|
||||||
|
"currency-0-credit-7-account_code": Accounts.SALES,
|
||||||
|
"currency-0-credit-7-summary": " ",
|
||||||
|
"currency-0-credit-7-amount": "950",
|
||||||
|
"currency-0-credit-27-account_code": Accounts.INTEREST,
|
||||||
|
"currency-0-credit-27-summary": " ",
|
||||||
|
"currency-0-credit-27-amount": "50.25",
|
||||||
|
"currency-3-no": "2",
|
||||||
|
"currency-3-code": "JPY",
|
||||||
|
"currency-3-debit-2-no": "2",
|
||||||
|
"currency-3-debit-2-account_code": Accounts.CASH,
|
||||||
|
"currency-3-debit-2-summary": " ",
|
||||||
|
"currency-3-debit-2-amount": "15000",
|
||||||
|
"currency-3-debit-9-no": "5",
|
||||||
|
"currency-3-debit-9-account_code": Accounts.BANK,
|
||||||
|
"currency-3-debit-9-summary": " Deposit ",
|
||||||
|
"currency-3-debit-9-amount": "95000",
|
||||||
|
"currency-3-credit-3-account_code": Accounts.AGENCY,
|
||||||
|
"currency-3-credit-3-summary": " Realtor ",
|
||||||
|
"currency-3-credit-3-amount": "65000",
|
||||||
|
"currency-3-credit-5-no": "4",
|
||||||
|
"currency-3-credit-5-account_code": Accounts.DONATION,
|
||||||
|
"currency-3-credit-5-summary": " Donation ",
|
||||||
|
"currency-3-credit-5-amount": "45000",
|
||||||
|
"currency-16-code": "TWD",
|
||||||
|
"currency-16-debit-2-no": "2",
|
||||||
|
"currency-16-debit-2-account_code": Accounts.CASH,
|
||||||
|
"currency-16-debit-2-summary": " ",
|
||||||
|
"currency-16-debit-2-amount": "10000",
|
||||||
|
"currency-16-debit-9-no": "2",
|
||||||
|
"currency-16-debit-9-account_code": Accounts.TRAVEL,
|
||||||
|
"currency-16-debit-9-summary": " Gas ",
|
||||||
|
"currency-16-debit-9-amount": "30000",
|
||||||
|
"currency-16-credit-6-no": "6",
|
||||||
|
"currency-16-credit-6-account_code": Accounts.RENT,
|
||||||
|
"currency-16-credit-6-summary": " Rent ",
|
||||||
|
"currency-16-credit-6-amount": "35000",
|
||||||
|
"currency-16-credit-9-account_code": Accounts.DONATION,
|
||||||
|
"currency-16-credit-9-summary": " Donation ",
|
||||||
|
"currency-16-credit-9-amount": "5000",
|
||||||
|
"note": f"\n \n\n \n{NON_EMPTY_NOTE} \n \n\n "}
|
||||||
|
|
||||||
|
|
||||||
|
def get_unchanged_update_form(txn_id: int, app: Flask, csrf_token: str) \
|
||||||
|
-> dict[str, str]:
|
||||||
|
"""Returns the form data to update a transaction, where the data are not
|
||||||
|
changed.
|
||||||
|
|
||||||
|
:param txn_id: The transaction ID.
|
||||||
|
:param app: The Flask application.
|
||||||
|
:param csrf_token: The CSRF token.
|
||||||
|
:return: The form data to update the transaction, where the data are not
|
||||||
|
changed.
|
||||||
|
"""
|
||||||
|
from accounting.models import Transaction, TransactionCurrency
|
||||||
|
with app.app_context():
|
||||||
|
txn: Transaction | None = db.session.get(Transaction, txn_id)
|
||||||
|
assert txn is not None
|
||||||
|
currencies: list[TransactionCurrency] = txn.currencies
|
||||||
|
|
||||||
|
form: dict[str, str] = {"csrf_token": csrf_token,
|
||||||
|
"next": NEXT_URI,
|
||||||
|
"date": txn.date,
|
||||||
|
"note": " \n \n\n " if txn.note is None
|
||||||
|
else f"\n \n\n \n \n{txn.note} \n\n "}
|
||||||
|
currency_indices_used: set[int] = set()
|
||||||
|
currency_no: int = 0
|
||||||
|
for currency in currencies:
|
||||||
|
currency_index: int = __get_new_index(currency_indices_used)
|
||||||
|
currency_no = currency_no + 3 + randbelow(3)
|
||||||
|
currency_prefix: str = f"currency-{currency_index}"
|
||||||
|
form[f"{currency_prefix}-no"] = str(currency_no)
|
||||||
|
form[f"{currency_prefix}-code"] = currency.code
|
||||||
|
entry_indices_used: set[int]
|
||||||
|
entry_no: int
|
||||||
|
prefix: str
|
||||||
|
|
||||||
|
entry_indices_used = set()
|
||||||
|
entry_no = 0
|
||||||
|
for entry in currency.debit:
|
||||||
|
entry_index: int = __get_new_index(entry_indices_used)
|
||||||
|
entry_no = entry_no + 3 + randbelow(3)
|
||||||
|
prefix = f"{currency_prefix}-debit-{entry_index}"
|
||||||
|
form[f"{prefix}-eid"] = str(entry.id)
|
||||||
|
form[f"{prefix}-no"] = str(entry_no)
|
||||||
|
form[f"{prefix}-account_code"] = entry.account.code
|
||||||
|
form[f"{prefix}-summary"] \
|
||||||
|
= " " if entry.summary is None else f" {entry.summary} "
|
||||||
|
form[f"{prefix}-amount"] = str(entry.amount)
|
||||||
|
|
||||||
|
entry_indices_used = set()
|
||||||
|
entry_no = 0
|
||||||
|
for entry in currency.credit:
|
||||||
|
entry_index: int = __get_new_index(entry_indices_used)
|
||||||
|
entry_no = entry_no + 3 + randbelow(3)
|
||||||
|
prefix = f"{currency_prefix}-credit-{entry_index}"
|
||||||
|
form[f"{prefix}-eid"] = str(entry.id)
|
||||||
|
form[f"{prefix}-no"] = str(entry_no)
|
||||||
|
form[f"{prefix}-account_code"] = entry.account.code
|
||||||
|
form[f"{prefix}-summary"] \
|
||||||
|
= " " if entry.summary is None else f" {entry.summary} "
|
||||||
|
form[f"{prefix}-amount"] = str(entry.amount)
|
||||||
|
|
||||||
|
return form
|
||||||
|
|
||||||
|
|
||||||
|
def __get_new_index(indices_used: set[int]) -> int:
|
||||||
|
"""Returns a new random index that is not used.
|
||||||
|
|
||||||
|
:param indices_used: The set of indices that are already used.
|
||||||
|
:return: The newly-generated random index that is not used.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
index: int = randbelow(100)
|
||||||
|
if index not in indices_used:
|
||||||
|
indices_used.add(index)
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def get_update_form(txn_id: int, app: Flask,
|
||||||
|
csrf_token: str, is_debit: bool | None) -> dict[str, str]:
|
||||||
|
"""Returns the form data to update a transaction, where the data are
|
||||||
|
changed.
|
||||||
|
|
||||||
|
:param txn_id: The transaction ID.
|
||||||
|
:param app: The Flask application.
|
||||||
|
:param csrf_token: The CSRF token.
|
||||||
|
:param is_debit: True for a cash expense transaction, False for a cash
|
||||||
|
income transaction, or None for a transfer transaction
|
||||||
|
:return: The form data to update the transaction, where the data are
|
||||||
|
changed.
|
||||||
|
"""
|
||||||
|
form: dict[str, str] = get_unchanged_update_form(
|
||||||
|
txn_id, app, csrf_token)
|
||||||
|
|
||||||
|
# Mess up the entries in a currency
|
||||||
|
currency_prefix: str = __get_currency_prefix(form, "USD")
|
||||||
|
if is_debit is None or is_debit:
|
||||||
|
form = __mess_up_debit(form, currency_prefix)
|
||||||
|
if is_debit is None or not is_debit:
|
||||||
|
form = __mess_up_credit(form, currency_prefix)
|
||||||
|
|
||||||
|
# Mess-up the currencies
|
||||||
|
form = __mess_up_currencies(form)
|
||||||
|
|
||||||
|
return form
|
||||||
|
|
||||||
|
|
||||||
|
def __mess_up_debit(form: dict[str, str], currency_prefix: str) \
|
||||||
|
-> dict[str, str]:
|
||||||
|
"""Mess up the debit journal entries in the form data.
|
||||||
|
|
||||||
|
:param form: The form data.
|
||||||
|
:param currency_prefix: The key prefix of the currency sub-form.
|
||||||
|
:return: The messed-up form.
|
||||||
|
"""
|
||||||
|
key: str
|
||||||
|
m: re.Match
|
||||||
|
|
||||||
|
# Remove the office expense
|
||||||
|
key = [x for x in form
|
||||||
|
if x.startswith(currency_prefix)
|
||||||
|
and form[x] == Accounts.OFFICE][0]
|
||||||
|
m = re.match(r"^((.+-)\d+-)account_code$", key)
|
||||||
|
debit_prefix: str = m.group(2)
|
||||||
|
entry_prefix: str = m.group(1)
|
||||||
|
amount: Decimal = Decimal(form[f"{entry_prefix}amount"])
|
||||||
|
form = {x: form[x] for x in form if not x.startswith(entry_prefix)}
|
||||||
|
# Add a new travel expense
|
||||||
|
indices: set[int] = set()
|
||||||
|
for key in form:
|
||||||
|
m = re.match(r"^.+-(\d+)-amount$", key)
|
||||||
|
if m is not None:
|
||||||
|
indices.add(int(m.group(1)))
|
||||||
|
new_index: int = max(indices) + 5 + randbelow(20)
|
||||||
|
min_no: int = min({int(form[x]) for x in form if x.endswith("-no")
|
||||||
|
and x.startswith(debit_prefix)})
|
||||||
|
form[f"{debit_prefix}{new_index}-no"] = str(1 + randbelow(min_no - 1))
|
||||||
|
form[f"{debit_prefix}{new_index}-amount"] = str(amount)
|
||||||
|
form[f"{debit_prefix}{new_index}-account_code"] = Accounts.TRAVEL
|
||||||
|
# Swap the cash and the bank order
|
||||||
|
key_cash: str = __get_entry_no_key(form, currency_prefix, Accounts.CASH)
|
||||||
|
key_bank: str = __get_entry_no_key(form, currency_prefix, Accounts.BANK)
|
||||||
|
form[key_cash], form[key_bank] = form[key_bank], form[key_cash]
|
||||||
|
return form
|
||||||
|
|
||||||
|
|
||||||
|
def __mess_up_credit(form: dict[str, str], currency_prefix: str) \
|
||||||
|
-> dict[str, str]:
|
||||||
|
"""Mess up the credit journal entries in the form data.
|
||||||
|
|
||||||
|
:param form: The form data.
|
||||||
|
:param currency_prefix: The key prefix of the currency sub-form.
|
||||||
|
:return: The messed-up form.
|
||||||
|
"""
|
||||||
|
key: str
|
||||||
|
m: re.Match
|
||||||
|
|
||||||
|
# Remove the sales income
|
||||||
|
key = [x for x in form
|
||||||
|
if x.startswith(currency_prefix)
|
||||||
|
and form[x] == Accounts.SALES][0]
|
||||||
|
m = re.match(r"^((.+-)\d+-)account_code$", key)
|
||||||
|
credit_prefix: str = m.group(2)
|
||||||
|
entry_prefix: str = m.group(1)
|
||||||
|
amount: Decimal = Decimal(form[f"{entry_prefix}amount"])
|
||||||
|
form = {x: form[x] for x in form if not x.startswith(entry_prefix)}
|
||||||
|
# Add a new agency income
|
||||||
|
indices: set[int] = set()
|
||||||
|
for key in form:
|
||||||
|
m = re.match(r"^.+-(\d+)-amount$", key)
|
||||||
|
if m is not None:
|
||||||
|
indices.add(int(m.group(1)))
|
||||||
|
new_index: int = max(indices) + 5 + randbelow(20)
|
||||||
|
min_no: int = min({int(form[x]) for x in form if x.endswith("-no")
|
||||||
|
and x.startswith(credit_prefix)})
|
||||||
|
form[f"{credit_prefix}{new_index}-no"] = str(1 + randbelow(min_no - 1))
|
||||||
|
form[f"{credit_prefix}{new_index}-amount"] = str(amount)
|
||||||
|
form[f"{credit_prefix}{new_index}-account_code"] = Accounts.AGENCY
|
||||||
|
# Swap the service and the interest order
|
||||||
|
key_srv: str = __get_entry_no_key(form, currency_prefix, Accounts.SERVICE)
|
||||||
|
key_int: str = __get_entry_no_key(form, currency_prefix, Accounts.INTEREST)
|
||||||
|
form[key_srv], form[key_int] = form[key_int], form[key_srv]
|
||||||
|
return form
|
||||||
|
|
||||||
|
|
||||||
|
def __mess_up_currencies(form: dict[str, str]) -> dict[str, str]:
|
||||||
|
"""Mess up the currency sub-forms in the form data.
|
||||||
|
|
||||||
|
:param form: The form data.
|
||||||
|
:return: The messed-up form.
|
||||||
|
"""
|
||||||
|
key: str
|
||||||
|
m: re.Match
|
||||||
|
|
||||||
|
# Remove JPY
|
||||||
|
currency_prefix: str = __get_currency_prefix(form, "JPY")
|
||||||
|
form = {x: form[x] for x in form if not x.startswith(currency_prefix)}
|
||||||
|
# Add AUD
|
||||||
|
indices: set[int] = set()
|
||||||
|
for key in form:
|
||||||
|
m = re.match(r"^currency-(\d+)-code$", key)
|
||||||
|
if m is not None:
|
||||||
|
indices.add(int(m.group(1)))
|
||||||
|
new_index: int = max(indices) + 5 + randbelow(20)
|
||||||
|
min_no: int = min({int(form[x]) for x in form if x.endswith("-no")
|
||||||
|
and "-debit-" not in x and "-credit-" not in x})
|
||||||
|
prefix: str = f"currency-{new_index}-"
|
||||||
|
form.update({
|
||||||
|
f"{prefix}code": "AUD",
|
||||||
|
f"{prefix}no": str(1 + randbelow(min_no - 1)),
|
||||||
|
f"{prefix}debit-0-no": "6",
|
||||||
|
f"{prefix}debit-0-account_code": Accounts.OFFICE,
|
||||||
|
f"{prefix}debit-0-summary": " Envelop ",
|
||||||
|
f"{prefix}debit-0-amount": "5.45",
|
||||||
|
f"{prefix}debit-14-no": "6",
|
||||||
|
f"{prefix}debit-14-account_code": Accounts.CASH,
|
||||||
|
f"{prefix}debit-14-summary": " ",
|
||||||
|
f"{prefix}debit-14-amount": "14.55",
|
||||||
|
f"{prefix}credit-16-no": "7",
|
||||||
|
f"{prefix}credit-16-account_code": Accounts.RENT,
|
||||||
|
f"{prefix}credit-16-summary": " Bike ",
|
||||||
|
f"{prefix}credit-16-amount": "19.5",
|
||||||
|
f"{prefix}credit-22-no": "5",
|
||||||
|
f"{prefix}credit-22-account_code": Accounts.DONATION,
|
||||||
|
f"{prefix}credit-22-summary": " Artist ",
|
||||||
|
f"{prefix}credit-22-amount": "0.5",
|
||||||
|
})
|
||||||
|
# Swap the USD and TWD order
|
||||||
|
usd_prefix: str = __get_currency_prefix(form, "USD")
|
||||||
|
key_usd: str = f"{usd_prefix}no"
|
||||||
|
twd_prefix: str = __get_currency_prefix(form, "TWD")
|
||||||
|
key_twd: str = f"{twd_prefix}no"
|
||||||
|
form[key_usd], form[key_twd] = form[key_twd], form[key_usd]
|
||||||
|
# Change TWD to EUR
|
||||||
|
key = [x for x in form if form[x] == "TWD"][0]
|
||||||
|
form[key] = "EUR"
|
||||||
|
return form
|
||||||
|
|
||||||
|
|
||||||
|
def __get_entry_no_key(form: dict[str, str], currency_prefix: str,
|
||||||
|
code: str) -> str:
|
||||||
|
"""Returns the key of an entry number in the form data.
|
||||||
|
|
||||||
|
:param form: The form data.
|
||||||
|
:param currency_prefix: The prefix of the currency.
|
||||||
|
:param code: The code of the account.
|
||||||
|
:return: The key of the entry number in the form data.
|
||||||
|
"""
|
||||||
|
key: str = [x for x in form
|
||||||
|
if x.startswith(currency_prefix)
|
||||||
|
and form[x] == code][0]
|
||||||
|
m: re.Match = re.match(r"^(.+-\d+-)account_code$", key)
|
||||||
|
return f"{m.group(1)}no"
|
||||||
|
|
||||||
|
|
||||||
|
def __get_currency_prefix(form: dict[str, str], code: str) -> str:
|
||||||
|
"""Returns the prefix of a currency in the form data.
|
||||||
|
|
||||||
|
:param form: The form data.
|
||||||
|
:param code: The code of the currency.
|
||||||
|
:return: The prefix of the currency.
|
||||||
|
"""
|
||||||
|
key: str = [x for x in form if form[x] == code][0]
|
||||||
|
m: re.Match = re.match(r"^(.+-)code$", key)
|
||||||
|
return m.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def add_txn(client: httpx.Client, form: dict[str, str]) -> int:
|
||||||
|
"""Adds a transfer transaction.
|
||||||
|
|
||||||
|
:param client: The client.
|
||||||
|
:param form: The form data.
|
||||||
|
:return: The newly-added transaction ID.
|
||||||
|
"""
|
||||||
|
prefix: str = "/accounting/transactions"
|
||||||
|
txn_type: str = "transfer"
|
||||||
|
if len({x for x in form if "-debit-" in x}) == 0:
|
||||||
|
txn_type = "income"
|
||||||
|
elif len({x for x in form if "-credit-" in x}) == 0:
|
||||||
|
txn_type = "expense"
|
||||||
|
store_uri = f"{prefix}/store/{txn_type}"
|
||||||
|
response: httpx.Response = client.post(store_uri, data=form)
|
||||||
|
assert response.status_code == 302
|
||||||
|
return match_txn_detail(response.headers["Location"])
|
||||||
|
|
||||||
|
|
||||||
|
def match_txn_detail(location: str) -> int:
|
||||||
|
"""Validates if the redirect location is the transaction detail, and
|
||||||
|
returns the transaction ID on success.
|
||||||
|
|
||||||
|
:param location: The redirect location.
|
||||||
|
:return: The transaction ID.
|
||||||
|
:raise AssertionError: When the location is not the transaction detail.
|
||||||
|
"""
|
||||||
|
m: re.Match = re.match(
|
||||||
|
r"^/accounting/transactions/(\d+)\?next=%2F_next",
|
||||||
|
location)
|
||||||
|
assert m is not None
|
||||||
|
return int(m.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def set_negative_amount(form: dict[str, str]) -> None:
|
||||||
|
"""Sets a negative amount in the form data, keeping the balance.
|
||||||
|
|
||||||
|
:param form: The form data.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
amount_keys: list[str] = []
|
||||||
|
prefix: str = ""
|
||||||
|
for key in form.keys():
|
||||||
|
m: re.Match = re.match(r"^(.+)-\d+-amount$", key)
|
||||||
|
if m is None:
|
||||||
|
continue
|
||||||
|
if prefix != "" and prefix != m.group(1):
|
||||||
|
continue
|
||||||
|
prefix = m.group(1)
|
||||||
|
amount_keys.append(key)
|
||||||
|
form[amount_keys[0]] = str(-Decimal(form[amount_keys[0]]))
|
||||||
|
form[amount_keys[1]] = str(Decimal(form[amount_keys[1]])
|
||||||
|
+ 2 * Decimal(form[amount_keys[0]]))
|
||||||
|
|
||||||
|
|
||||||
|
def remove_debit_in_a_currency(form: dict[str, str]) -> None:
|
||||||
|
"""Removes credit entries in a currency sub-form.
|
||||||
|
|
||||||
|
:param form: The form data.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
key: str = [x for x in form if "-debit-" in x][0]
|
||||||
|
m: re.Match = re.match(r"^(.+-debit-)", key)
|
||||||
|
keys: set[str] = {x for x in form if x.startswith(m.group(1))}
|
||||||
|
for key in keys:
|
||||||
|
del form[key]
|
||||||
|
|
||||||
|
|
||||||
|
def remove_credit_in_a_currency(form: dict[str, str]) -> None:
|
||||||
|
"""Removes credit entries in a currency sub-form.
|
||||||
|
|
||||||
|
:param form: The form data.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
key: str = [x for x in form if "-credit-" in x][0]
|
||||||
|
m: re.Match = re.match(r"^(.+-credit-)", key)
|
||||||
|
keys: set[str] = {x for x in form if x.startswith(m.group(1))}
|
||||||
|
for key in keys:
|
||||||
|
del form[key]
|
Reference in New Issue
Block a user