Compare commits

..

33 Commits

Author SHA1 Message Date
imacat 84bd01087c Fix Escape key navigation and dynamic parent targeting in stacked modals 2026-04-19 10:06:55 +08:00
imacat c10bf81d24 Add ARIA live regions and alerts for dynamic content announcements 2026-04-19 10:06:55 +08:00
imacat acf2a0fa87 Add BaseCombobox base class with listbox/option ARIA pattern 2026-04-19 10:05:35 +08:00
imacat 090acbd66b Convert clickable divs to buttons or add role="button" for keyboard accessibility 2026-04-19 09:53:18 +08:00
imacat 220dbaa683 Convert navbar dropdown span to button for accessibility 2026-04-19 09:49:56 +08:00
imacat 800832d15e Add BaseTablist base class with keyboard navigation 2026-04-19 09:47:50 +08:00
imacat 454ff8bb5f Add ARIA tablist markup to description editor and period chooser 2026-04-19 09:34:41 +08:00
imacat 9b002cd9a9 Add ARIA table roles to report tables for screen reader accessibility 2026-04-19 09:24:30 +08:00
imacat c1d5b46145 Add ARIA markup to icons, icon-only buttons, and pagination dropdown
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 09:24:30 +08:00
imacat 46c6767a90 Add missing semicolon in journal-entry-account-selector.js constructor 2026-04-17 06:56:53 +08:00
imacat 381298adb1 Mark internal classes as private in JavaScript modules 2026-04-17 06:11:17 +08:00
imacat bfa42000d8 Fix indentation in unapplied and unmatched account report templates 2026-04-16 20:41:08 +08:00
imacat 0d02f41417 Fix the typo in the variable name from conform to confirm in period-chooser.js 2026-04-16 20:41:06 +08:00
imacat a26f1942f8 Fix incorrect comment reference in balance-sheet.html template 2026-04-16 20:40:49 +08:00
imacat 0a7bcdd9ec Remove period-chooser script from unapplied and unmatched report templates 2026-04-16 20:40:37 +08:00
imacat b6ea944eb5 Fix the typo in the block name from as_trasfer to as_transfer in the journal entry detail templates 2026-04-15 13:35:21 +08:00
imacat be9f4f3d83 Fix the receipt and transfer journal entry details to show credit_total instead of debit_total for the credit section total 2026-04-15 13:35:21 +08:00
imacat dc42a05959 Migrate from Flask-SQLAlchemy to Flask-SQLAlchemy-Lite 2026-04-15 13:35:21 +08:00
imacat 9c6cc1f3eb Replace db.Model with DeclarativeBase from SQLAlchemy for Flask-SQLAlchemy-Lite migration 2026-04-15 13:35:21 +08:00
imacat e6d25882fc Replace Flask-SQLAlchemy helpers (db.relationship, db.ForeignKey, etc.) with plain SQLAlchemy equivalents 2026-04-15 13:35:21 +08:00
imacat 970c2e9946 Migrate from SQLAlchemy 1.x legacy Query API to 2.x style select/delete statements 2026-04-15 13:35:10 +08:00
imacat 356950e2c7 Replace typing.Type with built-in type[] for Python 3.12. 2026-04-05 23:49:16 +08:00
imacat cca3f89bf1 Replace absolute imports with relative imports 2026-04-05 23:49:16 +08:00
imacat 674b0de3b2 Fix various type hints 2026-04-05 23:49:12 +08:00
imacat 29dfc6c5a4 Fix pycodestyle styling issues 2026-04-05 07:05:20 +08:00
imacat aa3bc1ed69 Add Claude Code and Codex files to .gitignore 2026-04-04 23:40:55 +08:00
imacat 1289d7cba6 Fix type hints in the console command test case. 2026-01-11 12:02:29 +08:00
imacat d62e295dc6 Add init options to skip data initialization and remove manual cleanup in test cases. 2026-01-11 12:02:25 +08:00
imacat 693c5890ca Add db.engine.dispose() in test tearDown to fix ResourceWarning from Python 3.13. 2026-01-11 11:56:20 +08:00
imacat 3adcaa61d3 Fix httpx dependency version in pyproject.toml. 2026-01-08 21:52:11 +08:00
imacat aea9dcae79 Advanced to version 1.6.1. 2024-12-03 08:18:40 +08:00
imacat 40278eaf06 Fix test cases for compatibility with httpx 0.28.0. 2024-12-03 08:18:30 +08:00
imacat e00c14f277 Fixed the SQLite database URL for the in-memory database. 2024-07-10 05:49:29 +08:00
148 changed files with 2575 additions and 2012 deletions
+2
View File
@@ -25,6 +25,8 @@ venv
.DS_Store
.idea
.claude
.codex
instance
flask_session
+8
View File
@@ -2,6 +2,14 @@ Change Log
==========
Version 1.6.1
--------------
Released 2024/12/3
Fix test cases for compatibility with httpx 0.28.0.
Version 1.6.0
--------------
+8 -3
View File
@@ -13,7 +13,7 @@ The following is an example configuration for *Mia! Accounting*.
from flask import Response, redirect
from .auth import current_user()
from .modules import User
from .modules import Base, User
def create_app(test_config=None) -> Flask:
app: Flask = Flask(__name__)
@@ -37,7 +37,11 @@ The following is an example configuration for *Mia! Accounting*.
return redirect("/login")
@property
def cls(self) -> t.Type[User]:
def base(self) -> type[DeclarativeBase]:
return Base
@property
def cls(self) -> type[User]:
return User
@property
@@ -49,7 +53,8 @@ The following is an example configuration for *Mia! Accounting*.
return current_user()
def get_by_username(self, username: str) -> User | None:
return User.query.filter(User.username == username).first()
return db.session.scalar(
sa.select(User).where(User.username == username))
def get_pk(self, user: User) -> int:
return user.id
+3 -3
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
# Copyright (c) 2022-2024 imacat.
# Copyright (c) 2022-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -35,7 +35,7 @@ classifiers = [
dependencies = [
"Flask",
"SQLAlchemy >= 2",
"Flask-SQLAlchemy",
"Flask-SQLAlchemy-Lite",
"Flask-WTF",
"Flask-Babel >= 3",
"Flask-Babel-JS",
@@ -43,7 +43,7 @@ dependencies = [
[project.optional-dependencies]
devel = [
"httpx",
"httpx >= 0.28.0",
"OpenCC",
]
+4 -4
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023-2024 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,11 +20,11 @@
from pathlib import Path
from flask import Flask, Blueprint
from flask_sqlalchemy import SQLAlchemy
from flask_sqlalchemy_lite import SQLAlchemy
from accounting.utils.user import UserUtilityInterface
from .utils.user import UserUtilityInterface
VERSION: str = "1.6.0"
VERSION: str = "1.6.1"
"""The package version."""
db: SQLAlchemy = SQLAlchemy()
"""The database instance."""
+9 -8
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023-2024 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -23,9 +23,9 @@ from typing import Any
import click
import sqlalchemy as sa
from accounting import db
from accounting.models import BaseAccount, Account, AccountL10n
from accounting.utils.user import get_user_pk
from .. import db
from ..models import BaseAccount, Account, AccountL10n
from ..utils.user import get_user_pk
type AccountData = tuple[int, str, int, str, str, str, bool]
"""The format of the account data, as a list of (ID, base account code, number,
@@ -36,13 +36,14 @@ def init_accounts_command(username: str) -> None:
"""Initializes the accounts."""
creator_pk: int = get_user_pk(username)
bases: list[BaseAccount] = BaseAccount.query\
.filter(db.func.length(BaseAccount.code) == 4)\
.order_by(BaseAccount.code).all()
bases: list[BaseAccount] = db.session.scalars(
sa.select(BaseAccount).where(sa.func.length(BaseAccount.code) == 4)
.order_by(BaseAccount.code)).unique().all()
if len(bases) == 0:
raise click.Abort
existing: list[Account] = Account.query.all()
existing: list[Account] = \
db.session.scalars(sa.select(Account)).unique().all()
existing_base_code: set[str] = {x.base_code for x in existing}
bases_to_add: list[BaseAccount] = [x for x in bases
+2 -2
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/31
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,7 +20,7 @@
from flask import abort
from werkzeug.routing import BaseConverter
from accounting.models import Account
from ..models import Account
class AccountConverter(BaseConverter):
+18 -16
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -23,12 +23,12 @@ from flask_wtf import FlaskForm
from wtforms import StringField, BooleanField
from wtforms.validators import DataRequired, ValidationError
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import BaseAccount, Account
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk
from .. import db
from ..locale import lazy_gettext
from ..models import BaseAccount, Account
from ..utils.random_id import new_id
from ..utils.strip_text import strip_text
from ..utils.user import get_current_user_pk
class BaseAccountExists:
@@ -97,8 +97,9 @@ class AccountForm(FlaskForm):
if obj.base_code is not None:
sort_accounts_in(obj.base_code, obj.id)
sort_accounts_in(self.base_code.data, obj.id)
count: int = Account.query\
.filter(Account.base_code == self.base_code.data).count()
count: int = db.session.scalar(
sa.select(sa.func.count(Account.id))
.where(Account.base_code == self.base_code.data))
obj.base_code = self.base_code.data
obj.no = count + 1
obj.title = self.title.data
@@ -137,9 +138,10 @@ class AccountForm(FlaskForm):
:return: The selectable base accounts.
"""
return BaseAccount.query\
.filter(sa.func.char_length(BaseAccount.code) == 4)\
.order_by(BaseAccount.code).all()
return db.session.scalars(
sa.select(BaseAccount)
.where(sa.func.char_length(BaseAccount.code) == 4)
.order_by(BaseAccount.code)).unique()
def sort_accounts_in(base_code: str, exclude: int) -> None:
@@ -150,10 +152,10 @@ def sort_accounts_in(base_code: str, exclude: int) -> None:
:param exclude: The account ID to exclude.
:return: None.
"""
accounts: list[Account] = Account.query\
.filter(Account.base_code == base_code,
Account.id != exclude)\
.order_by(Account.no).all()
accounts: list[Account] = db.session.scalars(
sa.select(Account)
.where(Account.base_code == base_code, Account.id != exclude)
.order_by(Account.no)).unique().all()
for i in range(len(accounts)):
if accounts[i].no != i + 1:
accounts[i].no = i + 1
+18 -13
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,9 +20,10 @@
import sqlalchemy as sa
from flask import request
from accounting.locale import gettext
from accounting.models import Account, AccountL10n
from accounting.utils.query import parse_query_keywords
from .. import db
from ..locale import gettext
from ..models import Account, AccountL10n
from ..utils.query import parse_query_keywords
def get_account_query() -> list[Account]:
@@ -32,17 +33,20 @@ def get_account_query() -> list[Account]:
"""
keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0:
return Account.query.order_by(Account.base_code, Account.no).all()
code: sa.BinaryExpression = Account.base_code + "-" \
return db.session.scalars(
sa.select(Account)
.order_by(Account.base_code, Account.no)).unique().all()
code: sa.ColumnElement[str] = Account.base_code + "-" \
+ sa.func.substr("000" + sa.cast(Account.no, sa.String),
sa.func.char_length(sa.cast(Account.no,
sa.String)) + 1)
conditions: list[sa.BinaryExpression] = []
conditions: list[sa.ColumnElement[bool]] = []
for k in keywords:
l10n: list[AccountL10n] = AccountL10n.query\
.filter(AccountL10n.title.icontains(k)).all()
l10n_matches: set[str] = {x.account_id for x in l10n}
sub_conditions: list[sa.BinaryExpression] \
l10n: list[AccountL10n] = db.session.scalars(
sa.select(AccountL10n)
.where(AccountL10n.title.icontains(k))).all()
l10n_matches: set[int] = {x.account_id for x in l10n}
sub_conditions: list[sa.ColumnElement[bool]] \
= [Account.base_code.contains(k),
Account.title_l10n.icontains(k),
code.contains(k),
@@ -51,5 +55,6 @@ def get_account_query() -> list[Account]:
sub_conditions.append(Account.is_need_offset)
conditions.append(sa.or_(*sub_conditions))
return Account.query.filter(*conditions)\
.order_by(Account.base_code, Account.no).all()
return db.session.scalars(
sa.select(Account).where(*conditions)
.order_by(Account.base_code, Account.no)).unique().all()
+17 -17
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -21,20 +21,20 @@ from urllib.parse import parse_qsl, urlencode
import sqlalchemy as sa
from flask import Blueprint, render_template, session, redirect, flash, \
url_for, request
url_for, request, Response
from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Account, BaseAccount
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.pagination import Pagination
from accounting.utils.permission import can_view, has_permission, can_edit
from accounting.utils.user import get_current_user_pk
from .forms import AccountForm, sort_accounts_in, AccountReorderForm
from .queries import get_account_query
from .. import db
from ..locale import lazy_gettext
from ..models import Account, BaseAccount
from ..utils.cast import s
from ..utils.flash_errors import flash_form_errors
from ..utils.next_uri import inherit_next, or_next
from ..utils.pagination import Pagination
from ..utils.permission import can_view, has_permission, can_edit
from ..utils.user import get_current_user_pk
bp: Blueprint = Blueprint("account", __name__)
"""The view blueprint for the account management."""
@@ -47,8 +47,8 @@ def list_accounts() -> str:
:return: The account list.
"""
accounts: list[BaseAccount] = get_account_query()
pagination: Pagination = Pagination[BaseAccount](accounts)
accounts: list[Account] = get_account_query()
pagination: Pagination = Pagination[Account](accounts)
return render_template("accounting/account/list.html",
list=pagination.list, pagination=pagination)
@@ -72,7 +72,7 @@ def show_add_account_form() -> str:
@bp.post("store", endpoint="store")
@has_permission(can_edit)
def add_account() -> redirect:
def add_account() -> Response:
"""Adds an account.
:return: The redirection to the account detail on success, or the account
@@ -123,7 +123,7 @@ def show_account_edit_form(account: Account) -> str:
@bp.post("<account:account>/update", endpoint="update")
@has_permission(can_edit)
def update_account(account: Account) -> redirect:
def update_account(account: Account) -> Response:
"""Updates an account.
:param account: The account.
@@ -150,7 +150,7 @@ def update_account(account: Account) -> redirect:
@bp.post("<account:account>/delete", endpoint="delete")
@has_permission(can_edit)
def delete_account(account: Account) -> redirect:
def delete_account(account: Account) -> Response:
"""Deletes an account.
:param account: The account.
@@ -180,7 +180,7 @@ def show_account_order(base: BaseAccount) -> str:
@bp.post("bases/<baseAccount:base>", endpoint="sort")
@has_permission(can_edit)
def sort_accounts(base: BaseAccount) -> redirect:
def sort_accounts(base: BaseAccount) -> Response:
"""Reorders the accounts under a base account.
:param base: The base account.
+7 -8
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -21,21 +21,20 @@ import csv
import sqlalchemy as sa
from accounting import data_dir
from accounting import db
from accounting.models import BaseAccount, BaseAccountL10n
from accounting.utils.title_case import title_case
from .. import db, data_dir
from ..models import BaseAccount, BaseAccountL10n
from ..utils.title_case import title_case
def init_base_accounts_command() -> None:
"""Initializes the base accounts."""
if BaseAccount.query.first() is not None:
if db.session.scalar(sa.select(BaseAccount)) is not None:
return
with open(data_dir / "base_accounts.csv") as fp:
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
account_data: list[dict[str, str]] = [{"code": x["code"],
"title_l10n": title_case(x["title"])}
account_data: list[dict[str, str]] = \
[{"code": x["code"], "title_l10n": title_case(x["title"])}
for x in data]
locales: list[str] = [x[5:] for x in data[0] if x.startswith("l10n-")]
l10n_data: list[dict[str, str]] = [{"account_code": x["code"],
+3 -3
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,8 +20,8 @@
from flask import abort
from werkzeug.routing import BaseConverter
from accounting import db
from accounting.models import BaseAccount
from .. import db
from ..models import BaseAccount
class BaseAccountConverter(BaseConverter):
+13 -9
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,8 +20,9 @@
import sqlalchemy as sa
from flask import request
from accounting.models import BaseAccount, BaseAccountL10n
from accounting.utils.query import parse_query_keywords
from .. import db
from ..models import BaseAccount, BaseAccountL10n
from ..utils.query import parse_query_keywords
def get_base_account_query() -> list[BaseAccount]:
@@ -31,14 +32,17 @@ def get_base_account_query() -> list[BaseAccount]:
"""
keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0:
return BaseAccount.query.order_by(BaseAccount.code).all()
conditions: list[sa.BinaryExpression] = []
return db.session.scalars(
sa.select(BaseAccount).order_by(BaseAccount.code)).unique().all()
conditions: list[sa.ColumnElement[bool]] = []
for k in keywords:
l10n: list[BaseAccountL10n] = BaseAccountL10n.query\
.filter(BaseAccountL10n.title.icontains(k)).all()
l10n: list[BaseAccountL10n] = db.session.scalars(
sa.select(BaseAccountL10n)
.where((BaseAccountL10n.title.icontains(k)))).all()
l10n_matches: set[str] = {x.account_code for x in l10n}
conditions.append(sa.or_(BaseAccount.code.contains(k),
BaseAccount.title_l10n.icontains(k),
BaseAccount.code.in_(l10n_matches)))
return BaseAccount.query.filter(*conditions)\
.order_by(BaseAccount.code).all()
return db.session.scalars(
sa.select(BaseAccount).where(*conditions)
.order_by(BaseAccount.code)).unique().all()
+4 -5
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -19,10 +19,10 @@
"""
from flask import Blueprint, render_template
from accounting.models import BaseAccount
from accounting.utils.pagination import Pagination
from accounting.utils.permission import has_permission, can_view
from .queries import get_base_account_query
from ..models import BaseAccount
from ..utils.pagination import Pagination
from ..utils.permission import has_permission, can_view
bp: Blueprint = Blueprint("base-account", __name__)
"""The view blueprint for the base account management."""
@@ -50,4 +50,3 @@ def show_account_detail(account: BaseAccount) -> str:
:return: The detail.
"""
return render_template("accounting/base-account/detail.html", obj=account)
+22 -13
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/10
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,16 +20,16 @@
import os
import click
import sqlalchemy as sa
from flask.cli import with_appcontext
from accounting import db
from accounting.account import init_accounts_command
from accounting.base_account import init_base_accounts_command
from accounting.currency import init_currencies_command
from accounting.models import BaseAccount, Account
from accounting.utils.title_case import title_case
from accounting.utils.user import has_user, get_user_pk
import sqlalchemy as sa
from . import db
from .account import init_accounts_command
from .base_account import init_base_accounts_command
from .currency import init_currencies_command
from .models import BaseAccount, Account
from .utils.title_case import title_case
from .utils.user import base_cls, has_user, get_user_pk
def __validate_username(ctx: click.core.Context, param: click.core.Option,
@@ -54,13 +54,22 @@ def __validate_username(ctx: click.core.Context, param: click.core.Option,
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@click.option("--skip-accounts", is_flag=True, default=False,
help="Skip initializing accounts.")
@click.option("--skip-currencies", is_flag=True, default=False,
help="Skip initializing currencies.")
@with_appcontext
def init_db_command(username: str) -> None:
def init_db_command(username: str, skip_accounts: bool,
skip_currencies: bool) -> None:
"""Initializes the accounting database."""
db.create_all()
base_cls.metadata.create_all(db.engine)
init_base_accounts_command()
if not skip_accounts:
init_accounts_command(username)
print("OK 1")
if not skip_currencies:
init_currencies_command(username)
print("OK 2")
db.session.commit()
click.echo("Accounting database initialized.")
@@ -74,12 +83,12 @@ def titleize_command(username: str) -> None:
"""Capitalize the account titles."""
updater_pk: int = get_user_pk(username)
updated: int = 0
for base in BaseAccount.query:
for base in db.session.scalars(sa.select(BaseAccount)).unique():
new_title: str = title_case(base.title_l10n)
if base.title_l10n != new_title:
base.title_l10n = new_title
updated = updated + 1
for account in Account.query:
for account in db.session.scalars(sa.select(Account)).unique():
if account.title_l10n.lower() == account.base.title_l10n.lower():
new_title: str = title_case(account.title_l10n)
if account.title_l10n != new_title:
+6 -5
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -22,14 +22,15 @@ from typing import Any
import sqlalchemy as sa
from accounting import db, data_dir
from accounting.models import Currency, CurrencyL10n
from accounting.utils.user import get_user_pk
from .. import db, data_dir
from ..models import Currency, CurrencyL10n
from ..utils.user import get_user_pk
def init_currencies_command(username: str) -> None:
"""Initializes the currencies."""
existing_codes: set[str] = {x.code for x in Currency.query.all()}
existing_codes: set[str] = \
{x.code for x in db.session.scalars(sa.select(Currency)).unique()}
with open(data_dir / "currencies.csv") as fp:
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
+3 -3
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,8 +20,8 @@
from flask import abort
from werkzeug.routing import BaseConverter
from accounting import db
from accounting.models import Currency
from .. import db
from ..models import Currency
class CurrencyConverter(BaseConverter):
+6 -6
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -21,11 +21,11 @@ from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError
from wtforms.validators import DataRequired, Regexp, NoneOf
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency
from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk
from .. import db
from ..locale import lazy_gettext
from ..models import Currency
from ..utils.strip_text import strip_text
from ..utils.user import get_current_user_pk
class CodeUnique:
+13 -9
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,8 +20,9 @@
import sqlalchemy as sa
from flask import request
from accounting.models import Currency, CurrencyL10n
from accounting.utils.query import parse_query_keywords
from .. import db
from ..models import Currency, CurrencyL10n
from ..utils.query import parse_query_keywords
def get_currency_query() -> list[Currency]:
@@ -31,14 +32,17 @@ def get_currency_query() -> list[Currency]:
"""
keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0:
return Currency.query.order_by(Currency.code).all()
conditions: list[sa.BinaryExpression] = []
return db.session.scalars(
sa.select(Currency).order_by(Currency.code)).unique().all()
conditions: list[sa.ColumnElement[bool]] = []
for k in keywords:
l10n: list[CurrencyL10n] = CurrencyL10n.query\
.filter(CurrencyL10n.name.icontains(k)).all()
l10n: list[CurrencyL10n] = db.session.scalars(
sa.select(CurrencyL10n)
.where(CurrencyL10n.name.icontains(k))).all()
l10n_matches: set[str] = {x.account_code for x in l10n}
conditions.append(sa.or_(Currency.code.icontains(k),
Currency.name_l10n.icontains(k),
Currency.code.in_(l10n_matches)))
return Currency.query.filter(*conditions)\
.order_by(Currency.code).all()
return db.session.scalars(
sa.select(Currency).where(*conditions)
.order_by(Currency.code)).unique().all()
+14 -14
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -21,20 +21,20 @@ from urllib.parse import urlencode, parse_qsl
import sqlalchemy as sa
from flask import Blueprint, render_template, redirect, session, request, \
flash, url_for
flash, url_for, Response
from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.pagination import Pagination
from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.user import get_current_user_pk
from .forms import CurrencyForm
from .queries import get_currency_query
from .. import db
from ..locale import lazy_gettext
from ..models import Currency
from ..utils.cast import s
from ..utils.flash_errors import flash_form_errors
from ..utils.next_uri import inherit_next, or_next
from ..utils.pagination import Pagination
from ..utils.permission import has_permission, can_view, can_edit
from ..utils.user import get_current_user_pk
bp: Blueprint = Blueprint("currency", __name__)
"""The view blueprint for the currency management."""
@@ -74,7 +74,7 @@ def show_add_currency_form() -> str:
@bp.post("store", endpoint="store")
@has_permission(can_edit)
def add_currency() -> redirect:
def add_currency() -> Response:
"""Adds a currency.
:return: The redirection to the currency detail on success, or the currency
@@ -125,7 +125,7 @@ def show_currency_edit_form(currency: Currency) -> str:
@bp.post("<currency:currency>/update", endpoint="update")
@has_permission(can_edit)
def update_currency(currency: Currency) -> redirect:
def update_currency(currency: Currency) -> Response:
"""Updates a currency.
:param currency: The currency.
@@ -153,7 +153,7 @@ def update_currency(currency: Currency) -> redirect:
@bp.post("<currency:currency>/delete", endpoint="delete")
@has_permission(can_edit)
def delete_currency(currency: Currency) -> redirect:
def delete_currency(currency: Currency) -> Response:
"""Deletes a currency.
:param currency: The currency.
+4 -5
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -24,10 +24,9 @@ from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError
from wtforms.validators import DataRequired
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency, Account
from . import db
from .locale import lazy_gettext
from .models import Currency, Account
ACCOUNT_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the account."))
+4 -4
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -22,9 +22,9 @@ import datetime as dt
from flask import abort
from werkzeug.routing import BaseConverter
from accounting import db
from accounting.models import JournalEntry
from accounting.utils.journal_entry_types import JournalEntryType
from .. import db
from ..models import JournalEntry
from ..utils.journal_entry_types import JournalEntryType
class JournalEntryConverter(BaseConverter):
+16 -16
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -26,13 +26,13 @@ from wtforms import StringField, ValidationError, FieldList, IntegerField, \
BooleanField, FormField
from wtforms.validators import DataRequired
from accounting import db
from accounting.forms import CurrencyExists
from accounting.locale import lazy_gettext
from accounting.models import JournalEntryLineItem
from accounting.utils.offset_alias import offset_alias
from accounting.utils.strip_text import strip_text
from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
from ... import db
from ...forms import CurrencyExists
from ...locale import lazy_gettext
from ...models import JournalEntryLineItem
from ...utils.offset_alias import offset_alias
from ...utils.strip_text import strip_text
CURRENCY_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the currency."))
@@ -55,7 +55,7 @@ class SameCurrencyAsOriginalLineItems:
return
original_line_item_currency_codes: set[str] = set(db.session.scalars(
sa.select(JournalEntryLineItem.currency_code)
.filter(JournalEntryLineItem.id.in_(original_line_item_id))).all())
.where(JournalEntryLineItem.id.in_(original_line_item_id))).all())
for currency_code in original_line_item_currency_codes:
if field.data != currency_code:
raise ValidationError(lazy_gettext(
@@ -72,17 +72,17 @@ class KeepCurrencyWhenHavingOffset:
if field.data is None:
return
offset: sa.Alias = offset_alias()
original_line_items: list[JournalEntryLineItem]\
= JournalEntryLineItem.query\
original_line_items: list[JournalEntryLineItem] = db.session.scalars(
sa.select(JournalEntryLineItem)
.join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\
.filter(JournalEntryLineItem.id
isouter=True)
.where(JournalEntryLineItem.id
.in_({x.id.data for x in form.line_items
if x.id.data is not None}))\
if x.id.data is not None}))
.group_by(JournalEntryLineItem.id,
JournalEntryLineItem.currency_code)\
.having(sa.func.count(offset.c.id) > 0).all()
JournalEntryLineItem.currency_code)
.having(sa.func.count(offset.c.id) > 0)).unique().all()
for original_line_item in original_line_items:
if original_line_item.currency_code != field.data:
raise ValidationError(lazy_gettext(
@@ -152,7 +152,7 @@ class CurrencyForm(FlaskForm):
line_item_id: set[int] = {x.id.data for x in line_item_forms
if x.id.data is not None}
select: sa.Select = sa.select(sa.func.count(JournalEntryLineItem.id))\
.filter(JournalEntryLineItem.original_line_item_id
.where(JournalEntryLineItem.original_line_item_id
.in_(line_item_id))
return db.session.scalar(select) > 0
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# Copyright (c) 2023-2024 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -19,7 +19,6 @@
"""
import datetime as dt
from abc import ABC, abstractmethod
from typing import Type
import sqlalchemy as sa
from flask_babel import LazyString
@@ -28,21 +27,20 @@ from wtforms import DateField, FieldList, FormField, TextAreaField, \
BooleanField
from wtforms.validators import DataRequired, ValidationError
from accounting import db
from accounting.journal_entry.utils.account_option import AccountOption
from accounting.journal_entry.utils.description_editor import DescriptionEditor
from accounting.journal_entry.utils.original_line_items import \
get_selectable_original_line_items
from accounting.locale import lazy_gettext
from accounting.models import JournalEntry, Account, JournalEntryLineItem, \
JournalEntryCurrency
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_multiline_text
from accounting.utils.user import get_current_user_pk
from .currency import CurrencyForm, CashReceiptCurrencyForm, \
CashDisbursementCurrencyForm, TransferCurrencyForm
from .line_item import LineItemForm, DebitLineItemForm, CreditLineItemForm
from .reorder import sort_journal_entries_in
from ..utils.account_option import AccountOption
from ..utils.description_editor import DescriptionEditor
from ..utils.original_line_items import get_selectable_original_line_items
from ... import db
from ...locale import lazy_gettext
from ...models import JournalEntry, Account, JournalEntryLineItem, \
JournalEntryCurrency
from ...utils.random_id import new_id
from ...utils.strip_text import strip_multiline_text
from ...utils.user import get_current_user_pk
DATE_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please fill in the date."))
@@ -123,7 +121,7 @@ class JournalEntryForm(FlaskForm):
super().__init__(*args, **kwargs)
self.is_modified: bool = False
"""Whether the journal entry is modified during populate_obj()."""
self.collector: Type[LineItemCollector] = LineItemCollector
self.collector: type[LineItemCollector] = LineItemCollector
"""The line item collector. The default is the base abstract
collector only to provide the correct type. The subclass forms should
provide their own collectors."""
@@ -154,15 +152,16 @@ class JournalEntryForm(FlaskForm):
self.__set_date(obj, self.date.data)
obj.note = self.note.data
collector_cls: Type[LineItemCollector] = self.collector
collector_cls: type[LineItemCollector] = self.collector
collector: collector_cls = collector_cls(self, obj)
collector.collect()
to_delete: set[int] = {x.id for x in obj.line_items
if x.id not in collector.to_keep}
if len(to_delete) > 0:
JournalEntryLineItem.query\
.filter(JournalEntryLineItem.id.in_(to_delete)).delete()
db.session.execute(
sa.delete(JournalEntryLineItem)
.where(JournalEntryLineItem.id.in_(to_delete)))
self.is_modified = True
if is_new or db.session.is_modified(obj):
@@ -197,7 +196,7 @@ class JournalEntryForm(FlaskForm):
if self.max_date is not None and new_date == self.max_date:
db_min_no: int | None = db.session.scalar(
sa.select(sa.func.min(JournalEntry.no))
.filter(JournalEntry.date == new_date))
.where(JournalEntry.date == new_date))
if db_min_no is None:
obj.date = new_date
obj.no = 1
@@ -207,8 +206,9 @@ class JournalEntryForm(FlaskForm):
sort_journal_entries_in(new_date)
else:
sort_journal_entries_in(new_date, obj.id)
count: int = JournalEntry.query\
.filter(JournalEntry.date == new_date).count()
count: int = db.session.scalar(
sa.select(sa.func.count(JournalEntry.id))
.where(JournalEntry.date == new_date))
obj.date = new_date
obj.no = count + 1
@@ -223,7 +223,7 @@ class JournalEntryForm(FlaskForm):
if not (x.code[0] == "2" and x.is_need_offset)]
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntryLineItem.account_id)
.filter(JournalEntryLineItem.is_debit)
.where(JournalEntryLineItem.is_debit)
.group_by(JournalEntryLineItem.account_id)).all())
for account in accounts:
account.is_in_use = account.id in in_use
@@ -240,7 +240,7 @@ class JournalEntryForm(FlaskForm):
if not (x.code[0] == "1" and x.is_need_offset)]
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntryLineItem.account_id)
.filter(sa.not_(JournalEntryLineItem.is_debit))
.where(sa.not_(JournalEntryLineItem.is_debit))
.group_by(JournalEntryLineItem.account_id)).all())
for account in accounts:
account.is_in_use = account.id in in_use
@@ -290,7 +290,7 @@ class JournalEntryForm(FlaskForm):
return None
select: sa.Select = sa.select(sa.func.max(JournalEntry.date))\
.join(JournalEntryLineItem)\
.filter(JournalEntryLineItem.id.in_(original_line_item_id))
.where(JournalEntryLineItem.id.in_(original_line_item_id))
return db.session.scalar(select)
@property
@@ -303,7 +303,7 @@ class JournalEntryForm(FlaskForm):
if x.id.data is not None}
select: sa.Select = sa.select(sa.func.min(JournalEntry.date))\
.join(JournalEntryLineItem)\
.filter(JournalEntryLineItem.original_line_item_id
.where(JournalEntryLineItem.original_line_item_id
.in_(line_item_id))
return db.session.scalar(select)
+17 -16
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -27,15 +27,15 @@ from sqlalchemy.orm import selectinload
from wtforms import StringField, ValidationError, DecimalField, IntegerField
from wtforms.validators import Optional
from accounting import db
from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
from ... import db
from ...forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
IsCreditAccount
from accounting.locale import lazy_gettext
from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.template_filters import format_amount
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk
from ...locale import lazy_gettext
from ...models import Account, JournalEntry, JournalEntryLineItem
from ...template_filters import format_amount
from ...utils.random_id import new_id
from ...utils.strip_text import strip_text
from ...utils.user import get_current_user_pk
class OriginalLineItemExists:
@@ -202,7 +202,7 @@ class NotExceedingOriginalLineItemNetBalance:
else_=-JournalEntryLineItem.amount))
offset_total_but_form: Decimal | None = db.session.scalar(
sa.select(offset_total_func)
.filter(JournalEntryLineItem.original_line_item_id
.where(JournalEntryLineItem.original_line_item_id
== original_line_item.id,
JournalEntryLineItem.id.not_in(existing_line_item_id)))
if offset_total_but_form is None:
@@ -231,7 +231,7 @@ class NotLessThanOffsetTotal:
(JournalEntryLineItem.is_debit != is_debit,
JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)))\
.filter(JournalEntryLineItem.original_line_item_id == form.id.data)
.where(JournalEntryLineItem.original_line_item_id == form.id.data)
offset_total: Decimal | None = db.session.scalar(select_offset_total)
if offset_total is not None and field.data < offset_total:
raise ValidationError(lazy_gettext(
@@ -353,13 +353,14 @@ class LineItemForm(FlaskForm):
def get_offsets() -> list[JournalEntryLineItem]:
if not self.is_need_offset or self.id.data is None:
return []
return JournalEntryLineItem.query.join(JournalEntry)\
.filter(JournalEntryLineItem.original_line_item_id
== self.id.data)\
return db.session.scalars(
sa.select(JournalEntryLineItem).join(JournalEntry)
.where(JournalEntryLineItem.original_line_item_id
== self.id.data)
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.no)\
JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.journal_entry),
selectinload(JournalEntryLineItem.account)).all()
selectinload(JournalEntryLineItem.account))).all()
setattr(self, "__offsets", get_offsets())
return getattr(self, "__offsets")
+10 -9
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -22,8 +22,8 @@ import datetime as dt
import sqlalchemy as sa
from flask import request
from accounting import db
from accounting.models import JournalEntry
from ... import db
from ...models import JournalEntry
def sort_journal_entries_in(date: dt.date, exclude: int | None = None) -> None:
@@ -34,12 +34,12 @@ def sort_journal_entries_in(date: dt.date, exclude: int | None = None) -> None:
:param exclude: The journal entry ID to exclude.
:return: None.
"""
conditions: list[sa.BinaryExpression] = [JournalEntry.date == date]
conditions: list[sa.ColumnElement[bool]] = [JournalEntry.date == date]
if exclude is not None:
conditions.append(JournalEntry.id != exclude)
journal_entries: list[JournalEntry] = JournalEntry.query\
.filter(*conditions)\
.order_by(JournalEntry.no).all()
journal_entries: list[JournalEntry] = db.session.scalars(
sa.select(JournalEntry).where(*conditions)
.order_by(JournalEntry.no)).all()
for i in range(len(journal_entries)):
if journal_entries[i].no != i + 1:
journal_entries[i].no = i + 1
@@ -63,8 +63,9 @@ class JournalEntryReorderForm:
:return:
"""
journal_entries: list[JournalEntry] = JournalEntry.query\
.filter(JournalEntry.date == self.date).all()
journal_entries: list[JournalEntry] = db.session.scalars(
sa.select(JournalEntry)
.where(JournalEntry.date == self.date)).all()
# Collects the specified order.
orders: dict[JournalEntry, int] = {}
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,7 +17,7 @@
"""The account option for the journal entry management.
"""
from accounting.models import Account
from ...models import Account
class AccountOption:
@@ -28,7 +28,7 @@ class AccountOption:
:param account: The account.
"""
self.id: str = account.id
self.id: int = account.id
"""The account ID."""
self.code: str = account.code
"""The account code."""
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -22,9 +22,9 @@ from typing import Literal
import sqlalchemy as sa
from accounting import db
from accounting.models import Account, JournalEntryLineItem
from accounting.utils.options import options, Recurring
from ... import db
from ...models import Account, JournalEntryLineItem
from ...utils.options import options, Recurring
class DescriptionAccount:
@@ -272,15 +272,17 @@ class DescriptionEditor:
select: sa.Select = sa.Select(debit_credit, tag_type, tag,
JournalEntryLineItem.account_id,
sa.func.count().label("freq"))\
.filter(JournalEntryLineItem.description.is_not(None),
.where(JournalEntryLineItem.description.is_not(None),
JournalEntryLineItem.description.like("_%—_%"),
JournalEntryLineItem.original_line_item_id.is_(None))\
.group_by(debit_credit, tag_type, tag,
JournalEntryLineItem.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()}
= {x.id: x for x in db.session.scalars(
sa.select(Account)
.where(Account.id.in_({x.account_id for x in result})))
.unique()}
debit_credit_dict: dict[Literal["debit", "credit"],
DescriptionDebitCredit] \
= {x.debit_credit: x for x in {self.debit, self.credit}}
@@ -315,20 +317,21 @@ class DescriptionEditor:
if len(codes) == 0:
return {}
def get_condition(code0: str) -> sa.BinaryExpression:
m: re.Match = re.match(r"^(\d{4})-(\d{3})$", code0)
assert m is not None,\
def get_condition(code0: str) -> sa.ColumnElement[bool]:
m: re.Match[str] | None = re.match(r"^(\d{4})-(\d{3})$", code0)
assert m is not None, \
f"Malformed account code \"{code0}\" for regular transactions."
return sa.and_(Account.base_code == m.group(1),
Account.no == int(m.group(2)))
conditions: list[sa.BinaryExpression] \
conditions: list[sa.ColumnElement[bool]] \
= [get_condition(x) for x in codes]
accounts: dict[str, Account] \
= {x.code: x for x in
Account.query.filter(sa.or_(*conditions)).all()}
db.session.scalars(
sa.select(Account).where(sa.or_(*conditions))).unique()}
for code in codes:
assert code in accounts,\
assert code in accounts, \
f"Unknown account \"{code}\" for regular transactions."
return accounts
+12 -13
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -18,18 +18,16 @@
"""
from abc import ABC, abstractmethod
from typing import Type
from flask import render_template, request, abort
from flask_wtf import FlaskForm
from accounting.journal_entry.forms import JournalEntryForm, \
CashReceiptJournalEntryForm, CashDisbursementJournalEntryForm, \
TransferJournalEntryForm
from accounting.journal_entry.forms.line_item import LineItemForm
from accounting.models import JournalEntry
from accounting.template_globals import default_currency_code
from accounting.utils.journal_entry_types import JournalEntryType
from ..forms import JournalEntryForm, CashReceiptJournalEntryForm, \
CashDisbursementJournalEntryForm, TransferJournalEntryForm
from ..forms.line_item import LineItemForm
from ...models import JournalEntry
from ...template_globals import default_currency_code
from ...utils.journal_entry_types import JournalEntryType
class JournalEntryOperator(ABC):
@@ -39,7 +37,7 @@ class JournalEntryOperator(ABC):
@property
@abstractmethod
def form(self) -> Type[JournalEntryForm]:
def form(self) -> type[JournalEntryForm]:
"""Returns the form class.
:return: The form class.
@@ -100,7 +98,7 @@ class CashReceiptJournalEntry(JournalEntryOperator):
"""The order when checking the journal entry operator."""
@property
def form(self) -> Type[JournalEntryForm]:
def form(self) -> type[JournalEntryForm]:
"""Returns the form class.
:return: The form class.
@@ -170,7 +168,7 @@ class CashDisbursementJournalEntry(JournalEntryOperator):
"""The order when checking the journal entry operator."""
@property
def form(self) -> Type[JournalEntryForm]:
def form(self) -> type[JournalEntryForm]:
"""Returns the form class.
:return: The form class.
@@ -243,7 +241,7 @@ class TransferJournalEntry(JournalEntryOperator):
"""The order when checking the journal entry operator."""
@property
def form(self) -> Type[JournalEntryForm]:
def form(self) -> type[JournalEntryForm]:
"""Returns the form class.
:return: The form class.
@@ -334,3 +332,4 @@ def get_journal_entry_op(journal_entry: JournalEntry,
key=lambda x: x.CHECK_ORDER):
if journal_entry_type.is_my_type(journal_entry):
return journal_entry_type
assert False
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -22,9 +22,9 @@ from decimal import Decimal
import sqlalchemy as sa
from sqlalchemy.orm import selectinload
from accounting import db
from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.utils.offset_alias import offset_alias
from ... import db
from ...models import Account, JournalEntry, JournalEntryLineItem
from ...utils.offset_alias import offset_alias
def get_selectable_original_line_items(
@@ -46,8 +46,8 @@ def get_selectable_original_line_items(
(offset.c.id.in_(line_item_id_on_form), 0),
(offset.c.is_debit == JournalEntryLineItem.is_debit, offset.c.amount),
else_=-offset.c.amount))).label("net_balance")
conditions: list[sa.BinaryExpression] = [Account.is_need_offset]
sub_conditions: list[sa.BinaryExpression] = []
conditions: list[sa.ColumnElement[bool]] = [Account.is_need_offset]
sub_conditions: list[sa.ColumnElement[bool]] = []
if is_payable:
sub_conditions.append(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)))
@@ -61,20 +61,21 @@ def get_selectable_original_line_items(
.join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\
.filter(*conditions)\
.where(*conditions)\
.group_by(JournalEntryLineItem.id)\
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
net_balances: dict[int, Decimal] \
= {x.id: x.net_balance
for x in db.session.execute(select_net_balances).all()}
line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query\
.filter(JournalEntryLineItem.id.in_({x for x in net_balances}))\
.join(JournalEntry)\
for x in db.session.execute(select_net_balances)}
line_items: list[JournalEntryLineItem] = db.session.scalars(
sa.select(JournalEntryLineItem)
.where(JournalEntryLineItem.id.in_({x for x in net_balances}))
.join(JournalEntry)
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)\
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.journal_entry)).all()
selectinload(JournalEntryLineItem.journal_entry))).all()
line_items.reverse()
for line_item in line_items:
line_item.net_balance = line_item.amount \
+19 -19
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# Copyright (c) 2023-2024 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -22,24 +22,24 @@ from urllib.parse import parse_qsl, urlencode
import sqlalchemy as sa
from flask import Blueprint, render_template, session, redirect, request, \
flash, url_for
flash, url_for, Response
from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import JournalEntry
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.journal_entry_types import JournalEntryType
from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.timezone import get_tz_today
from accounting.utils.user import get_current_user_pk
from .forms import sort_journal_entries_in, JournalEntryReorderForm
from .template_filters import with_type, to_transfer, format_amount_input, \
text2html
from .utils.operators import JournalEntryOperator, JOURNAL_ENTRY_TYPE_TO_OP, \
get_journal_entry_op
from .. import db
from ..locale import lazy_gettext
from ..models import JournalEntry
from ..utils.cast import s
from ..utils.flash_errors import flash_form_errors
from ..utils.journal_entry_types import JournalEntryType
from ..utils.next_uri import inherit_next, or_next
from ..utils.permission import has_permission, can_view, can_edit
from ..utils.timezone import get_tz_today
from ..utils.user import get_current_user_pk
bp: Blueprint = Blueprint("journal-entry", __name__)
"""The view blueprint for the journal entry management."""
@@ -74,7 +74,7 @@ def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
@bp.post("store/<journalEntryType:journal_entry_type>", endpoint="store")
@has_permission(can_edit)
def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect:
def add_journal_entry(journal_entry_type: JournalEntryType) -> Response:
"""Adds a journal entry.
:param journal_entry_type: The journal entry type.
@@ -136,7 +136,7 @@ def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
@bp.post("<journalEntry:journal_entry>/update", endpoint="update")
@has_permission(can_edit)
def update_journal_entry(journal_entry: JournalEntry) -> redirect:
def update_journal_entry(journal_entry: JournalEntry) -> Response:
"""Updates a journal entry.
:param journal_entry: The journal entry.
@@ -169,7 +169,7 @@ def update_journal_entry(journal_entry: JournalEntry) -> redirect:
@bp.post("<journalEntry:journal_entry>/delete", endpoint="delete")
@has_permission(can_edit)
def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
def delete_journal_entry(journal_entry: JournalEntry) -> Response:
"""Deletes a journal entry.
:param journal_entry: The journal entry.
@@ -195,16 +195,16 @@ def show_journal_entry_order(date: dt.date) -> str:
:param date: The date.
:return: The order of the journal entries in the date.
"""
journal_entries: list[JournalEntry] = JournalEntry.query \
.filter(JournalEntry.date == date) \
.order_by(JournalEntry.no).all()
journal_entries: list[JournalEntry] = db.session.scalars(
sa.select(JournalEntry).where(JournalEntry.date == date)
.order_by(JournalEntry.no)).all()
return render_template("accounting/journal-entry/order.html",
date=date, list=journal_entries)
@bp.post("dates/<date:date>", endpoint="sort")
@has_permission(can_edit)
def sort_journal_entries(date: dt.date) -> redirect:
def sort_journal_entries(date: dt.date) -> Response:
"""Reorders the journal entries in a date.
:param date: The date.
+112 -100
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -22,20 +22,19 @@ from __future__ import annotations
import datetime as dt
import re
from decimal import Decimal
from typing import Type, Self
from typing import Self
import sqlalchemy as sa
from babel import Locale
from flask_babel import get_locale, get_babel
from sqlalchemy import text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from accounting import db
from accounting.locale import gettext
from accounting.utils.user import user_cls, user_pk_column
from . import db
from .locale import gettext
from .utils.user import base_cls, user_cls, user_pk_column
class BaseAccount(db.Model):
class BaseAccount(base_cls):
"""A base account."""
__tablename__ = "accounting_base_accounts"
"""The table name."""
@@ -44,9 +43,9 @@ class BaseAccount(db.Model):
title_l10n: Mapped[str] = mapped_column("title")
"""The title."""
l10n: Mapped[list[BaseAccountL10n]] \
= db.relationship(back_populates="account", lazy=False)
= relationship(back_populates="account", lazy=False)
"""The localized titles."""
accounts: Mapped[list[Account]] = db.relationship(back_populates="base")
accounts: Mapped[list[Account]] = relationship(back_populates="base")
"""The descendant accounts under the base account."""
def __str__(self) -> str:
@@ -79,16 +78,16 @@ class BaseAccount(db.Model):
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
class BaseAccountL10n(db.Model):
class BaseAccountL10n(base_cls):
"""A localized base account title."""
__tablename__ = "accounting_base_accounts_l10n"
"""The table name."""
account_code: Mapped[str] \
= mapped_column(db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
= mapped_column(sa.ForeignKey(BaseAccount.code, onupdate="CASCADE",
ondelete="CASCADE"),
primary_key=True)
"""The account code."""
account: Mapped[BaseAccount] = db.relationship(back_populates="l10n")
account: Mapped[BaseAccount] = relationship(back_populates="l10n")
"""The account."""
locale: Mapped[str] = mapped_column(primary_key=True)
"""The locale."""
@@ -96,47 +95,47 @@ class BaseAccountL10n(db.Model):
"""The localized title."""
class Account(db.Model):
class Account(base_cls):
"""An account."""
__tablename__ = "accounting_accounts"
"""The table name."""
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
"""The account ID."""
base_code: Mapped[str] \
= mapped_column(db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
= mapped_column(sa.ForeignKey(BaseAccount.code, onupdate="CASCADE",
ondelete="CASCADE"))
"""The code of the base account."""
base: Mapped[BaseAccount] = db.relationship(back_populates="accounts")
base: Mapped[BaseAccount] = relationship(back_populates="accounts")
"""The base account."""
no: Mapped[int] = mapped_column(default=text("1"))
no: Mapped[int] = mapped_column(default=sa.text("1"))
"""The account number under the base account."""
title_l10n: Mapped[str] = mapped_column("title")
"""The title."""
is_need_offset: Mapped[bool] = mapped_column(default=False)
"""Whether the journal entry line items of this account need offset."""
created_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
= mapped_column(sa.DateTime(timezone=True),
server_default=sa.func.now())
"""The date and time when this record was created."""
created_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the user who created the record."""
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
"""The user who created the record."""
updated_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
= mapped_column(sa.DateTime(timezone=True),
server_default=sa.func.now())
"""The date and time when this record was last updated."""
updated_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the last user who updated the record."""
updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
updated_by: Mapped[user_cls] = relationship(foreign_keys=updated_by_id)
"""The last user who updated the record."""
l10n: Mapped[list[AccountL10n]] \
= db.relationship(back_populates="account", lazy=False)
= relationship(back_populates="account", lazy=False)
"""The localized titles."""
line_items: Mapped[list[JournalEntryLineItem]] \
= db.relationship(back_populates="account")
= relationship(back_populates="account")
"""The journal entry line items."""
CASH_CODE: str = "1111-001"
@@ -268,9 +267,10 @@ class Account(db.Model):
:return: None.
"""
AccountL10n.query.filter(AccountL10n.account == self).delete()
cls: Type[Self] = self.__class__
cls.query.filter(cls.id == self.id).delete()
db.session.execute(sa.delete(AccountL10n)
.where(AccountL10n.account == self))
cls: type[Self] = self.__class__
db.session.execute(sa.delete(cls).where(cls.id == self.id))
@classmethod
def find_by_code(cls, code: str) -> Self | None:
@@ -279,11 +279,12 @@ class Account(db.Model):
:param code: The code.
:return: The account, or None if this account does not exist.
"""
m = re.match(r"^([1-9]{4})-(\d{3})$", code)
m: re.Match[str] | None = re.match(r"^([1-9]{4})-(\d{3})$", code)
if m is None:
return None
return cls.query.filter(cls.base_code == m.group(1),
cls.no == int(m.group(2))).first()
return db.session.scalar(
sa.select(cls).where(cls.base_code == m.group(1),
cls.no == int(m.group(2))))
@classmethod
def selectable_debit(cls) -> list[Self]:
@@ -292,7 +293,9 @@ class Account(db.Model):
:return: The selectable debit accounts.
"""
return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
return db.session.scalars(
sa.select(cls)
.where(sa.or_(cls.base_code.startswith("1"),
sa.and_(cls.base_code.startswith("2"),
sa.not_(cls.is_need_offset)),
cls.base_code.startswith("3"),
@@ -304,8 +307,8 @@ class Account(db.Model):
cls.base_code.startswith("78"),
cls.base_code.startswith("8"),
cls.base_code.startswith("9")),
cls.base_code != "3353")\
.order_by(cls.base_code, cls.no).all()
cls.base_code != "3353")
.order_by(cls.base_code, cls.no)).unique().all()
@classmethod
def selectable_credit(cls) -> list[Self]:
@@ -314,7 +317,9 @@ class Account(db.Model):
:return: The selectable debit accounts.
"""
return cls.query.filter(sa.or_(sa.and_(cls.base_code.startswith("1"),
return db.session.scalars(
sa.select(cls)
.where(sa.or_(sa.and_(cls.base_code.startswith("1"),
sa.not_(cls.is_need_offset)),
cls.base_code.startswith("2"),
cls.base_code.startswith("3"),
@@ -325,8 +330,8 @@ class Account(db.Model):
cls.base_code.startswith("74"),
cls.base_code.startswith("8"),
cls.base_code.startswith("9")),
cls.base_code != "3353")\
.order_by(cls.base_code, cls.no).all()
cls.base_code != "3353")
.order_by(cls.base_code, cls.no)).unique().all()
@classmethod
def cash(cls) -> Self:
@@ -334,7 +339,9 @@ class Account(db.Model):
:return: The cash account
"""
return cls.find_by_code(cls.CASH_CODE)
account: Self | None = cls.find_by_code(cls.CASH_CODE)
assert account is not None
return account
@classmethod
def accumulated_change(cls) -> Self:
@@ -342,19 +349,21 @@ class Account(db.Model):
:return: The accumulated-change account
"""
return cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE)
account: Self | None = cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE)
assert account is not None
return account
class AccountL10n(db.Model):
class AccountL10n(base_cls):
"""A localized account title."""
__tablename__ = "accounting_accounts_l10n"
"""The table name."""
account_id: Mapped[int] \
= mapped_column(db.ForeignKey(Account.id, onupdate="CASCADE",
= mapped_column(sa.ForeignKey(Account.id, onupdate="CASCADE",
ondelete="CASCADE"),
primary_key=True)
"""The account ID."""
account: Mapped[Account] = db.relationship(back_populates="l10n")
account: Mapped[Account] = relationship(back_populates="l10n")
"""The account."""
locale: Mapped[str] = mapped_column(primary_key=True)
"""The locale."""
@@ -362,7 +371,7 @@ class AccountL10n(db.Model):
"""The localized title."""
class Currency(db.Model):
class Currency(base_cls):
"""A currency."""
__tablename__ = "accounting_currencies"
"""The table name."""
@@ -371,29 +380,29 @@ class Currency(db.Model):
name_l10n: Mapped[str] = mapped_column("name")
"""The currency name."""
created_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
= mapped_column(sa.DateTime(timezone=True),
server_default=sa.func.now())
"""The date and time when this record was created."""
created_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the user who created the record."""
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
"""The user who created the record."""
updated_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
= mapped_column(sa.DateTime(timezone=True),
server_default=sa.func.now())
"""The date and time when this record was last updated."""
updated_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the last user who updated the record."""
updated_by: Mapped[user_cls] \
= db.relationship(foreign_keys=updated_by_id)
= relationship(foreign_keys=updated_by_id)
"""The last user who updated the record."""
l10n: Mapped[list[CurrencyL10n]] \
= db.relationship(back_populates="currency", lazy=False)
= relationship(back_populates="currency", lazy=False)
"""The localized names."""
line_items: Mapped[list[JournalEntryLineItem]] \
= db.relationship(back_populates="currency")
= relationship(back_populates="currency")
"""The journal entry line items."""
def __str__(self) -> str:
@@ -458,7 +467,7 @@ class Currency(db.Model):
:return: True if the currency can be deleted, or False otherwise.
"""
from accounting.template_globals import default_currency_code
from .template_globals import default_currency_code
if self.code == default_currency_code():
return False
return len(self.line_items) == 0
@@ -468,21 +477,22 @@ class Currency(db.Model):
:return: None.
"""
CurrencyL10n.query.filter(CurrencyL10n.currency == self).delete()
cls: Type[Self] = self.__class__
cls.query.filter(cls.code == self.code).delete()
db.session.execute(
sa.delete(CurrencyL10n)
.where(CurrencyL10n.currency_code == self.code))
db.session.delete(self)
class CurrencyL10n(db.Model):
class CurrencyL10n(base_cls):
"""A localized currency name."""
__tablename__ = "accounting_currencies_l10n"
"""The table name."""
currency_code: Mapped[str] \
= mapped_column(db.ForeignKey(Currency.code, onupdate="CASCADE",
= mapped_column(sa.ForeignKey(Currency.code, onupdate="CASCADE",
ondelete="CASCADE"),
primary_key=True)
"""The currency code."""
currency: Mapped[Currency] = db.relationship(back_populates="l10n")
currency: Mapped[Currency] = relationship(back_populates="l10n")
"""The currency."""
locale: Mapped[str] = mapped_column(primary_key=True)
"""The locale."""
@@ -533,7 +543,7 @@ class JournalEntryCurrency:
return sum([x.amount for x in self.credit])
class JournalEntry(db.Model):
class JournalEntry(base_cls):
"""A journal entry."""
__tablename__ = "accounting_journal_entries"
"""The table name."""
@@ -541,30 +551,30 @@ class JournalEntry(db.Model):
"""The journal entry ID."""
date: Mapped[dt.date]
"""The date."""
no: Mapped[int] = mapped_column(default=text("1"))
no: Mapped[int] = mapped_column(default=sa.text("1"))
"""The journal entry number under the date."""
note: Mapped[str | None]
"""The note."""
created_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
= mapped_column(sa.DateTime(timezone=True),
server_default=sa.func.now())
"""The date and time when this record was created."""
created_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the user who created the record."""
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
"""The user who created the record."""
updated_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
= mapped_column(sa.DateTime(timezone=True),
server_default=sa.func.now())
"""The date and time when this record was last updated."""
updated_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the last user who updated the record."""
updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
updated_by: Mapped[user_cls] = relationship(foreign_keys=updated_by_id)
"""The last user who updated the record."""
line_items: Mapped[list[JournalEntryLineItem]] \
= db.relationship(back_populates="journal_entry")
= relationship(back_populates="journal_entry")
"""The line items."""
def __str__(self) -> str:
@@ -645,48 +655,49 @@ class JournalEntry(db.Model):
:return: None.
"""
JournalEntryLineItem.query\
.filter(JournalEntryLineItem.journal_entry_id == self.id).delete()
db.session.execute(
sa.delete(JournalEntryLineItem)
.where(JournalEntryLineItem.journal_entry_id == self.id))
db.session.delete(self)
class JournalEntryLineItem(db.Model):
class JournalEntryLineItem(base_cls):
"""A line item in the journal entry."""
__tablename__ = "accounting_journal_entry_line_items"
"""The table name."""
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
"""The line item ID."""
journal_entry_id: Mapped[int] \
= mapped_column(db.ForeignKey(JournalEntry.id, onupdate="CASCADE",
= mapped_column(sa.ForeignKey(JournalEntry.id, onupdate="CASCADE",
ondelete="CASCADE"))
"""The journal entry ID."""
journal_entry: Mapped[JournalEntry] \
= db.relationship(back_populates="line_items")
= relationship(back_populates="line_items")
"""The journal entry."""
is_debit: Mapped[bool]
"""True for a debit line item, or False for a credit line item."""
no: Mapped[int]
"""The line item number under the journal entry and debit or credit."""
original_line_item_id: Mapped[int | None] \
= mapped_column(db.ForeignKey(id, onupdate="CASCADE"))
= mapped_column(sa.ForeignKey(id, onupdate="CASCADE"))
"""The ID of the original line item."""
original_line_item: Mapped[JournalEntryLineItem | None] \
= db.relationship(remote_side=id, passive_deletes=True)
= relationship(remote_side=id, passive_deletes=True)
"""The original line item."""
currency_code: Mapped[str] \
= mapped_column(db.ForeignKey(Currency.code, onupdate="CASCADE"))
= mapped_column(sa.ForeignKey(Currency.code, onupdate="CASCADE"))
"""The currency code."""
currency: Mapped[Currency] = db.relationship(back_populates="line_items")
currency: Mapped[Currency] = relationship(back_populates="line_items")
"""The currency."""
account_id: Mapped[int] \
= mapped_column(db.ForeignKey(Account.id, onupdate="CASCADE"))
= mapped_column(sa.ForeignKey(Account.id, onupdate="CASCADE"))
"""The account ID."""
account: Mapped[Account] \
= db.relationship(back_populates="line_items", lazy=False)
= relationship(back_populates="line_items", lazy=False)
"""The account."""
description: Mapped[str | None]
"""The description."""
amount: Mapped[Decimal] = mapped_column(db.Numeric(14, 2))
amount: Mapped[Decimal] = mapped_column(sa.Numeric(14, 2))
"""The amount."""
def __str__(self) -> str:
@@ -695,7 +706,7 @@ class JournalEntryLineItem(db.Model):
:return: The string representation of the line item.
"""
if not hasattr(self, "__str"):
from accounting.template_filters import format_date, format_amount
from .template_filters import format_date, format_amount
setattr(self, "__str",
gettext("%(date)s %(description)s %(amount)s",
date=format_date(self.journal_entry.date),
@@ -811,11 +822,12 @@ class JournalEntryLineItem(db.Model):
:return: The offset items.
"""
if not hasattr(self, "__offsets"):
cls: Type[Self] = self.__class__
offsets: list[Self] = cls.query.join(JournalEntry)\
.filter(JournalEntryLineItem.original_line_item_id == self.id)\
cls: type[Self] = self.__class__
offsets: list[Self] = db.session.scalars(
sa.select(cls).join(JournalEntry)
.where(cls.original_line_item_id == self.id)
.order_by(JournalEntry.date, JournalEntry.no,
cls.is_debit, cls.no).all()
cls.is_debit, cls.no)).unique().all()
setattr(self, "__offsets", offsets)
return getattr(self, "__offsets")
@@ -876,29 +888,29 @@ class JournalEntryLineItem(db.Model):
format_amount(self.amount)]
class Option(db.Model):
class Option(base_cls):
"""An option."""
__tablename__ = "accounting_options"
"""The table name."""
name: Mapped[str] = mapped_column(primary_key=True)
"""The name."""
value: Mapped[str] = mapped_column(db.Text)
value: Mapped[str] = mapped_column(sa.Text)
"""The option value."""
created_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
= mapped_column(sa.DateTime(timezone=True),
server_default=sa.func.now())
"""The date and time when this record was created."""
created_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the user who created the record."""
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
"""The user who created the record."""
updated_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
= mapped_column(sa.DateTime(timezone=True),
server_default=sa.func.now())
"""The date and time when this record was last updated."""
updated_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the last user who updated the record."""
updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
updated_by: Mapped[user_cls] = relationship(foreign_keys=updated_by_id)
"""The last user who updated the record."""
+7 -7
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -23,13 +23,13 @@ from flask_wtf import FlaskForm
from wtforms import StringField, FieldList, FormField, IntegerField
from wtforms.validators import DataRequired, ValidationError
from accounting.forms import ACCOUNT_REQUIRED, CurrencyExists, AccountExists, \
from ..forms import ACCOUNT_REQUIRED, CurrencyExists, AccountExists, \
IsDebitAccount, IsCreditAccount
from accounting.locale import lazy_gettext
from accounting.models import Account
from accounting.utils.current_account import CurrentAccount
from accounting.utils.options import Options
from accounting.utils.strip_text import strip_text
from ..locale import lazy_gettext
from ..models import Account
from ..utils.current_account import CurrentAccount
from ..utils.options import Options
from ..utils.strip_text import strip_text
class CurrentAccountExists:
+9 -9
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,16 +20,16 @@
from urllib.parse import parse_qsl, urlencode
from flask import Blueprint, render_template, redirect, session, request, \
flash, url_for
flash, url_for, Response
from werkzeug.datastructures import ImmutableMultiDict
from accounting.locale import lazy_gettext
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next
from accounting.utils.options import options
from accounting.utils.permission import has_permission, can_admin
from .forms import OptionForm
from ..locale import lazy_gettext
from ..utils.cast import s
from ..utils.flash_errors import flash_form_errors
from ..utils.next_uri import inherit_next
from ..utils.options import options
from ..utils.permission import has_permission, can_admin
bp: Blueprint = Blueprint("option", __name__)
"""The view blueprint for the currency management."""
@@ -64,7 +64,7 @@ def show_option_form() -> str:
@bp.post("update", endpoint="update")
@has_permission(can_admin)
def update_options() -> redirect:
def update_options() -> Response:
"""Updates the options.
:return: The redirection to the option form.
+3 -3
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -22,9 +22,9 @@ import re
from flask import abort
from werkzeug.routing import BaseConverter
from accounting.models import Account
from accounting.utils.current_account import CurrentAccount
from .period import Period, get_period
from ..models import Account
from ..utils.current_account import CurrentAccount
class PeriodConverter(BaseConverter):
+8 -5
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023-2024 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -23,11 +23,14 @@ This file is largely taken from the NanoParma ERP project, first written in
import datetime as dt
from collections.abc import Callable
from accounting.models import JournalEntry
from accounting.utils.timezone import get_tz_today
import sqlalchemy as sa
from .period import Period
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
from ... import db
from ...models import JournalEntry
from ...utils.timezone import get_tz_today
class PeriodChooser:
@@ -62,8 +65,8 @@ class PeriodChooser:
self.url_template: str = get_url(TemplatePeriod())
"""The URL template."""
first: JournalEntry | None \
= JournalEntry.query.order_by(JournalEntry.date).first()
first: JournalEntry | None = db.session.scalar(
sa.select(JournalEntry).order_by(JournalEntry.date))
start: dt.date | None = None if first is None else first.date
# Attributes
+2 -2
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -19,7 +19,7 @@
"""
import datetime as dt
from accounting.locale import gettext
from ...locale import gettext
def get_desc(start: dt.date | None, end: dt.date | None) -> str:
+3 -3
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -21,7 +21,6 @@ import calendar
import datetime as dt
import re
from collections.abc import Callable
from typing import Type
from .period import Period
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
@@ -40,7 +39,7 @@ def get_period(spec: str | None = None) -> Period:
"""
if spec is None:
return ThisMonth()
named_periods: dict[str, Type[Callable[[], Period]]] = {
named_periods: dict[str, type[Callable[[], Period]]] = {
"this-month": lambda: ThisMonth(),
"last-month": lambda: LastMonth(),
"since-last-month": lambda: SinceLastMonth(),
@@ -68,6 +67,7 @@ def __parse_spec(text: str) -> tuple[dt.date | None, dt.date | None]:
"""
if text == "-":
return None, None
m: re.Match[str] | None
m = re.match(f"^{DATE_SPEC_RE}$", text)
if m is not None:
return __get_start(m[1], m[2], m[3]), \
+3 -3
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023-2024 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -19,10 +19,10 @@
"""
import datetime as dt
from accounting.locale import gettext
from accounting.utils.timezone import get_tz_today
from .month_end import month_end
from .period import Period
from ...locale import gettext
from ...utils.timezone import get_tz_today
class ThisMonth(Period):
+11 -5
View File
@@ -27,12 +27,18 @@ def get_spec(start: dt.date | None, end: dt.date | None) -> str:
:param end: The end of the period.
:return: The period specification.
"""
if start is None and end is None:
return "-"
if end is None:
return __get_since_spec(start)
if start is None:
return __get_until_spec(end)
return "-" if end is None else __get_until_spec(end)
return __get_since_spec(start) if end is None else __get_spec(start, end)
def __get_spec(start: dt.date, end: dt.date) -> str:
"""Returns the period specification with both start and end.
:param start: The start of the period.
:param end: The end of the period.
:return: The period specification.
"""
try:
return __get_year_spec(start, end)
except ValueError:
+30 -29
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -22,20 +22,18 @@ from decimal import Decimal
import sqlalchemy as sa
from flask import render_template, Response
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
from ..period import Period, PeriodChooser
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from ..utils.option_link import OptionLink
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.urls import ledger_url, balance_sheet_url, income_statement_url
from ... import db
from ...locale import gettext
from ...models import Currency, BaseAccount, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url, balance_sheet_url, \
income_statement_url
class ReportAccount:
@@ -121,9 +119,9 @@ class AccountCollector:
:return: The balances.
"""
sub_conditions: list[sa.BinaryExpression] \
sub_conditions: list[sa.ColumnElement[bool]] \
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
conditions: list[sa.BinaryExpression] \
conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code,
sa.or_(*sub_conditions)]
if self.__period.end is not None:
@@ -135,16 +133,17 @@ class AccountCollector:
= sa.select(Account.id, Account.base_code, Account.no,
balance_func)\
.join(JournalEntry).join(Account)\
.filter(*conditions)\
.where(*conditions)\
.group_by(Account.id, Account.base_code, Account.no)\
.having(balance_func != 0)\
.order_by(Account.base_code, Account.no)
account_balances: list[sa.Row] \
= db.session.execute(select_balance).all()
self.__all_accounts: list[Account] = Account.query\
.filter(sa.or_(Account.id.in_({x.id for x in account_balances}),
self.__all_accounts: list[Account] = db.session.scalars(
sa.select(Account)
.where(sa.or_(Account.id.in_({x.id for x in account_balances}),
Account.base_code == "3351",
Account.base_code == "3353")).all()
Account.base_code == "3353"))).unique().all()
"""The accounts."""
account_by_id: dict[int, Account] \
= {x.id: x for x in self.__all_accounts}
@@ -180,7 +179,7 @@ class AccountCollector:
"""
if self.__period.start is None:
return None
conditions: list[sa.BinaryExpression] \
conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code,
JournalEntry.date < self.__period.start]
return self.__query_balance(conditions)
@@ -199,7 +198,7 @@ class AccountCollector:
:return: The net income or loss for current period.
"""
conditions: list[sa.BinaryExpression] \
conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code]
if self.__period.start is not None:
conditions.append(JournalEntry.date >= self.__period.start)
@@ -208,7 +207,7 @@ class AccountCollector:
return self.__query_balance(conditions)
@staticmethod
def __query_balance(conditions: list[sa.BinaryExpression])\
def __query_balance(conditions: list[sa.ColumnElement[bool]])\
-> Decimal:
"""Queries the balance.
@@ -221,7 +220,7 @@ class AccountCollector:
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount))
select_balance: sa.Select = sa.select(balance_func)\
.join(JournalEntry).join(Account).filter(*conditions)
.join(JournalEntry).join(Account).where(*conditions)
return db.session.scalar(select_balance)
def __add_owner_s_equity(self, code: str, amount: Decimal | None,
@@ -385,11 +384,13 @@ class BalanceSheet(BaseReport):
balances: list[ReportAccount] = AccountCollector(
self.__currency, self.__period).accounts
titles: list[BaseAccount] = BaseAccount.query\
.filter(BaseAccount.code.in_({"1", "2", "3"})).all()
subtitles: list[BaseAccount] = BaseAccount.query\
.filter(BaseAccount.code.in_({x.account.base_code[:2]
for x in balances})).all()
titles: list[BaseAccount] = db.session.scalars(
sa.select(BaseAccount)
.where(BaseAccount.code.in_({"1", "2", "3"}))).unique().all()
subtitle_codes: set[str] = {x.account.base_code[:2] for x in balances}
subtitles: list[BaseAccount] = db.session.scalars(
sa.select(BaseAccount)
.where(BaseAccount.code.in_(subtitle_codes))).unique().all()
sections: dict[str, Section] = {x.code: Section(x) for x in titles}
subsections: dict[str, Subsection] = {x.code: Subsection(x)
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -24,21 +24,19 @@ import sqlalchemy as sa
from flask import url_for, render_template, Response
from sqlalchemy.orm import selectinload
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import income_expenses_url
from accounting.utils.current_account import CurrentAccount
from accounting.utils.pagination import Pagination
from ..period import Period, PeriodChooser
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from ..utils.option_link import OptionLink
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.urls import income_expenses_url
from ... import db
from ...locale import gettext
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
from ...utils.current_account import CurrentAccount
from ...utils.pagination import Pagination
class ReportLineItem:
@@ -119,12 +117,12 @@ class LineItemCollector:
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount))
select: sa.Select = sa.Select(balance_func)\
select: sa.Select[tuple[Decimal]] = sa.Select(balance_func)\
.join(JournalEntry).join(Account)\
.filter(JournalEntryLineItem.currency_code == self.__currency.code,
.where(JournalEntryLineItem.currency_code == self.__currency.code,
self.__account_condition,
JournalEntry.date < self.__period.start)
balance: int | None = db.session.scalar(select)
balance: Decimal | None = db.session.scalar(select)
if balance is None:
return None
line_item: ReportLineItem = ReportLineItem()
@@ -144,7 +142,7 @@ class LineItemCollector:
:return: The line items.
"""
conditions: list[sa.BinaryExpression] \
conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code,
self.__account_condition]
if self.__period.start is not None:
@@ -152,12 +150,12 @@ class LineItemCollector:
if self.__period.end is not None:
conditions.append(JournalEntry.date <= self.__period.end)
journal_entry_with_account: sa.Select = sa.Select(JournalEntry.id).\
join(JournalEntryLineItem).join(Account).filter(*conditions)
join(JournalEntryLineItem).join(Account).where(*conditions)
return [ReportLineItem(x)
for x in JournalEntryLineItem.query
return [ReportLineItem(x) for x in db.session.scalars(
sa.select(JournalEntryLineItem)
.join(JournalEntry).join(Account)
.filter(JournalEntryLineItem.journal_entry_id
.where(JournalEntryLineItem.journal_entry_id
.in_(journal_entry_with_account),
JournalEntryLineItem.currency_code
== self.__currency.code,
@@ -167,10 +165,10 @@ class LineItemCollector:
JournalEntryLineItem.is_debit,
JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.journal_entry))]
selectinload(JournalEntryLineItem.journal_entry)))]
@property
def __account_condition(self) -> sa.BinaryExpression:
def __account_condition(self) -> sa.ColumnElement[bool]:
if self.__account.code == CurrentAccount.CURRENT_AL_CODE:
return CurrentAccount.sql_condition()
return Account.id == self.__account.id
@@ -345,7 +343,7 @@ class PageParams(BasePageParams):
self.account.id == 0)]
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
.join(Account)\
.filter(JournalEntryLineItem.currency_code == self.currency.code,
.where(JournalEntryLineItem.currency_code == self.currency.code,
CurrentAccount.sql_condition())\
.group_by(JournalEntryLineItem.account_id)
options.extend([OptionLink(str(x),
@@ -354,8 +352,10 @@ class PageParams(BasePageParams):
CurrentAccount(x),
self.period),
x.id == self.account.id)
for x in Account.query.filter(Account.id.in_(in_use))
.order_by(Account.base_code, Account.no).all()])
for x in db.session.scalars(
sa.select(Account).where(Account.id.in_(in_use))
.order_by(Account.base_code, Account.no))
.unique()])
return options
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -22,19 +22,18 @@ from decimal import Decimal
import sqlalchemy as sa
from flask import render_template, Response
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
from ..period import Period, PeriodChooser
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from ..utils.option_link import OptionLink
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.urls import ledger_url, income_statement_url
from ... import db
from ...locale import gettext
from ...models import Currency, BaseAccount, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url, income_statement_url
class ReportAccount:
@@ -219,11 +218,14 @@ class IncomeStatement(BaseReport):
"""
balances: list[ReportAccount] = self.__query_balances()
titles: list[BaseAccount] = BaseAccount.query\
.filter(BaseAccount.code.in_({"4", "5", "6", "7", "8", "9"})).all()
subtitles: list[BaseAccount] = BaseAccount.query\
.filter(BaseAccount.code.in_({x.account.base_code[:2]
for x in balances})).all()
title_codes: set[str] = {"4", "5", "6", "7", "8", "9"}
titles: list[BaseAccount] = db.session.scalars(
sa.select(BaseAccount)
.where(BaseAccount.code.in_(title_codes))).unique().all()
subtitle_codes: set[str] = {x.account.base_code[:2] for x in balances}
subtitles: list[BaseAccount] = db.session.scalars(
sa.select(BaseAccount)
.where(BaseAccount.code.in_(subtitle_codes))).unique().all()
total_titles: dict[str, str] \
= {"4": gettext("Total Operating Revenue"),
@@ -254,9 +256,9 @@ class IncomeStatement(BaseReport):
:return: The balances.
"""
sub_conditions: list[sa.BinaryExpression] \
sub_conditions: list[sa.ColumnElement[bool]] \
= [Account.base_code.startswith(str(x)) for x in range(4, 10)]
conditions: list[sa.BinaryExpression] \
conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code,
sa.or_(*sub_conditions)]
if self.__period.start is not None:
@@ -268,14 +270,15 @@ class IncomeStatement(BaseReport):
else_=JournalEntryLineItem.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(JournalEntry).join(Account)\
.filter(*conditions)\
.where(*conditions)\
.group_by(Account.id)\
.having(balance_func != 0)\
.order_by(Account.base_code, Account.no)
balances: list[sa.Row] = db.session.execute(select_balances).all()
accounts: dict[int, Account] \
= {x.id: x for x in Account.query
.filter(Account.id.in_([x.id for x in balances])).all()}
= {x.id: x for x in db.session.scalars(
sa.select(Account)
.where(Account.id.in_([x.id for x in balances]))).unique()}
return [ReportAccount(account=accounts[x.id],
amount=x.balance,
url=ledger_url(self.__currency,
+18 -18
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -24,18 +24,17 @@ import sqlalchemy as sa
from flask import render_template, Response
from sqlalchemy.orm import selectinload
from accounting.locale import gettext
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import journal_url
from accounting.utils.pagination import Pagination
from ..period import Period, PeriodChooser
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.urls import journal_url
from ... import db
from ...locale import gettext
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
from ...utils.pagination import Pagination
class ReportLineItem:
@@ -185,20 +184,21 @@ class Journal(BaseReport):
:return: The line items.
"""
conditions: list[sa.BinaryExpression] = []
conditions: list[sa.ColumnElement[bool]] = []
if self.__period.start is not None:
conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(JournalEntry.date <= self.__period.end)
return JournalEntryLineItem.query.join(JournalEntry)\
.filter(*conditions)\
return db.session.scalars(
sa.select(JournalEntryLineItem).join(JournalEntry)
.where(*conditions)
.order_by(JournalEntry.date,
JournalEntry.no,
JournalEntryLineItem.is_debit.desc(),
JournalEntryLineItem.no)\
JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
selectinload(JournalEntryLineItem.journal_entry))).all()
def csv(self) -> Response:
"""Returns the report as CSV for download.
+24 -25
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -24,20 +24,18 @@ import sqlalchemy as sa
from flask import url_for, render_template, Response
from sqlalchemy.orm import selectinload
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url
from accounting.utils.pagination import Pagination
from ..period import Period, PeriodChooser
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from ..utils.option_link import OptionLink
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.urls import ledger_url
from ... import db
from ...locale import gettext
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
from ...utils.pagination import Pagination
class ReportLineItem:
@@ -117,7 +115,7 @@ class LineItemCollector:
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount))
select: sa.Select = sa.Select(balance_func).join(JournalEntry)\
.filter(JournalEntryLineItem.currency_code == self.__currency.code,
.where(JournalEntryLineItem.currency_code == self.__currency.code,
JournalEntryLineItem.account_id == self.__account.id,
JournalEntry.date < self.__period.start)
balance: int | None = db.session.scalar(select)
@@ -139,22 +137,22 @@ class LineItemCollector:
:return: The line items.
"""
conditions: list[sa.BinaryExpression] \
conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code,
JournalEntryLineItem.account_id == self.__account.id]
if self.__period.start is not None:
conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(JournalEntry.date <= self.__period.end)
return [ReportLineItem(x) for x in JournalEntryLineItem.query
.join(JournalEntry)
.filter(*conditions)
return [ReportLineItem(x) for x in db.session.scalars(
sa.select(JournalEntryLineItem).join(JournalEntry)
.where(*conditions)
.order_by(JournalEntry.date,
JournalEntry.no,
JournalEntryLineItem.is_debit.desc(),
JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.journal_entry))
.all()]
.options(selectinload(JournalEntryLineItem.journal_entry)))
.unique()]
def __get_total(self) -> ReportLineItem | None:
"""Composes the total line item.
@@ -310,12 +308,13 @@ class PageParams(BasePageParams):
:return: The account options.
"""
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
.filter(JournalEntryLineItem.currency_code == self.currency.code)\
.where(JournalEntryLineItem.currency_code == self.currency.code)\
.group_by(JournalEntryLineItem.account_id)
return [OptionLink(str(x), ledger_url(self.currency, x, self.period),
x.id == self.account.id)
for x in Account.query.filter(Account.id.in_(in_use))
.order_by(Account.base_code, Account.no).all()]
for x in db.session.scalars(
sa.select(Account).where(Account.id.in_(in_use))
.order_by(Account.base_code, Account.no)).unique()]
class Ledger(BaseReport):
+28 -26
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -24,17 +24,18 @@ import sqlalchemy as sa
from flask import Response, render_template, request
from sqlalchemy.orm import selectinload
from accounting.locale import gettext
from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
JournalEntry, JournalEntryLineItem
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import csv_download
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.utils.pagination import Pagination
from accounting.utils.query import parse_query_keywords
from .journal import get_csv_rows
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import csv_download
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ... import db
from ...locale import gettext
from ...models import Currency, CurrencyL10n, Account, AccountL10n, \
JournalEntry, JournalEntryLineItem
from ...utils.pagination import Pagination
from ...utils.query import parse_query_keywords
class LineItemCollector:
@@ -53,9 +54,9 @@ class LineItemCollector:
keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0:
return []
conditions: list[sa.BinaryExpression] = []
conditions: list[sa.ColumnElement[bool]] = []
for k in keywords:
sub_conditions: list[sa.BinaryExpression] \
sub_conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.description.icontains(k),
JournalEntryLineItem.account_id.in_(
self.__get_account_condition(k)),
@@ -69,15 +70,16 @@ class LineItemCollector:
except ArithmeticError:
pass
conditions.append(sa.or_(*sub_conditions))
return JournalEntryLineItem.query.join(JournalEntry)\
.filter(*conditions)\
return db.session.scalars(
sa.select(JournalEntryLineItem).join(JournalEntry)
.where(*conditions)
.order_by(JournalEntry.date,
JournalEntry.no,
JournalEntryLineItem.is_debit,
JournalEntryLineItem.no)\
JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
selectinload(JournalEntryLineItem.journal_entry))).all()
@staticmethod
def __get_account_condition(k: str) -> sa.Select:
@@ -86,20 +88,20 @@ class LineItemCollector:
:param k: The keyword.
:return: The condition to filter the account.
"""
code: sa.BinaryExpression = Account.base_code + "-" \
code: sa.ColumnElement[str] = Account.base_code + "-" \
+ sa.func.substr("000" + sa.cast(Account.no, sa.String),
sa.func.char_length(sa.cast(Account.no,
sa.String)) + 1)
select_l10n: sa.Select = sa.select(AccountL10n.account_id)\
.filter(AccountL10n.title.icontains(k))
conditions: list[sa.BinaryExpression] \
.where(AccountL10n.title.icontains(k))
conditions: list[sa.ColumnElement[bool]] \
= [Account.base_code.contains(k),
Account.title_l10n.icontains(k),
code.contains(k),
Account.id.in_(select_l10n)]
if k in gettext("Needs Offset"):
conditions.append(Account.is_need_offset)
return sa.select(Account.id).filter(sa.or_(*conditions))
return sa.select(Account.id).where(sa.or_(*conditions))
@staticmethod
def __get_currency_condition(k: str) -> sa.Select:
@@ -109,9 +111,9 @@ class LineItemCollector:
:return: The condition to filter the currency.
"""
select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\
.filter(CurrencyL10n.name.icontains(k))
return sa.select(Currency.code).filter(
sa.or_(Currency.code.icontains(k),
.where(CurrencyL10n.name.icontains(k))
return sa.select(Currency.code)\
.where(sa.or_(Currency.code.icontains(k),
Currency.name_l10n.icontains(k),
Currency.code.in_(select_l10n)))
@@ -122,7 +124,7 @@ class LineItemCollector:
:param k: The keyword.
:return: The condition to filter the journal entry.
"""
conditions: list[sa.BinaryExpression] \
conditions: list[sa.ColumnElement[bool]] \
= [JournalEntry.note.icontains(k)]
date: dt.datetime
try:
@@ -153,7 +155,7 @@ class LineItemCollector:
sa.extract("day", JournalEntry.date) == date.day))
except ValueError:
pass
return sa.select(JournalEntry.id).filter(sa.or_(*conditions))
return sa.select(JournalEntry.id).where(sa.or_(*conditions))
class PageParams(BasePageParams):
+17 -18
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -22,19 +22,17 @@ from decimal import Decimal
import sqlalchemy as sa
from flask import Response, render_template
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url, trial_balance_url
from ..period import Period, PeriodChooser
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from ..utils.option_link import OptionLink
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.urls import ledger_url, trial_balance_url
from ... import db
from ...locale import gettext
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
class ReportAccount:
@@ -178,7 +176,7 @@ class TrialBalance(BaseReport):
:return: None.
"""
conditions: list[sa.BinaryExpression] \
conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code]
if self.__period.start is not None:
conditions.append(JournalEntry.date >= self.__period.start)
@@ -189,14 +187,15 @@ class TrialBalance(BaseReport):
else_=-JournalEntryLineItem.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(JournalEntry).join(Account)\
.filter(*conditions)\
.where(*conditions)\
.group_by(Account.id)\
.having(balance_func != 0)\
.order_by(Account.base_code, Account.no)
balances: list[sa.Row] = db.session.execute(select_balances).all()
accounts: dict[int, Account] \
= {x.id: x for x in Account.query
.filter(Account.id.in_([x.id for x in balances])).all()}
= {x.id: x for x in db.session.scalars(
sa.select(Account)
.where(Account.id.in_([x.id for x in balances]))).unique()}
self.__accounts = [ReportAccount(account=accounts[x.id],
amount=x.balance,
url=ledger_url(self.__currency,
+20 -19
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,22 +20,22 @@
import datetime as dt
from decimal import Decimal
import sqlalchemy as sa
from flask import render_template, Response
from sqlalchemy.orm import selectinload
from accounting.locale import gettext
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.unapplied import get_accounts_with_unapplied, \
get_net_balances
from accounting.report.utils.urls import unapplied_url
from accounting.utils.pagination import Pagination
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download
from ..utils.option_link import OptionLink
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.unapplied import get_accounts_with_unapplied, get_net_balances
from ..utils.urls import unapplied_url
from ... import db
from ...locale import gettext
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
from ...utils.pagination import Pagination
class CSVRow(BaseCSVRow):
@@ -178,13 +178,14 @@ class UnappliedOriginalLineItems(BaseReport):
"""
net_balances: dict[int, Decimal | None] \
= get_net_balances(self.__currency, self.__account)
line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query \
.join(Account).join(JournalEntry) \
.filter(JournalEntryLineItem.id.in_(net_balances)) \
line_items: list[JournalEntryLineItem] = db.session.scalars(
sa.select(JournalEntryLineItem).join(Account).join(JournalEntry)
.where(JournalEntryLineItem.id.in_(net_balances))
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.no) \
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
selectinload(JournalEntryLineItem.journal_entry)))\
.unique().all()
for line_item in line_items:
line_item.net_balance = line_item.amount \
if net_balances[line_item.id] is None \
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -22,16 +22,16 @@ from decimal import Decimal
from flask import render_template, Response
from accounting.locale import gettext
from accounting.models import Currency, Account
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.unapplied import get_accounts_with_unapplied
from accounting.report.utils.urls import unapplied_url
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download
from ..utils.option_link import OptionLink
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.unapplied import get_accounts_with_unapplied
from ..utils.urls import unapplied_url
from ...locale import gettext
from ...models import Currency, Account
class CSVRow(BaseCSVRow):
+13 -13
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -23,18 +23,18 @@ from decimal import Decimal
from flask import render_template, Response
from flask_babel import LazyString
from accounting.locale import gettext
from accounting.models import Currency, Account, JournalEntryLineItem
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.offset_matcher import OffsetMatcher, OffsetPair
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.unmatched import get_accounts_with_unmatched
from accounting.report.utils.urls import unmatched_url
from accounting.utils.pagination import Pagination
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download
from ..utils.offset_matcher import OffsetMatcher, OffsetPair
from ..utils.option_link import OptionLink
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.unmatched import get_accounts_with_unmatched
from ..utils.urls import unmatched_url
from ...locale import gettext
from ...models import Currency, Account, JournalEntryLineItem
from ...utils.pagination import Pagination
class CSVRow(BaseCSVRow):
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -22,16 +22,16 @@ from decimal import Decimal
from flask import render_template, Response
from accounting.locale import gettext
from accounting.models import Currency, Account
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.unmatched import get_accounts_with_unmatched
from accounting.report.utils.urls import unmatched_url
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download
from ..utils.option_link import OptionLink
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.unmatched import get_accounts_with_unmatched
from ..utils.urls import unmatched_url
from ...locale import gettext
from ...models import Currency, Account
class CSVRow(BaseCSVRow):
+2 -2
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -19,7 +19,7 @@
"""
from decimal import Decimal
from accounting.template_filters import format_amount as core_format_amount
from ..template_filters import format_amount as core_format_amount
def format_amount(value: Decimal | None) -> str | None:
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/6
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -19,18 +19,17 @@
"""
from abc import ABC, abstractmethod
from collections.abc import Callable
from typing import Type
from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \
urlunparse
import sqlalchemy as sa
from flask import request
from accounting import db
from accounting.models import Currency, JournalEntryLineItem
from accounting.utils.journal_entry_types import JournalEntryType
from .option_link import OptionLink
from .report_chooser import ReportChooser
from ... import db
from ...models import Currency, JournalEntryLineItem
from ...utils.journal_entry_types import JournalEntryType
class BasePageParams(ABC):
@@ -53,7 +52,7 @@ class BasePageParams(ABC):
"""
@property
def journal_entry_types(self) -> Type[JournalEntryType]:
def journal_entry_types(self) -> type[JournalEntryType]:
"""Returns the journal entry types.
:return: The journal entry types.
@@ -85,5 +84,6 @@ class BasePageParams(ABC):
sa.select(JournalEntryLineItem.currency_code)
.group_by(JournalEntryLineItem.currency_code)).all())
return [OptionLink(str(x), get_url(x), x.code == active_currency.code)
for x in Currency.query.filter(Currency.code.in_(in_use))
.order_by(Currency.code).all()]
for x in db.session.scalars(
sa.select(Currency).where(Currency.code.in_(in_use))
.order_by(Currency.code)).unique()]
+15 -11
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -26,7 +26,7 @@ from urllib.parse import quote
from flask import Response
from accounting.report.period import Period
from ..period import Period
class BaseCSVRow(ABC):
@@ -66,15 +66,19 @@ def period_spec(period: Period) -> str:
"""
start: str | None = __get_start_str(period.start)
end: str | None = __get_end_str(period.end)
if period.start is None and period.end is None:
return "all-time"
if start == end:
return start
if period.start is None:
return f"until-{end}"
if period.end is None:
return f"since-{start}"
return f"{start}-{end}"
if start is None:
return "all-time" if end is None else f"until-{end}"
return f"since-{start}" if end is None else __get_spec(start, end)
def __get_spec(start: str, end: str) -> str:
"""Constructs the period specification with both start and end
:param start: The start date.
:param end: The end date.
:return: The period specification.
"""
return start if start == end else f"{start}-{end}"
def __get_start_str(start: dt.date | None) -> str | None:
+14 -13
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -23,10 +23,10 @@ import sqlalchemy as sa
from flask_babel import LazyString
from sqlalchemy.orm import selectinload
from accounting.locale import lazy_gettext
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.utils.unapplied import get_net_balances
from ..utils.unapplied import get_net_balances
from ... import db
from ...locale import lazy_gettext
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
class OffsetPair:
@@ -54,7 +54,7 @@ class OffsetMatcher:
:param currency: The currency.
:param account: The account.
"""
self.__currency: Account = currency
self.__currency: Currency = currency
"""The currency."""
self.__account: Account = account
"""The account."""
@@ -105,7 +105,7 @@ class OffsetMatcher:
"""
net_balances: dict[int, Decimal | None] \
= get_net_balances(self.__currency, self.__account)
unmatched_offset_condition: sa.BinaryExpression \
unmatched_offset_condition: sa.ColumnElement[bool] \
= sa.and_(Account.id == self.__account.id,
JournalEntryLineItem.currency_code
== self.__currency.code,
@@ -114,14 +114,15 @@ class OffsetMatcher:
JournalEntryLineItem.is_debit),
sa.and_(Account.base_code.startswith("1"),
sa.not_(JournalEntryLineItem.is_debit))))
self.line_items = JournalEntryLineItem.query \
.join(Account).join(JournalEntry) \
.filter(sa.or_(JournalEntryLineItem.id.in_(net_balances),
unmatched_offset_condition)) \
self.line_items = db.session.scalars(
sa.select(JournalEntryLineItem).join(Account).join(JournalEntry)
.where(sa.or_(JournalEntryLineItem.id.in_(net_balances),
unmatched_offset_condition))
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.no) \
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
selectinload(JournalEntryLineItem.journal_entry)))\
.unique().all()
for line_item in self.line_items:
line_item.is_offset = line_item.id not in net_balances
self.unapplied = [x for x in self.line_items if not x.is_offset]
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -25,18 +25,18 @@ from collections.abc import Iterator
from flask_babel import LazyString
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account
from accounting.report.period import Period, get_period
from accounting.template_globals import default_currency_code
from accounting.utils.current_account import CurrentAccount
from accounting.utils.permission import can_edit
from .option_link import OptionLink
from .report_type import ReportType
from .urls import journal_url, ledger_url, income_expenses_url, \
trial_balance_url, income_statement_url, balance_sheet_url, \
unapplied_url, unmatched_url
from ..period import Period, get_period
from ... import db
from ...locale import gettext
from ...models import Currency, Account
from ...template_globals import default_currency_code
from ...utils.current_account import CurrentAccount
from ...utils.permission import can_edit
class ReportChooser:
+9 -9
View File
@@ -22,21 +22,21 @@ from enum import Enum
class ReportType(Enum):
"""The report types."""
JOURNAL: str = "journal"
JOURNAL = "journal"
"""The journal."""
LEDGER: str = "ledger"
LEDGER = "ledger"
"""The ledger."""
INCOME_EXPENSES: str = "income-expenses"
INCOME_EXPENSES = "income-expenses"
"""The income and expenses log."""
TRIAL_BALANCE: str = "trial-balance"
TRIAL_BALANCE = "trial-balance"
"""The trial balance."""
INCOME_STATEMENT: str = "income-statement"
INCOME_STATEMENT = "income-statement"
"""The income statement."""
BALANCE_SHEET: str = "balance-sheet"
BALANCE_SHEET = "balance-sheet"
"""The balance sheet."""
UNAPPLIED: str = "unapplied"
UNAPPLIED = "unapplied"
"""The unapplied original line items."""
UNMATCHED: str = "unmatched"
UNMATCHED = "unmatched"
"""The unmatched offsets."""
SEARCH: str = "search"
SEARCH = "search"
"""The search."""
+11 -11
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -21,10 +21,9 @@ from decimal import Decimal
import sqlalchemy as sa
from accounting import db
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.utils.offset_alias import offset_alias
from ... import db
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
from ...utils.offset_alias import offset_alias
def get_accounts_with_unapplied(currency: Currency) -> list[Account]:
@@ -46,7 +45,7 @@ def get_accounts_with_unapplied(currency: Currency) -> list[Account]:
.join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\
.filter(Account.is_need_offset,
.where(Account.is_need_offset,
JournalEntryLineItem.currency_code == currency.code,
sa.or_(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)),
@@ -59,13 +58,14 @@ def get_accounts_with_unapplied(currency: Currency) -> list[Account]:
= sa.func.count(JournalEntryLineItem.id).label("count")
select: sa.Select = sa.select(Account.id, count_func)\
.join(JournalEntryLineItem, isouter=True)\
.filter(JournalEntryLineItem.id.in_(select_unapplied))\
.where(JournalEntryLineItem.id.in_(select_unapplied))\
.group_by(Account.id)\
.having(count_func > 0)
counts: dict[int, int] \
= {x.id: x.count for x in db.session.execute(select)}
accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\
.order_by(Account.base_code, Account.no).all()
accounts: list[Account] = db.session.scalars(
sa.select(Account).where(Account.id.in_(counts))
.order_by(Account.base_code, Account.no)).unique().all()
for account in accounts:
account.count = counts[account.id]
return accounts
@@ -92,7 +92,7 @@ def get_net_balances(currency: Currency, account: Account) \
.join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True) \
.filter(Account.id == account.id,
.where(Account.id == account.id,
JournalEntryLineItem.currency_code == currency.code,
sa.or_(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)),
@@ -101,4 +101,4 @@ def get_net_balances(currency: Currency, account: Account) \
.group_by(JournalEntryLineItem.id) \
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
return {x.id: x.net_balance
for x in db.session.execute(select_net_balances).all()}
for x in db.session.execute(select_net_balances)}
+7 -7
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -19,9 +19,8 @@
"""
import sqlalchemy as sa
from accounting import db
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from ... import db
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
def get_accounts_with_unmatched(currency: Currency) -> list[Account]:
@@ -36,7 +35,7 @@ def get_accounts_with_unmatched(currency: Currency) -> list[Account]:
select: sa.Select = sa.select(Account.id, count_func)\
.select_from(Account)\
.join(JournalEntryLineItem, isouter=True).join(JournalEntry)\
.filter(Account.is_need_offset,
.where(Account.is_need_offset,
JournalEntryLineItem.currency_code == currency.code,
JournalEntryLineItem.original_line_item_id.is_(None),
sa.or_(sa.and_(Account.base_code.startswith("2"),
@@ -47,8 +46,9 @@ def get_accounts_with_unmatched(currency: Currency) -> list[Account]:
.having(count_func > 0)
counts: dict[int, int] \
= {x.id: x.count for x in db.session.execute(select)}
accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\
.order_by(Account.base_code, Account.no).all()
accounts: list[Account] = db.session.scalars(
sa.select(Account).where(Account.id.in_(counts))
.order_by(Account.base_code, Account.no)).unique().all()
for account in accounts:
account.count = counts[account.id]
return accounts
+6 -6
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -19,11 +19,11 @@
"""
from flask import url_for
from accounting.models import Currency, Account
from accounting.report.period import Period
from accounting.template_globals import default_currency_code
from accounting.utils.current_account import CurrentAccount
from accounting.utils.options import options
from ...models import Currency, Account
from ...report.period import Period
from ...template_globals import default_currency_code
from ...utils.current_account import CurrentAccount
from ...utils.options import options
def journal_url(period: Period) \
+11 -11
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -19,15 +19,6 @@
"""
from flask import Blueprint, request, Response, redirect, flash
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency, Account
from accounting.template_globals import default_currency_code
from accounting.utils.cast import s
from accounting.utils.current_account import CurrentAccount
from accounting.utils.next_uri import or_next
from accounting.utils.options import options
from accounting.utils.permission import has_permission, can_view, can_edit
from .period import Period, get_period
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
IncomeStatement, BalanceSheet, Search
@@ -38,6 +29,15 @@ from .reports.unmatched_accounts import AccountsWithUnmatchedOffsets
from .template_filters import format_amount
from .utils.offset_matcher import OffsetMatcher
from .utils.urls import unmatched_url
from .. import db
from ..locale import lazy_gettext
from ..models import Currency, Account
from ..template_globals import default_currency_code
from ..utils.cast import s
from ..utils.current_account import CurrentAccount
from ..utils.next_uri import or_next
from ..utils.options import options
from ..utils.permission import has_permission, can_view, can_edit
bp: Blueprint = Blueprint("accounting-report", __name__)
"""The view blueprint for the reports."""
@@ -391,7 +391,7 @@ def get_unmatched(currency: Currency, account: Account) -> str | Response:
@bp.post("match-offsets/<currency:currency>/<needOffsetAccount:account>",
endpoint="match-offsets")
@has_permission(can_edit)
def match_offsets(currency: Currency, account: Account) -> redirect:
def match_offsets(currency: Currency, account: Account) -> Response:
"""Matches the original line items with their offsets.
:return: Redirection to the view of the unmatched offsets.
+29 -69
View File
@@ -48,7 +48,7 @@ class AccountForm {
/**
* The control of the base account
* @type {HTMLDivElement}
* @type {HTMLButtonElement}
*/
#baseControl;
@@ -60,7 +60,7 @@ class AccountForm {
/**
* The base account
* @type {HTMLDivElement}
* @type {HTMLSpanElement}
*/
#base;
@@ -225,15 +225,16 @@ class AccountForm {
/**
* The base account selector.
*
* @extends {BaseCombobox<BaseAccountOption>}
* @private
*/
class BaseAccountSelector {
class BaseAccountSelector extends BaseCombobox {
/**
* The account form
* @type {AccountForm}
*/
form;
#form;
/**
* The selector modal
@@ -241,12 +242,6 @@ class BaseAccountSelector {
*/
#modal;
/**
* The query input
* @type {HTMLInputElement}
*/
#query;
/**
* The error message when the query has no result
* @type {HTMLParagraphElement}
@@ -259,12 +254,6 @@ class BaseAccountSelector {
*/
#optionList;
/**
* The options
* @type {BaseAccountOption[]}
*/
#options;
/**
* The button to clear the base account value
* @type {HTMLButtonElement}
@@ -277,29 +266,32 @@ class BaseAccountSelector {
* @param form {AccountForm} the form
*/
constructor(form) {
this.form = form;
const prefix = "accounting-base-selector";
const query = document.getElementById(`${prefix}-query`);
const options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new BaseAccountOption(element, form.saveBaseAccount.bind(form)));
super(query, options);
this.#form = form;
this.#modal = document.getElementById(`${prefix}-modal`);
this.#query = document.getElementById(`${prefix}-query`);
this.#modal.addEventListener("hidden.bs.modal", () => this.#form.onBaseAccountSelectorClosed());
this.#queryNoResult = document.getElementById(`${prefix}-option-no-result`);
this.#optionList = document.getElementById(`${prefix}-option-list`);
this.#options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new BaseAccountOption(this, element));
this.#clearButton = document.getElementById(`${prefix}-clear`);
this.#modal.addEventListener("hidden.bs.modal", () => this.form.onBaseAccountSelectorClosed());
this.#query.oninput = () => this.#filterOptions();
this.#clearButton.onclick = () => this.form.clearBaseAccount();
this.#clearButton = document.getElementById(`${prefix}-clear`);
this.#clearButton.onclick = () => this.#form.clearBaseAccount();
}
/**
* Filters the options.
*
* @override
*/
#filterOptions() {
filterOptions() {
this.shownOptions = [];
let isAnyMatched = false;
for (const option of this.#options) {
if (option.isMatched(this.#query.value)) {
for (const option of this.options) {
if (option.isMatched(this.query.value)) {
option.setShown(true);
this.shownOptions.push(option);
isAnyMatched = true;
} else {
option.setShown(false);
@@ -319,12 +311,11 @@ class BaseAccountSelector {
*
*/
onOpen() {
this.#query.value = "";
this.#filterOptions();
for (const option of this.#options) {
option.setActive(option.code === this.form.baseCode);
}
if (this.form.baseCode === null) {
this.query.value = "";
this.filterOptions();
this.query.removeAttribute("aria-activedescendant");
this.selectOption(this.shownOptions.find((option) => option.code === this.#form.baseCode));
if (this.#form.baseCode === null) {
this.#clearButton.classList.add("btn-secondary")
this.#clearButton.classList.remove("btn-danger");
this.#clearButton.disabled = true;
@@ -339,14 +330,9 @@ class BaseAccountSelector {
/**
* A base account option.
*
* @private
*/
class BaseAccountOption {
/**
* The element
* @type {HTMLLIElement}
*/
#element;
class BaseAccountOption extends BaseOption {
/**
* The account code
@@ -369,16 +355,16 @@ class BaseAccountOption {
/**
* Constructs the account in the base account selector.
*
* @param selector {BaseAccountSelector} the base account selector
* @param element {HTMLLIElement} the element
* @param save {function(BaseAccountOption): void} the callback to save the option
*/
constructor(selector, element) {
this.#element = element;
constructor(element, save) {
super(element);
this.code = element.dataset.code;
this.text = element.dataset.text;
this.#queryValues = JSON.parse(element.dataset.queryValues);
this.#element.onclick = () => selector.form.saveBaseAccount(this);
element.onclick = () => save(this);
}
/**
@@ -398,30 +384,4 @@ class BaseAccountOption {
}
return false;
}
/**
* Sets whether the option is shown.
*
* @param isShown {boolean} true to show, or false otherwise
*/
setShown(isShown) {
if (isShown) {
this.#element.classList.remove("d-none");
} else {
this.#element.classList.add("d-none");
}
}
/**
* Sets whether the option is active.
*
* @param isActive {boolean} true if active, or false otherwise
*/
setActive(isActive) {
if (isActive) {
this.#element.classList.add("active");
} else {
this.#element.classList.remove("active");
}
}
}
+237
View File
@@ -0,0 +1,237 @@
/* The Mia! Accounting Project
* base-combobox.js: The JavaScript for the base abstract combobox
*/
/* Copyright (c) 2026 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2026/4/16
*/
"use strict";
/**
* The base abstract combobox.
*
* @abstract
* @template {BaseOption} T
*/
class BaseCombobox {
/**
* The query input
* @type {HTMLInputElement}
*/
query;
/**
* The options
* @type {T[]}
*/
options;
/**
* The options that are shown
* @type {T[]}
*/
shownOptions;
/**
* Constructs a base abstract combobox.
*
* @param query {HTMLInputElement} the query input
* @param options {T[]} the options
*/
constructor(query, options) {
this.query = query;
this.query.oninput = () => this.filterOptions();
this.query.onkeydown = this.onQueryKeyDown.bind(this);
this.options = options;
this.shownOptions = [];
}
/**
* Actions when keys are pressed on the query input.
*
* @param event {KeyboardEvent} the key event
*/
onQueryKeyDown(event) {
if (this.shownOptions.length === 0) {
return;
}
const currentID = this.query.getAttribute("aria-activedescendant");
const currentIndex = this.shownOptions.findIndex((option) => option.elementID === currentID);
let newIndex;
switch (event.key) {
case "ArrowUp":
if (currentIndex === -1) {
newIndex = this.shownOptions.length - 1;
} else {
newIndex = (currentIndex - 1 + this.shownOptions.length) % this.shownOptions.length;
}
break;
case "ArrowDown":
if (currentIndex === -1) {
newIndex = 0;
} else {
newIndex = (currentIndex + 1) % this.shownOptions.length;
}
break;
case "Home":
if (this.query.value !== "") {
return;
}
newIndex = 0;
break;
case "End":
if (this.query.value !== "") {
return;
}
newIndex = this.shownOptions.length - 1;
break;
case "PageUp":
if (currentIndex === -1) {
newIndex = this.shownOptions.length - 1;
} else {
newIndex = Math.max(currentIndex - 10, 0);
}
break;
case "PageDown":
if (currentIndex === -1) {
newIndex = 0;
} else {
newIndex = Math.min(currentIndex + 10, this.shownOptions.length - 1);
}
break;
case "Enter":
event.preventDefault();
if (currentIndex !== -1) {
this.shownOptions[currentIndex].click();
}
return;
case "Escape":
if (this.query.value !== "") {
event.preventDefault();
event.stopPropagation();
this.query.value = "";
this.filterOptions();
}
return;
default:
return;
}
event.preventDefault();
this.selectOption(this.shownOptions[newIndex]);
}
/**
* Filters the options.
*
* @abstract
*/
filterOptions() {
throw new Error("Method not implemented");
}
/**
* Selects an option.
*
* @param option {T|undefined} the option.
*/
selectOption(option) {
this.options.forEach((opt) => opt.setActive(false));
if (option === undefined) {
return;
}
option.setActive(true);
this.query.setAttribute("aria-activedescendant", option.elementID);
option.scrollIntoView();
}
}
/**
* The base abstract option
*
* @abstract
*/
class BaseOption {
/**
* The element
* @type {HTMLLIElement}
*/
#element;
/**
* The element ID
* @type {string}
*/
elementID;
/**
* Constructs the base abstract option.
*
* @param element {HTMLLIElement} the element
*/
constructor(element) {
this.#element = element;
this.elementID = element.id;
}
/**
* Sets whether the option is shown.
*
* @param isShown {boolean} true to show, or false otherwise
*/
setShown(isShown) {
if (isShown) {
this.#element.classList.remove("d-none");
} else {
this.#element.classList.add("d-none");
}
}
/**
* Sets whether the option is active.
*
* @param isActive {boolean} true if active, or false otherwise
*/
setActive(isActive) {
if (isActive) {
this.#element.classList.add("active");
this.#element.ariaSelected = "true";
} else {
this.#element.classList.remove("active");
this.#element.ariaSelected = "false";
}
}
/**
* Clicks the option.
*
*/
click() {
this.#element.click();
}
/**
* Scrolls the option into view.
*
*/
scrollIntoView() {
this.#element.scrollIntoView({block: "nearest"});
}
}
+177
View File
@@ -0,0 +1,177 @@
/* The Mia! Accounting Project
* base-tablist.js: The JavaScript for base abstract tablist
*/
/* Copyright (c) 2026 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2026/4/16
*/
"use strict";
/**
* The base abstract tablist.
*
* @abstract
* @template {BaseTab} T
*/
class BaseTablist {
/**
* The tabs.
* @type {T[]}
*/
tabs;
/**
* The current tab.
* @type {T}
*/
currentTab;
/**
* Constructs a new base abstract tablist.
*
* @param tablist {HTMLUListElement} the tab list
*/
constructor(tablist) {
tablist.onkeydown = this.onTabKeyDown.bind(this);
}
/**
* Actions when keys are pressed on the tabs.
*
* @param event {KeyboardEvent} the key event
*/
onTabKeyDown(event) {
const currentIndex = this.tabs.indexOf(this.currentTab);
if (currentIndex === -1) {
return;
}
let newIndex = currentIndex;
switch (event.key) {
case "ArrowRight":
newIndex = (newIndex + 1) % this.tabs.length;
break;
case "ArrowLeft":
newIndex = (newIndex - 1 + this.tabs.length) % this.tabs.length;
break;
case "Home":
newIndex = 0;
break;
case "End":
newIndex = this.tabs.length - 1;
break;
default:
return;
}
event.preventDefault();
this.tabs[newIndex].focus();
this.onTabFocus(this.tabs[newIndex]);
}
/**
* Actions when a tab is focused.
*
* @param tab {T} the tab
*/
onTabFocus(tab) { /* Do nothing */ }
/**
* Switches to a tab.
*
* @param tab {T} the tab
*/
switchTo(tab) {
this.tabs.forEach(t => t.setActive(t === tab));
this.currentTab = tab;
this.currentTab.onActivated();
}
}
/**
* The base abstract tab.
*
* @abstract
*/
class BaseTab {
/**
* The tab element.
* @type {HTMLButtonElement}
*/
#tab;
/**
* The panel element.
* @type {HTMLDivElement}
*/
#panel;
/**
* Constructs a new base abstract tab.
*
* @param tab {HTMLButtonElement} The tab element.
* @param panel {HTMLDivElement} The panel element.
* @param switchTo {function(BaseTab): void} The function to switch to the tab.
*/
constructor(tab, panel, switchTo) {
this.#tab = tab;
this.#panel = panel;
this.#tab.onclick = () => switchTo(this);
}
/**
* Sets the active state of the tab.
*
* @param isActive {boolean} true if the tab is active, false otherwise
*/
setActive(isActive) {
if (isActive) {
this.#tab.classList.add("active");
this.#tab.tabIndex = 0;
this.#tab.ariaSelected = "true";
this.#panel.classList.remove("d-none");
} else {
this.#tab.classList.remove("active");
this.#tab.tabIndex = -1;
this.#tab.ariaSelected = "false";
this.#panel.classList.add("d-none");
}
}
/**
* Returns whether the tab is active.
*
* @returns {boolean} true if the tab is active, false otherwise
*/
isActive() {
return this.#tab.classList.contains("active");
}
/**
* Actions when the tab is activated.
*/
onActivated() { /* Do nothing */ }
/**
* Focuses the tab.
*/
focus() {
this.#tab.focus();
}
}
+170 -191
View File
@@ -25,8 +25,9 @@
/**
* A description editor.
*
* @extends {BaseTablist<BaseDescriptionEditorTab>}
*/
class DescriptionEditor {
class DescriptionEditor extends BaseTablist {
/**
* The line item editor
@@ -58,12 +59,6 @@ class DescriptionEditor {
*/
debitCredit;
/**
* The current tab
* @type {DescriptionEditorTabPlane}
*/
currentTab;
/**
* The description input
* @type {HTMLInputElement}
@@ -125,10 +120,10 @@ class DescriptionEditor {
selectedAccount = null;
/**
* The tab planes
* @type {{general: DescriptionEditorGeneralTagTab, travel: DescriptionEditorGeneralTripTab, bus: DescriptionEditorBusTripTab, recurring: DescriptionEditorRecurringTab, annotation: DescriptionEditorAnnotationTab}}
* The tabs by their ID.
* @type {DescriptionEditorTabFactory}
*/
tabPlanes = {};
#tabsByID;
/**
* Constructs a description editor.
@@ -137,31 +132,38 @@ class DescriptionEditor {
* @param debitCredit {string} either "debit" or "credit"
*/
constructor(lineItemEditor, debitCredit) {
const prefix = `accounting-description-editor-${debitCredit}`;
super(document.getElementById(`${prefix}-tab-list`));
this.prefix = prefix;
this.lineItemEditor = lineItemEditor;
this.debitCredit = debitCredit;
this.prefix = `accounting-description-editor-${debitCredit}`;
this.#form = document.getElementById(this.prefix);
this.#modal = document.getElementById(`${this.prefix}-modal`);
this.#descriptionInput = document.getElementById(`${this.prefix}-description`);
this.#offsetButton = document.getElementById(`${this.prefix}-offset`);
this.number = document.getElementById(`${this.prefix}-annotation-number`);
this.note = document.getElementById(`${this.prefix}-annotation-note`);
this.#confirmedAccountPlaceholder = new DescriptionEditorConfirmedAccount(this, document.getElementById(`${this.prefix}-account-confirmed`));
this.#allSuggestedAccounts = Array.from(document.getElementsByClassName(`${this.prefix}-account`)).map((button) => new DescriptionEditorSuggestedAccount(this, button));
this.#form = document.getElementById(prefix);
this.#modal = document.getElementById(`${prefix}-modal`);
this.#descriptionInput = document.getElementById(`${prefix}-description`);
this.#offsetButton = document.getElementById(`${prefix}-offset`);
this.number = document.getElementById(`${prefix}-annotation-number`);
this.note = document.getElementById(`${prefix}-annotation-note`);
this.#confirmedAccountPlaceholder = new DescriptionEditorConfirmedAccount(this, document.getElementById(`${prefix}-account-confirmed`));
this.#allSuggestedAccounts = Array.from(document.getElementsByClassName(`${prefix}-account`)).map((button) => new DescriptionEditorSuggestedAccount(this, button));
for (const cls of [DescriptionEditorGeneralTagTab, DescriptionEditorGeneralTripTab, DescriptionEditorBusTripTab, DescriptionEditorRecurringTab, DescriptionEditorAnnotationTab]) {
const tab = new cls(this);
this.tabPlanes[tab.tabId()] = tab;
}
this.currentTab = this.tabPlanes.general;
this.#tabsByID = new DescriptionEditorTabFactory(this);
this.tabs = [this.#tabsByID.general, this.#tabsByID.travel, this.#tabsByID.bus, this.#tabsByID.recurring, this.#tabsByID.annotation];
this.currentTab = this.tabs[0];
this.#descriptionInput.onchange = () => this.#onDescriptionChange();
this.#offsetButton.onclick = () => this.lineItemEditor.originalLineItemSelector.onOpen();
this.#offsetButton.onclick = () => this.lineItemEditor.originalLineItemSelector.onOpen(this.#modal.id);
this.#form.onsubmit = () => {
if (this.currentTab.validate()) {
this.#submit();
}
return false;
};
const closeButton = document.getElementById(`${prefix}-close`);
this.#modal.onkeydown = (event) => {
if (event.key === "Escape") {
closeButton.click();
}
};
}
/**
@@ -199,26 +201,26 @@ class DescriptionEditor {
*
*/
#onDescriptionChange() {
this.#resetTabPlanes();
this.#resetTabs();
this.selectedAccount = null;
this.description = this.description.trim();
for (const tabPlane of [this.tabPlanes.recurring, this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) {
if (tabPlane.populate()) {
for (const tab of [this.#tabsByID.recurring, this.#tabsByID.bus, this.#tabsByID.travel, this.#tabsByID.general]) {
if (tab.populate()) {
break;
}
}
this.tabPlanes.annotation.populate();
this.#tabsByID.annotation.populate();
}
/**
* Resets the tab planes.
* Resets the tabs.
*
*/
#resetTabPlanes() {
for (const tabPlane of Object.values(this.tabPlanes)) {
tabPlane.reset();
#resetTabs() {
for (const tab of this.tabs) {
tab.reset();
}
this.tabPlanes.general.switchToMe();
this.switchTo(this.tabs[0]);
}
/**
@@ -344,6 +346,7 @@ class DescriptionEditor {
/**
* An account option in the description editor.
*
* @private
*/
class DescriptionEditorAccount extends JournalEntryAccount {
@@ -415,6 +418,7 @@ class DescriptionEditorAccount extends JournalEntryAccount {
/**
* A suggested account.
*
* @private
*/
class DescriptionEditorSuggestedAccount extends DescriptionEditorAccount {
@@ -432,6 +436,7 @@ class DescriptionEditorSuggestedAccount extends DescriptionEditorAccount {
/**
* The account option that is specified or confirmed by the user.
*
* @private
*/
class DescriptionEditorConfirmedAccount extends DescriptionEditorAccount {
@@ -460,12 +465,63 @@ class DescriptionEditorConfirmedAccount extends DescriptionEditorAccount {
}
/**
* A tab plane.
* The tab factory.
*
* @private
*/
class DescriptionEditorTabFactory {
/**
* The general tag tab
* @type {GeneralTagTab}
*/
general;
/**
* The general trip tab
* @type {GeneralTripTab}
*/
travel;
/**
* The bus trip tab
* @type {BusTripTab}
*/
bus;
/**
* The recurring transactions tab
* @type {RecurringTab}
*/
recurring;
/**
* The annotation tab
* @type {AnnotationTab}
*/
annotation;
/**
* Constructs the tab factory
*
* @param editor {DescriptionEditor} the parent description editor
*/
constructor(editor) {
this.general = new GeneralTagTab(editor);
this.travel = new GeneralTripTab(editor);
this.bus = new BusTripTab(editor);
this.recurring = new RecurringTab(editor);
this.annotation = new AnnotationTab(editor);
}
}
/**
* The base abstract tab in the description editor.
*
* @abstract
* @private
*/
class DescriptionEditorTabPlane {
class BaseDescriptionEditorTab extends BaseTab {
/**
* The parent description editor
@@ -480,47 +536,29 @@ class DescriptionEditorTabPlane {
prefix;
/**
* The tab
* @type {HTMLSpanElement}
*/
#tab;
/**
* The page
* @type {HTMLDivElement}
*/
#page;
/**
* Constructs a tab plane.
* Constructs a base abstract tab in the description editor.
*
* @param tabID {string} the tab ID
* @param editor {DescriptionEditor} the parent description editor
*/
constructor(editor) {
constructor(tabID, editor) {
const prefix = `${editor.prefix}-${tabID}`;
const tab = document.getElementById(`${prefix}-tab`);
const panel = document.getElementById(`${prefix}-panel`);
super(tab, panel, editor.switchTo.bind(editor));
this.editor = editor;
this.prefix = `${this.editor.prefix}-${this.tabId()}`;
this.#tab = document.getElementById(`${this.prefix}-tab`);
this.#page = document.getElementById(`${this.prefix}-page`);
this.#tab.onclick = () => this.switchToMe();
this.prefix = prefix;
}
/**
* The tab ID
*
* @return {string}
* @abstract
*/
tabId() { throw new Error("Method not implemented.") };
/**
* Resets the tab plane input.
* Resets the tab panel input.
*
* @abstract
*/
reset() { throw new Error("Method not implemented."); }
/**
* Populates the tab plane with the description input.
* Populates the tab panel with the description input.
*
* @return {boolean} true if the description input matches this tab, or false otherwise
* @abstract
@@ -528,39 +566,21 @@ class DescriptionEditorTabPlane {
populate() { throw new Error("Method not implemented."); }
/**
* Validates the input in the tab plane.
* Validates the input in the tab panel.
*
* @return {boolean} true if valid, or false otherwise
* @abstract
*/
validate() { throw new Error("Method not implemented."); }
/**
* Switches to the tab plane.
*
*/
switchToMe() {
for (const tabPlane of Object.values(this.editor.tabPlanes)) {
tabPlane.#tab.classList.remove("active")
tabPlane.#tab.ariaCurrent = "false";
tabPlane.#page.classList.add("d-none");
tabPlane.#page.ariaCurrent = "false";
}
this.#tab.classList.add("active");
this.#tab.ariaCurrent = "page";
this.#page.classList.remove("d-none");
this.#page.ariaCurrent = "page";
this.editor.currentTab = this;
}
}
/**
* A tag plane with selectable tags.
* The base abstract tab with selectable tags.
*
* @abstract
* @private
*/
class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
class BaseTagTab extends BaseDescriptionEditorTab {
/**
* The tag input
@@ -578,20 +598,21 @@ class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
* The tag buttons
* @type {HTMLButtonElement[]}
*/
tagButtons;
#tagButtons;
/**
* Constructs a tab plane.
* Constructs a base abstract tab with selectable tags.
*
* @param tabID {string} the tab ID
* @param editor {DescriptionEditor} the parent description editor
* @override
*/
constructor(editor) {
super(editor);
constructor(tabID, editor) {
super(tabID, editor);
this.tag = document.getElementById(`${this.prefix}-tag`);
this.tagError = document.getElementById(`${this.prefix}-tag-error`);
// noinspection JSValidateTypes
this.tagButtons = Array.from(document.getElementsByClassName(`${this.prefix}-btn-tag`));
this.#tagButtons = Array.from(document.getElementsByClassName(`${this.prefix}-btn-tag`));
this.initializeTagButtons();
this.tag.onchange = () => {
this.onTagChange();
@@ -606,7 +627,7 @@ class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
onTagChange() {
this.tag.value = this.tag.value.trim();
let isMatched = false;
for (const tagButton of this.tagButtons) {
for (const tagButton of this.#tagButtons) {
if (tagButton.dataset.value === this.tag.value) {
tagButton.classList.remove("btn-outline-primary");
tagButton.classList.add("btn-primary");
@@ -624,19 +645,18 @@ class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
}
/**
* Updates the description according to the input in the tab plane.
* Updates the description according to the input in the tab panel.
*
* @abstract
*/
updateDescription() { throw new Error("Method not implemented."); }
/**
* Switches to the tab plane.
*
* @inheritDoc
* @override
*/
switchToMe() {
super.switchToMe();
for (const tagButton of this.tagButtons) {
onActivated() {
for (const tagButton of this.#tagButtons) {
if (tagButton.classList.contains("btn-primary")) {
this.editor.updateCurrentSuggestedAccounts(tagButton);
return;
@@ -650,9 +670,9 @@ class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
*
*/
initializeTagButtons() {
for (const tagButton of this.tagButtons) {
for (const tagButton of this.#tagButtons) {
tagButton.onclick = () => {
for (const otherButton of this.tagButtons) {
for (const otherButton of this.#tagButtons) {
otherButton.classList.remove("btn-primary");
otherButton.classList.add("btn-outline-primary");
}
@@ -698,7 +718,7 @@ class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
}
/**
* Resets the tab plane input.
* Resets the tab panel input.
*
* @override
*/
@@ -706,7 +726,7 @@ class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
this.tag.value = "";
this.tag.classList.remove("is-invalid");
this.tagError.innerText = "";
for (const tagButton of this.tagButtons) {
for (const tagButton of this.#tagButtons) {
tagButton.classList.remove("btn-primary");
tagButton.classList.add("btn-outline-primary");
}
@@ -714,24 +734,24 @@ class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
}
/**
* The general tag tab plane.
* The general tag tab.
*
* @private
*/
class DescriptionEditorGeneralTagTab extends DescriptionEditorTagTabPlane {
class GeneralTagTab extends BaseTagTab {
/**
* The tab ID
* Constructs a general tag tab.
*
* @return {string}
* @abstract
* @param editor {DescriptionEditor} the parent description editor
* @override
*/
tabId() {
return "general";
};
constructor(editor) {
super("general", editor);
}
/**
* Updates the description according to the input in the tab plane.
* Updates the description according to the input in the tab panel.
*
* @override
*/
@@ -746,7 +766,7 @@ class DescriptionEditorGeneralTagTab extends DescriptionEditorTagTabPlane {
}
/**
* Populates the tab plane with the description input.
* Populates the tab panel with the description input.
*
* @return {boolean} true if the description input matches this tab, or false otherwise
* @override
@@ -760,12 +780,12 @@ class DescriptionEditorGeneralTagTab extends DescriptionEditorTagTabPlane {
this.tag.value = found[1];
this.onTagChange();
}
this.switchToMe();
this.editor.switchTo(this);
return true;
}
/**
* Validates the input in the tab plane.
* Validates the input in the tab panel.
*
* @return {boolean} true if valid, or false otherwise
*/
@@ -775,11 +795,11 @@ class DescriptionEditorGeneralTagTab extends DescriptionEditorTagTabPlane {
}
/**
* The general trip tab plane.
* The general trip tab.
*
* @private
*/
class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {
class GeneralTripTab extends BaseTagTab {
/**
* The origin
@@ -812,13 +832,13 @@ class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {
#directionButtons;
/**
* Constructs a tab plane.
* Constructs a general trip tab.
*
* @param editor {DescriptionEditor} the parent description editor
* @override
*/
constructor(editor) {
super(editor);
super("travel", editor);
this.#from = document.getElementById(`${this.prefix}-from`);
this.#fromError = document.getElementById(`${this.prefix}-from-error`);
this.#to = document.getElementById(`${this.prefix}-to`);
@@ -849,17 +869,7 @@ class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {
}
/**
* The tab ID
*
* @return {string}
* @abstract
*/
tabId() {
return "travel";
};
/**
* Updates the description according to the input in the tab plane.
* Updates the description according to the input in the tab panel.
*
* @override
*/
@@ -875,7 +885,7 @@ class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {
}
/**
* Resets the tab plane input.
* Resets the tab panel input.
*
* @override
*/
@@ -899,7 +909,7 @@ class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {
}
/**
* Populates the tab plane with the description input.
* Populates the tab panel with the description input.
*
* @return {boolean} true if the description input matches this tab, or false otherwise
* @override
@@ -924,12 +934,12 @@ class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {
}
}
this.#to.value = found[4];
this.switchToMe();
this.editor.switchTo(this);
return true;
}
/**
* Validates the input in the tab plane.
* Validates the input in the tab panel.
*
* @return {boolean} true if valid, or false otherwise
* @override
@@ -974,11 +984,11 @@ class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {
}
/**
* The bus trip tab plane.
* The bus trip tab.
*
* @private
*/
class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {
class BusTripTab extends BaseTagTab {
/**
* The route
@@ -1017,13 +1027,13 @@ class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {
#toError;
/**
* Constructs a tab plane.
* Constructs a bus trip tab.
*
* @param editor {DescriptionEditor} the parent description editor
* @override
*/
constructor(editor) {
super(editor);
super("bus", editor);
this.#route = document.getElementById(`${this.prefix}-route`);
this.#routeError = document.getElementById(`${this.prefix}-route-error`);
this.#from = document.getElementById(`${this.prefix}-from`);
@@ -1048,17 +1058,7 @@ class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {
}
/**
* The tab ID
*
* @return {string}
* @abstract
*/
tabId() {
return "bus";
};
/**
* Updates the description according to the input in the tab plane.
* Updates the description according to the input in the tab panel.
*
* @override
*/
@@ -1067,7 +1067,7 @@ class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {
}
/**
* Resets the tab plane input.
* Resets the tab panel input.
*
* @override
*/
@@ -1085,7 +1085,7 @@ class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {
}
/**
* Populates the tab plane with the description input.
* Populates the tab panel with the description input.
*
* @return {boolean} true if the description input matches this tab, or false otherwise
* @override
@@ -1102,12 +1102,12 @@ class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {
this.#route.value = found[2];
this.#from.value = found[3];
this.#to.value = found[4];
this.switchToMe();
this.editor.switchTo(this);
return true;
}
/**
* Validates the input in the tab plane.
* Validates the input in the tab panel.
*
* @return {boolean} true if valid, or false otherwise
*/
@@ -1162,11 +1162,11 @@ class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {
}
/**
* The recurring transaction tab plane.
* The recurring transaction tab.
*
* @private
*/
class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
class RecurringTab extends BaseDescriptionEditorTab {
/**
* The month names
@@ -1181,13 +1181,13 @@ class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
#itemButtons;
/**
* Constructs a tab plane.
* Constructs a recurring transaction tab.
*
* @param editor {DescriptionEditor} the parent description editor
* @override
*/
constructor(editor) {
super(editor);
super("recurring", editor);
this.#monthNames = [
"",
A_("January"), A_("February"), A_("March"), A_("April"),
@@ -1229,17 +1229,7 @@ class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
}
/**
* The tab ID
*
* @return {string}
* @abstract
*/
tabId() {
return "recurring";
};
/**
* Resets the tab plane input.
* Resets the tab panel input.
*
* @override
*/
@@ -1251,7 +1241,7 @@ class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
}
/**
* Populates the tab plane with the description input.
* Populates the tab panel with the description input.
*
* @return {boolean} true if the description input matches this tab, or false otherwise
* @override
@@ -1261,7 +1251,7 @@ class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
if (this.#getDescription(itemButton) === this.editor.description) {
itemButton.classList.add("btn-primary");
itemButton.classList.remove("btn-outline-primary");
this.switchToMe();
this.editor.switchTo(this);
return true;
}
}
@@ -1269,11 +1259,10 @@ class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
}
/**
* Switches to the tab plane.
*
* @inheritDoc
* @override
*/
switchToMe() {
super.switchToMe();
onActivated() {
for (const itemButton of this.#itemButtons) {
if (itemButton.classList.contains("btn-primary")) {
this.editor.updateCurrentSuggestedAccounts(itemButton);
@@ -1284,7 +1273,7 @@ class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
}
/**
* Validates the input in the tab plane.
* Validates the input in the tab panel.
*
* @return {boolean} true if valid, or false otherwise
* @override
@@ -1295,20 +1284,20 @@ class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
}
/**
* The annotation tab plane.
* The annotation tab.
*
* @private
*/
class DescriptionEditorAnnotationTab extends DescriptionEditorTabPlane {
class AnnotationTab extends BaseDescriptionEditorTab {
/**
* Constructs a tab plane.
* Constructs an annotation tab.
*
* @param editor {DescriptionEditor} the parent description editor
* @override
*/
constructor(editor) {
super(editor);
super("annotation", editor);
this.editor.number.onchange = () => this.updateDescription();
this.editor.note.onchange = () => {
this.editor.note.value = this.editor.note.value.trim();
@@ -1317,17 +1306,7 @@ class DescriptionEditorAnnotationTab extends DescriptionEditorTabPlane {
}
/**
* The tab ID
*
* @return {string}
* @abstract
*/
tabId() {
return "annotation";
};
/**
* Updates the description according to the input in the tab plane.
* Updates the description according to the input in the tab panel.
*
* @override
*/
@@ -1345,7 +1324,7 @@ class DescriptionEditorAnnotationTab extends DescriptionEditorTabPlane {
}
/**
* Resets the tab plane input.
* Resets the tab panel input.
*
* @override
*/
@@ -1355,7 +1334,7 @@ class DescriptionEditorAnnotationTab extends DescriptionEditorTabPlane {
}
/**
* Populates the tab plane with the description input.
* Populates the tab panel with the description input.
*
* @return {boolean} true if the description input matches this tab, or false otherwise
* @override
@@ -1379,7 +1358,7 @@ class DescriptionEditorAnnotationTab extends DescriptionEditorTabPlane {
}
/**
* Validates the input in the tab plane.
* Validates the input in the tab panel.
*
* @return {boolean} true if valid, or false otherwise
* @override
@@ -25,14 +25,16 @@
/**
* The account selector.
*
* @extends {BaseCombobox<BaseJournalEntryAccountOption>}
* @private
*/
class JournalEntryAccountSelector {
class JournalEntryAccountSelector extends BaseCombobox {
/**
* The line item editor
* @type {JournalEntryLineItemEditor}
*/
lineItemEditor;
#lineItemEditor;
/**
* Either "debit" or "credit"
@@ -46,12 +48,6 @@ class JournalEntryAccountSelector {
*/
#clearButton
/**
* The query input
* @type {HTMLInputElement}
*/
#query;
/**
* The error message when the query has no result
* @type {HTMLParagraphElement}
@@ -64,15 +60,9 @@ class JournalEntryAccountSelector {
*/
#optionList;
/**
* The options
* @type {JournalEntryAccountOption[]}
*/
#options;
/**
* The more item to show all accounts
* @type {HTMLLIElement}
* @type {MoreItems}
*/
#more;
@@ -89,40 +79,55 @@ class JournalEntryAccountSelector {
* @param debitCredit {string} either "debit" or "credit"
*/
constructor(lineItemEditor, debitCredit) {
this.lineItemEditor = lineItemEditor
this.#debitCredit = debitCredit;
const prefix = `accounting-account-selector-${debitCredit}`;
this.#query = document.getElementById(`${prefix}-query`);
const query = document.getElementById(`${prefix}-query`);
const options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new JournalEntryAccountOption(element, lineItemEditor.saveAccount.bind(lineItemEditor)));
super(query, options);
this.#lineItemEditor = lineItemEditor;
this.#debitCredit = debitCredit;
this.#queryNoResult = document.getElementById(`${prefix}-option-no-result`);
this.#optionList = document.getElementById(`${prefix}-option-list`);
this.#options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new JournalEntryAccountOption(this, element));
this.#more = document.getElementById(`${prefix}-more`);
this.#clearButton = document.getElementById(`${prefix}-btn-clear`);
this.#more.onclick = () => {
const moreElement = document.getElementById(`${prefix}-more`);
this.#more = new MoreItems(moreElement);
moreElement.onclick = () => {
this.#isShowMore = true;
this.#more.classList.add("d-none");
this.#filterOptions();
this.#more.setShown(false);
this.filterOptions();
};
this.#clearButton = document.getElementById(`${prefix}-btn-clear`);
this.#clearButton.onclick = () => this.#lineItemEditor.clearAccount();
const modal = document.getElementById(`${prefix}-modal`);
const closeButton = document.getElementById(`${prefix}-close`);
modal.onkeydown = (event) => {
if (event.key === "Escape") {
closeButton.click();
}
};
this.#query.oninput = () => this.#filterOptions();
this.#clearButton.onclick = () => this.lineItemEditor.clearAccount();
}
/**
* Filters the options.
*
* @override
*/
#filterOptions() {
filterOptions() {
this.shownOptions = [];
const codesInUse = this.#getCodesUsedInForm();
let isAnyMatched = false;
for (const option of this.#options) {
if (option.isMatched(this.#isShowMore, codesInUse, this.#query.value)) {
for (const option of this.options) {
if (option.isMatched(this.#isShowMore, codesInUse, this.query.value)) {
option.setShown(true);
this.shownOptions.push(option);
isAnyMatched = true;
} else {
option.setShown(false);
}
}
if (!this.#isShowMore) {
this.shownOptions.push(this.#more);
}
if (!isAnyMatched && this.#isShowMore) {
this.#optionList.classList.add("d-none");
this.#queryNoResult.classList.remove("d-none");
@@ -138,9 +143,9 @@ class JournalEntryAccountSelector {
* @return {string[]} the account codes that are used in the form
*/
#getCodesUsedInForm() {
const inUse = this.lineItemEditor.form.getAccountCodesUsed(this.#debitCredit);
if (this.lineItemEditor.account !== null) {
inUse.push(this.lineItemEditor.account.code);
const inUse = this.#lineItemEditor.form.getAccountCodesUsed(this.#debitCredit);
if (this.#lineItemEditor.account !== null) {
inUse.push(this.#lineItemEditor.account.code);
}
return inUse
}
@@ -150,14 +155,13 @@ class JournalEntryAccountSelector {
*
*/
onOpen() {
this.#query.value = "";
this.query.value = "";
this.#isShowMore = false;
this.#more.classList.remove("d-none");
this.#filterOptions();
for (const option of this.#options) {
option.setActive(this.lineItemEditor.account !== null && option.code === this.lineItemEditor.account.code);
}
if (this.lineItemEditor.account === null) {
this.#more.setShown(true);
this.filterOptions();
this.query.removeAttribute("aria-activedescendant");
this.selectOption(this.shownOptions.find((option) => this.#lineItemEditor.account !== null && option.code === this.#lineItemEditor.account.code));
if (this.#lineItemEditor.account === null) {
this.#clearButton.classList.add("btn-secondary");
this.#clearButton.classList.remove("btn-danger");
this.#clearButton.disabled = true;
@@ -168,6 +172,17 @@ class JournalEntryAccountSelector {
}
}
/**
* Selects an option.
*
* @param option {BaseJournalEntryAccountOption|undefined} the option.
* @override
*/
selectOption(option) {
this.#more.setActive(false);
super.selectOption(option);
}
/**
* Returns the account selector instances.
*
@@ -185,22 +200,37 @@ class JournalEntryAccountSelector {
}
/**
* An account option
* The base abstract account option
*
* @private
*/
class JournalEntryAccountOption {
/**
* The element
* @type {HTMLLIElement}
*/
#element;
class BaseJournalEntryAccountOption extends BaseOption {
/**
* The account code
* @type {string}
*/
code;
code = "";
/**
* Returns whether the account matches the query.
*
* @param isShowMore {boolean} true to show all accounts, or false to show only those in use
* @param codesInUse {string[]} the account codes that are used in the form
* @param query {string} the query term
* @return {boolean} true if the option matches, or false otherwise
*/
isMatched(isShowMore, codesInUse, query) {
return false;
}
}
/**
* An account option
*
* @private
*/
class JournalEntryAccountOption extends BaseJournalEntryAccountOption {
/**
* The account title
@@ -235,11 +265,11 @@ class JournalEntryAccountOption {
/**
* Constructs the account in the account selector.
*
* @param selector {JournalEntryAccountSelector} the account selector
* @param element {HTMLLIElement} the element
* @param save {function(JournalEntryAccountOption): void} the callback to save the option
*/
constructor(selector, element) {
this.#element = element;
constructor(element, save) {
super(element);
this.code = element.dataset.code;
this.title = element.dataset.title;
this.text = element.dataset.text;
@@ -247,7 +277,7 @@ class JournalEntryAccountOption {
this.isNeedOffset = element.classList.contains("accounting-account-is-need-offset");
this.#queryValues = JSON.parse(element.dataset.queryValues);
this.#element.onclick = () => selector.lineItemEditor.saveAccount(this);
element.onclick = () => save(this);
}
/**
@@ -257,6 +287,7 @@ class JournalEntryAccountOption {
* @param codesInUse {string[]} the account codes that are used in the form
* @param query {string} the query term
* @return {boolean} true if the option matches, or false otherwise
* @override
*/
isMatched(isShowMore, codesInUse, query) {
return this.#isInUseMatched(isShowMore, codesInUse) && this.#isQueryMatched(query);
@@ -290,30 +321,12 @@ class JournalEntryAccountOption {
}
return false;
}
/**
* Sets whether the option is shown.
*
* @param isShown {boolean} true to show, or false otherwise
*/
setShown(isShown) {
if (isShown) {
this.#element.classList.remove("d-none");
} else {
this.#element.classList.add("d-none");
}
}
/**
* Sets whether the option is active.
*
* @param isActive {boolean} true if active, or false otherwise
*/
setActive(isActive) {
if (isActive) {
this.#element.classList.add("active");
} else {
this.#element.classList.remove("active");
}
}
}
/**
* The more item to show all accounts.
*
* @private
*/
class MoreItems extends BaseJournalEntryAccountOption {
}
@@ -701,12 +701,23 @@ class DebitCreditSubForm {
this.#element.classList.add("accounting-not-empty");
this.currency.form.lineItemEditor.onAddNew(this);
};
this.#element.role = "button";
this.#element.tabIndex = 0;
this.#element.onkeydown = (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
this.#element.click();
}
};
} else {
this.#element.classList.add("accounting-not-empty");
this.#element.classList.remove("accounting-clickable");
delete this.#element.dataset.bsToggle;
delete this.#element.dataset.bsTarget;
this.#element.onclick = null;
this.#element.removeAttribute("role");
this.#element.tabIndex = -1;
this.#element.onkeydown = null;
}
setElementShown(this.#content, this.lineItems.length !== 0);
}
@@ -986,6 +997,12 @@ class LineItemSubForm {
this.#element.parentElement.removeChild(this.#element);
this.debitCreditSubForm.deleteLineItem(this);
};
this.#control.onkeydown = (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
this.#control.click();
}
};
}
/**
@@ -66,13 +66,13 @@ class JournalEntryLineItemEditor {
/**
* The control of the original line item
* @type {HTMLDivElement}
* @type {HTMLButtonElement}
*/
#originalLineItemControl;
/**
* The original line item
* @type {HTMLDivElement}
* @type {HTMLSpanElement}
*/
#originalLineItemText;
@@ -90,13 +90,13 @@ class JournalEntryLineItemEditor {
/**
* The control of the description
* @type {HTMLDivElement}
* @type {HTMLButtonElement}
*/
#descriptionControl;
/**
* The description
* @type {HTMLDivElement}
* @type {HTMLSpanElement}
*/
#descriptionText;
@@ -108,13 +108,13 @@ class JournalEntryLineItemEditor {
/**
* The control of the account
* @type {HTMLDivElement}
* @type {HTMLButtonElement}
*/
#accountControl;
/**
* The account
* @type {HTMLDivElement}
* @type {HTMLSpanElement}
*/
#accountText;
@@ -228,7 +228,7 @@ class JournalEntryLineItemEditor {
this.#accountSelectors = JournalEntryAccountSelector.getInstances(this);
this.originalLineItemSelector = new OriginalLineItemSelector(this);
this.#originalLineItemControl.onclick = () => this.originalLineItemSelector.onOpen()
this.#originalLineItemControl.onclick = () => this.originalLineItemSelector.onOpen(this.modal.id)
this.#originalLineItemDelete.onclick = () => this.clearOriginalLineItem();
this.#descriptionControl.onclick = () => this.#descriptionEditors[this.debitCredit].onOpen();
this.#accountControl.onclick = () => this.#accountSelectors[this.debitCredit].onOpen();
+56 -73
View File
@@ -175,6 +175,7 @@ class OptionForm {
/**
* The recurring expenses or incomes sub-form.
*
* @private
*/
class RecurringExpenseIncomeSubForm {
@@ -297,6 +298,14 @@ class RecurringExpenseIncomeSubForm {
this.#element.dataset.bsTarget = `#${this.editor.modal.id}`;
this.#element.onclick = () => this.editor.onAddNew();
this.#content.classList.add("d-none");
this.#element.role = "button";
this.#element.tabIndex = 0;
this.#element.onkeydown = (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
this.#element.click();
}
};
} else {
this.#element.classList.add("accounting-not-empty");
this.#element.classList.remove("accounting-clickable");
@@ -304,6 +313,9 @@ class RecurringExpenseIncomeSubForm {
delete this.#element.dataset.bsTarget;
this.#element.onclick = null;
this.#content.classList.remove("d-none");
this.#element.removeAttribute("role");
this.#element.tabIndex = -1;
this.#element.onkeydown = null;
}
}
@@ -350,6 +362,7 @@ class RecurringExpenseIncomeSubForm {
/**
* A recurring item sub-form.
*
* @private
*/
class RecurringItemSubForm {
@@ -373,7 +386,7 @@ class RecurringItemSubForm {
/**
* The control
* @type {HTMLDivElement}
* @type {HTMLButtonElement}
*/
#control;
@@ -397,7 +410,7 @@ class RecurringItemSubForm {
/**
* The text display of the name
* @type {HTMLDivElement}
* @type {HTMLSpanElement}
*/
#nameText;
@@ -409,7 +422,7 @@ class RecurringItemSubForm {
/**
* The text display of the account
* @type {HTMLDivElement}
* @type {HTMLSpanElement}
*/
#accountText;
@@ -421,7 +434,7 @@ class RecurringItemSubForm {
/**
* The text display of the description template
* @type {HTMLDivElement}
* @type {HTMLSpanElement}
*/
#descriptionTemplateText;
@@ -551,6 +564,7 @@ class RecurringItemSubForm {
/**
* The recurring item editor.
*
* @private
*/
class RecurringItemEditor {
@@ -592,13 +606,13 @@ class RecurringItemEditor {
/**
* The control of the account
* @type {HTMLDivElement}
* @type {HTMLButtonElement}
*/
#accountControl;
/**
* The text display of the account
* @type {HTMLDivElement}
* @type {HTMLSpanElement}
*/
#accountContainer;
@@ -829,14 +843,16 @@ class RecurringItemEditor {
/**
* The account selector for the recurring item editor.
*
* @extends {BaseCombobox<RecurringAccount>}
* @private
*/
class RecurringAccountSelector {
class RecurringAccountSelector extends BaseCombobox {
/**
* The recurring item editor
* @type {RecurringItemEditor}
*/
editor;
#editor;
/**
* Either "expense" or "income"
@@ -844,12 +860,6 @@ class RecurringAccountSelector {
*/
#expenseIncome;
/**
* The query input
* @type {HTMLInputElement}
*/
#query;
/**
* The error message when the query has no result
* @type {HTMLParagraphElement}
@@ -862,12 +872,6 @@ class RecurringAccountSelector {
*/
#optionList;
/**
* The account options
* @type {RecurringAccount[]}
*/
#options;
/**
* The button to clear the account
* @type {HTMLButtonElement}
@@ -880,28 +884,39 @@ class RecurringAccountSelector {
* @param editor {RecurringItemEditor} the recurring item editor
*/
constructor(editor) {
this.editor = editor;
this.#expenseIncome = editor.expenseIncome;
const prefix = `accounting-recurring-accounting-selector-${editor.expenseIncome}`;
this.#query = document.getElementById(`${prefix}-query`);
const query = document.getElementById(`${prefix}-query`);
const options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new RecurringAccount(element, editor.saveAccount.bind(editor)));
super(query, options);
this.#editor = editor;
this.#expenseIncome = editor.expenseIncome;
this.#queryNoResult = document.getElementById(`${prefix}-option-no-result`);
this.#optionList = document.getElementById(`${prefix}-option-list`);
this.#options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new RecurringAccount(this, element));
this.#clearButton = document.getElementById(`${prefix}-clear`);
this.#query.oninput = () => this.#filterOptions();
this.#clearButton.onclick = () => this.editor.clearAccount();
this.#clearButton = document.getElementById(`${prefix}-clear`);
this.#clearButton.onclick = () => this.#editor.clearAccount();
const modal = document.getElementById(`${prefix}-modal`);
const closeButton = document.getElementById(`${prefix}-close`);
modal.onkeydown = (event) => {
if (event.key === "Escape") {
closeButton.click();
}
};
}
/**
* Filters the options.
*
* @override
*/
#filterOptions() {
filterOptions() {
this.shownOptions = [];
let isAnyMatched = false;
for (const option of this.#options) {
if (option.isMatched(this.#query.value)) {
for (const option of this.options) {
if (option.isMatched(this.query.value)) {
option.setShown(true);
this.shownOptions.push(option);
isAnyMatched = true;
} else {
option.setShown(false);
@@ -921,12 +936,11 @@ class RecurringAccountSelector {
*
*/
onOpen() {
this.#query.value = "";
this.#filterOptions();
for (const option of this.#options) {
option.setActive(option.code === this.editor.accountCode);
}
if (this.editor.accountCode === null) {
this.query.value = "";
this.filterOptions();
this.query.removeAttribute("aria-activedescendant");
this.selectOption(this.shownOptions.find((option) => option.code === this.#editor.accountCode));
if (this.#editor.accountCode === null) {
this.#clearButton.classList.add("btn-secondary");
this.#clearButton.classList.remove("btn-danger");
this.#clearButton.disabled = true;
@@ -941,14 +955,9 @@ class RecurringAccountSelector {
/**
* An account in the account selector for the recurring item editor.
*
* @private
*/
class RecurringAccount {
/**
* The element
* @type {HTMLLIElement}
*/
#element;
class RecurringAccount extends BaseOption {
/**
* The account code
@@ -971,16 +980,16 @@ class RecurringAccount {
/**
* Constructs the account in the account selector for the recurring item editor.
*
* @param selector {RecurringAccountSelector} the account selector
* @param element {HTMLLIElement} the element
* @param save {function(RecurringAccount): void} the callback to save the option
*/
constructor(selector, element) {
this.#element = element;
constructor(element, save) {
super(element);
this.code = element.dataset.code;
this.text = element.dataset.text;
this.#queryValues = JSON.parse(element.dataset.queryValues);
this.#element.onclick = () => selector.editor.saveAccount(this);
element.onclick = () => save(this);
}
/**
@@ -1000,30 +1009,4 @@ class RecurringAccount {
}
return false;
}
/**
* Sets whether the option is shown.
*
* @param isShown {boolean} true to show, or false otherwise
*/
setShown(isShown) {
if (isShown) {
this.#element.classList.remove("d-none");
} else {
this.#element.classList.add("d-none");
}
}
/**
* Sets whether the option is active.
*
* @param isActive {boolean} true if active, or false otherwise
*/
setActive(isActive) {
if (isActive) {
this.#element.classList.add("active");
} else {
this.#element.classList.remove("active");
}
}
}
@@ -25,26 +25,16 @@
/**
* The original line item selector.
*
* @extends {BaseCombobox<OriginalLineItem>}
* @private
*/
class OriginalLineItemSelector {
class OriginalLineItemSelector extends BaseCombobox {
/**
* The line item editor
* @type {JournalEntryLineItemEditor}
*/
lineItemEditor;
/**
* The prefix of the HTML ID and class names
* @type {string}
*/
#prefix = "accounting-original-line-item-selector";
/**
* The query input
* @type {HTMLInputElement}
*/
#query;
#lineItemEditor;
/**
* The error message when the query has no result
@@ -58,12 +48,6 @@ class OriginalLineItemSelector {
*/
#optionList;
/**
* The options
* @type {OriginalLineItem[]}
*/
#options;
/**
* The options by their ID
* @type {Object.<string, OriginalLineItem>}
@@ -81,22 +65,37 @@ class OriginalLineItemSelector {
*/
#debitCredit;
/**
* The close button.
* @type {HTMLButtonElement}
*/
#closeButton;
/**
* Constructs an original line item selector.
*
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
*/
constructor(lineItemEditor) {
this.lineItemEditor = lineItemEditor;
this.#query = document.getElementById(`${this.#prefix}-query`);
this.#queryNoResult = document.getElementById(`${this.#prefix}-option-no-result`);
this.#optionList = document.getElementById(`${this.#prefix}-option-list`);
this.#options = Array.from(document.getElementsByClassName(`${this.#prefix}-option`)).map((element) => new OriginalLineItem(this, element));
const prefix = "accounting-original-line-item-selector";
const query = document.getElementById(`${prefix}-query`);
const options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new OriginalLineItem(element, lineItemEditor.saveOriginalLineItem.bind(lineItemEditor), lineItemEditor.form));
super(query, options);
this.#lineItemEditor = lineItemEditor;
this.#queryNoResult = document.getElementById(`${prefix}-option-no-result`);
this.#optionList = document.getElementById(`${prefix}-option-list`);
this.#optionById = {};
for (const option of this.#options) {
for (const option of this.options) {
this.#optionById[option.id] = option;
}
this.#query.oninput = () => this.#filterOptions();
this.#closeButton = document.getElementById(`${prefix}-close`);
const modal = document.getElementById(`${prefix}-modal`);
modal.onkeydown = (event) => {
if (event.key === "Escape") {
this.#closeButton.click();
}
};
}
/**
@@ -126,7 +125,7 @@ class OriginalLineItemSelector {
*
*/
#updateNetBalances() {
const otherLineItems = this.lineItemEditor.form.getLineItems().filter((lineItem) => lineItem !== this.lineItemEditor.lineItem);
const otherLineItems = this.#lineItemEditor.form.getLineItems().filter((lineItem) => lineItem !== this.#lineItemEditor.lineItem);
const otherOffsets = {}
for (const otherLineItem of otherLineItems) {
const otherOriginalLineItemId = otherLineItem.originalLineItemId;
@@ -139,7 +138,7 @@ class OriginalLineItemSelector {
}
otherOffsets[otherOriginalLineItemId] = otherOffsets[otherOriginalLineItemId].plus(amount);
}
for (const option of this.#options) {
for (const option of this.options) {
if (option.id in otherOffsets) {
option.updateNetBalance(otherOffsets[option.id]);
} else {
@@ -151,12 +150,15 @@ class OriginalLineItemSelector {
/**
* Filters the options.
*
* @override
*/
#filterOptions() {
filterOptions() {
this.shownOptions = [];
let isAnyMatched = false;
for (const option of this.#options) {
if (option.isMatched(this.#debitCredit, this.#currencyCode, this.#query.value)) {
for (const option of this.options) {
if (option.isMatched(this.#debitCredit, this.#currencyCode, this.query.value)) {
option.setShown(true);
this.shownOptions.push(option);
isAnyMatched = true;
} else {
option.setShown(false);
@@ -174,24 +176,26 @@ class OriginalLineItemSelector {
/**
* The callback when the original line item selector is shown.
*
* @param parentID {string} the ID of the parent element
*/
onOpen() {
this.#currencyCode = this.lineItemEditor.currencyCode;
this.#debitCredit = this.lineItemEditor.debitCredit;
for (const option of this.#options) {
option.setActive(option.id === this.lineItemEditor.originalLineItemId);
}
this.#query.value = "";
onOpen(parentID) {
this.#closeButton.dataset.bsTarget = `#${parentID}`;
this.#currencyCode = this.#lineItemEditor.currencyCode;
this.#debitCredit = this.#lineItemEditor.debitCredit;
this.query.value = "";
this.#updateNetBalances();
this.#filterOptions();
this.filterOptions();
this.query.removeAttribute("aria-activedescendant");
this.selectOption(this.shownOptions.find((option) => option.id === this.#lineItemEditor.originalLineItemId));
}
}
/**
* An original line item.
*
* @private
*/
class OriginalLineItem {
class OriginalLineItem extends BaseOption {
/**
* The journal entry form
@@ -199,12 +203,6 @@ class OriginalLineItem {
*/
#form;
/**
* The element
* @type {HTMLLIElement}
*/
#element;
/**
* The ID
* @type {string}
@@ -274,12 +272,13 @@ class OriginalLineItem {
/**
* Constructs an original line item.
*
* @param selector {OriginalLineItemSelector} the original line item selector
* @param element {HTMLLIElement} the element
* @param save {function(OriginalLineItem): void} the callback to save the option
* @param form {JournalEntryForm} the journal entry form
*/
constructor(selector, element) {
this.#form = selector.lineItemEditor.form;
this.#element = element;
constructor(element, save, form) {
super(element);
this.#form = form;
this.id = element.dataset.id;
this.date = element.dataset.date;
this.#debitCredit = element.dataset.debitCredit;
@@ -291,7 +290,8 @@ class OriginalLineItem {
this.netBalanceText = document.getElementById(`accounting-original-line-item-selector-option-${this.id}-net-balance`);
this.text = element.dataset.text;
this.#queryValues = JSON.parse(element.dataset.queryValues);
this.#element.onclick = () => selector.lineItemEditor.saveOriginalLineItem(this);
element.onclick = () => save(this);
}
/**
@@ -380,30 +380,4 @@ class OriginalLineItem {
const whole = Number(this.netBalance.minus(frac));
return String(whole) + String(frac).substring(1);
}
/**
* Sets whether the option is shown.
*
* @param isShown {boolean} true to show, or false otherwise
*/
setShown(isShown) {
if (isShown) {
this.#element.classList.remove("d-none");
} else {
this.#element.classList.add("d-none");
}
}
/**
* Sets whether the option is active.
*
* @param isActive {boolean} true if active, or false otherwise
*/
setActive(isActive) {
if (isActive) {
this.#element.classList.add("active");
} else {
this.#element.classList.remove("active");
}
}
}
+62 -117
View File
@@ -2,7 +2,7 @@
* period-chooser.js: The JavaScript for the period chooser
*/
/* Copyright (c) 2023 imacat.
/* Copyright (c) 2023-2026 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -30,20 +30,16 @@ document.addEventListener("DOMContentLoaded", () => {
/**
* The period chooser.
*
* @extends {BaseTablist<BasePeriodTab>}
* @private
*/
class PeriodChooser {
class PeriodChooser extends BaseTablist {
/**
* The modal of the period chooser
* @type {HTMLDivElement}
* The URL template for different periods.
* @type {string}
*/
modal;
/**
* The tab planes
* @type {{month: MonthTab, year: YearTab, day: DayTab, custom: CustomTab}}
*/
tabPlanes = {};
urlTemplate;
/**
* Constructs the period chooser.
@@ -51,12 +47,23 @@ class PeriodChooser {
*/
constructor() {
const prefix = "accounting-period-chooser";
this.modal = document.getElementById(`${prefix}-modal`);
for (const cls of [MonthTab, YearTab, DayTab, CustomTab]) {
const tab = new cls(this);
this.tabPlanes[tab.tabId()] = tab;
super(document.getElementById(`${prefix}-tab-list`));
this.tabs = [new MonthTab(this), new YearTab(this), new DayTab(this), new CustomTab(this)];
const modal = document.getElementById(`${prefix}-modal`);
this.urlTemplate = modal.dataset.urlTemplate;
for (const tab of this.tabs) {
if (tab.isActive()) {
this.currentTab = tab;
break;
}
}
}
/**
* @inheritDoc
* @override
*/
onTabFocus(tab) { this.switchTo(tab); }
/**
* The period chooser.
@@ -74,12 +81,12 @@ class PeriodChooser {
}
/**
* A tab plane.
* A base abstract period tab.
*
* @abstract
* @private
*/
class TabPlane {
class BasePeriodTab extends BaseTab {
/**
* The period chooser
@@ -94,62 +101,27 @@ class TabPlane {
prefix;
/**
* The tab
* @type {HTMLSpanElement}
*/
#tab;
/**
* The page
* @type {HTMLDivElement}
*/
#page;
/**
* Constructs a tab plane.
* Constructs a base abstract period tab.
*
* @param tabID {string} the tab ID
* @param chooser {PeriodChooser} the period chooser
*/
constructor(chooser) {
constructor(tabID, chooser) {
const prefix = `accounting-period-chooser-${tabID}`;
const tab = document.getElementById(`${prefix}-tab`);
const panel = document.getElementById(`${prefix}-panel`);
super(tab, panel, chooser.switchTo.bind(chooser));
this.chooser = chooser;
this.prefix = `accounting-period-chooser-${this.tabId()}`;
this.#tab = document.getElementById(`${this.prefix}-tab`);
this.#page = document.getElementById(`${this.prefix}-page`);
this.#tab.onclick = () => this.#switchToMe();
}
/**
* The tab ID
*
* @return {string}
* @abstract
*/
tabId() { throw new Error("Method not implemented.") };
/**
* Switches to the tab plane.
*
*/
#switchToMe() {
for (const tabPlane of Object.values(this.chooser.tabPlanes)) {
tabPlane.#tab.classList.remove("active")
tabPlane.#tab.ariaCurrent = "false";
tabPlane.#page.classList.add("d-none");
tabPlane.#page.ariaCurrent = "false";
}
this.#tab.classList.add("active");
this.#tab.ariaCurrent = "page";
this.#page.classList.remove("d-none");
this.#page.ariaCurrent = "page";
this.prefix = prefix;
}
}
/**
* The month tab plane.
* The month tab.
*
* @private
*/
class MonthTab extends TabPlane {
class MonthTab extends BasePeriodTab {
/**
* The month chooser.
@@ -158,12 +130,12 @@ class MonthTab extends TabPlane {
#monthChooser
/**
* Constructs a tab plane.
* Constructs a month tab.
*
* @param chooser {PeriodChooser} the period chooser
*/
constructor(chooser) {
super(chooser);
super("month", chooser);
const monthChooser = document.getElementById(`${this.prefix}-chooser`);
if (monthChooser !== null) {
this.#monthChooser = new tempusDominus.TempusDominus(monthChooser, {
@@ -183,45 +155,36 @@ class MonthTab extends TabPlane {
const date = e.detail.date;
const zeroPaddedMonth = `0${date.month + 1}`.slice(-2)
const period = `${date.year}-${zeroPaddedMonth}`;
window.location = chooser.modal.dataset.urlTemplate
window.location = chooser.urlTemplate
.replaceAll("PERIOD", period);
});
}
}
}
/**
* The year tab.
*
* @private
*/
class YearTab extends BasePeriodTab {
/**
* The tab ID
* Constructs a year tab.
*
* @return {string}
* @param chooser {PeriodChooser} the period chooser
*/
tabId() {
return "month";
constructor(chooser) {
super("year", chooser);
}
}
/**
* The year tab plane.
* The day tab.
*
* @private
*/
class YearTab extends TabPlane {
/**
* The tab ID
*
* @return {string}
*/
tabId() {
return "year";
}
}
/**
* The day tab plane.
*
* @private
*/
class DayTab extends TabPlane {
class DayTab extends BasePeriodTab {
/**
* The day input
@@ -236,18 +199,18 @@ class DayTab extends TabPlane {
#dateError;
/**
* Constructs a tab plane.
* Constructs a day tab.
*
* @param chooser {PeriodChooser} the period chooser
*/
constructor(chooser) {
super(chooser);
super("day", chooser);
this.#date = document.getElementById(`${this.prefix}-date`);
this.#dateError = document.getElementById(`${this.prefix}-date-error`);
if (this.#date !== null) {
this.#date.onchange = () => {
if (this.#validateDate()) {
window.location = chooser.modal.dataset.urlTemplate
window.location = chooser.urlTemplate
.replaceAll("PERIOD", this.#date.value);
}
};
@@ -274,23 +237,14 @@ class DayTab extends TabPlane {
this.#dateError.innerText = "";
return true;
}
/**
* The tab ID
*
* @return {string}
*/
tabId() {
return "day";
}
}
/**
* The custom tab plane.
* The custom tab.
*
* @private
*/
class CustomTab extends TabPlane {
class CustomTab extends BasePeriodTab {
/**
* The start of the period
@@ -320,20 +274,20 @@ class CustomTab extends TabPlane {
* The confirm button
* @type {HTMLButtonElement}
*/
#conform;
#confirm;
/**
* Constructs a tab plane.
* Constructs a custom tab.
*
* @param chooser {PeriodChooser} the period chooser
*/
constructor(chooser) {
super(chooser);
super("custom", chooser);
this.#start = document.getElementById(`${this.prefix}-start`);
this.#startError = document.getElementById(`${this.prefix}-start-error`);
this.#end = document.getElementById(`${this.prefix}-end`);
this.#endError = document.getElementById(`${this.prefix}-end-error`);
this.#conform = document.getElementById(`${this.prefix}-confirm`);
this.#confirm = document.getElementById(`${this.prefix}-confirm`);
if (this.#start !== null) {
this.#start.onchange = () => {
if (this.#validateStart()) {
@@ -345,12 +299,12 @@ class CustomTab extends TabPlane {
this.#start.max = this.#end.value;
}
};
this.#conform.onclick = () => {
this.#confirm.onclick = () => {
let isValid = true;
isValid = this.#validateStart() && isValid;
isValid = this.#validateEnd() && isValid;
if (isValid) {
window.location = chooser.modal.dataset.urlTemplate
window.location = chooser.urlTemplate
.replaceAll("PERIOD", `${this.#start.value}-${this.#end.value}`);
}
};
@@ -406,13 +360,4 @@ class CustomTab extends TabPlane {
this.#endError.innerText = "";
return true;
}
/**
* The tab ID
*
* @return {string}
*/
tabId() {
return "custom";
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/25
# Copyright (c) 2023-2024 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -23,8 +23,8 @@ from typing import Any
from flask_babel import get_locale
from accounting.locale import gettext
from accounting.utils.timezone import get_tz_today
from .locale import gettext
from .utils.timezone import get_tz_today
def format_amount(value: Decimal | None) -> str | None:
+8 -4
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2026 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,8 +17,11 @@
"""The template globals.
"""
from accounting.models import Currency
from accounting.utils.options import options
import sqlalchemy as sa
from . import db
from .models import Currency
from .utils.options import options
def currency_options() -> list[Currency]:
@@ -26,7 +29,8 @@ def currency_options() -> list[Currency]:
:return: The currency options.
"""
return Currency.query.order_by(Currency.code).all()
return db.session.scalars(
sa.select(Currency).order_by(Currency.code)).unique().all()
def default_currency_code() -> str:
@@ -27,28 +27,28 @@ First written: 2023/1/31
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
<i class="fa-solid fa-circle-chevron-left" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a>
{% if accounting_can_edit() %}
<a class="btn btn-primary d-none d-md-inline" role="button" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
<i class="fa-solid fa-pen-to-square" aria-hidden="true"></i>
{{ A_("Edit") }}
</a>
{% endif %}
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.order", base=obj.base)|accounting_append_next }}">
<i class="fa-solid fa-bars-staggered"></i>
<i class="fa-solid fa-bars-staggered" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Order") }}</span>
</a>
{% if accounting_can_edit() %}
{% if obj.can_delete %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i>
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Delete") }}</span>
</button>
{% else %}
<button class="btn btn-secondary" type="button" disabled="disabled">
<i class="fa-solid fa-trash"></i>
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Delete") }}</span>
</button>
{% endif %}
@@ -57,8 +57,8 @@ First written: 2023/1/31
{% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}" aria-label="{{ A_("Edit") }}">
<i class="fa-solid fa-pen-to-square" aria-hidden="true"></i>
</a>
</div>
{% endif %}
@@ -22,6 +22,7 @@ First written: 2023/2/1
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/base-combobox.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/account-form.js") }}"></script>
{% endblock %}
@@ -29,7 +30,7 @@ First written: 2023/2/1
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
<i class="fa-solid fa-circle-chevron-left" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a>
</div>
@@ -41,9 +42,9 @@ First written: 2023/2/1
{% endif %}
<div class="form-floating mb-3">
<input id="accounting-base-code" type="hidden" name="base_code" value="{{ form.base_code.data|accounting_default }}">
<div id="accounting-base-control" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-modal">
<label class="form-label" for="accounting-base">{{ A_("Base account") }}</label>
<div id="accounting-base">
<button id="accounting-base-control" class="form-control text-start accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" type="button" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-modal">
<span class="form-label">{{ A_("Base account") }}</span>
<span id="accounting-base">
{% if form.base_code.data %}
{% if form.base_code.errors %}
{{ A_("(Unknown)") }}
@@ -51,15 +52,15 @@ First written: 2023/2/1
{{ form.selected_base }}
{% endif %}
{% endif %}
</div>
</div>
<div id="accounting-base-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
</span>
</button>
<div id="accounting-base-error" class="invalid-feedback" role="alert">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
</div>
<div class="form-floating mb-3">
<input id="accounting-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ form.title.data|accounting_default }}" placeholder=" " required="required">
<label class="form-label" for="accounting-title">{{ A_("Title") }}</label>
<div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
<div id="accounting-title-error" class="invalid-feedback" role="alert">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
</div>
<div id="accounting-is-need-offset-control" class="form-check form-switch mb-3 {% if form.base_code.data[0] not in ["1", "2", "3"] %} d-none {% endif %}">
@@ -71,14 +72,14 @@ First written: 2023/2/1
<div class="d-none d-md-block">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i>
{{ A_("Save") }}
</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 class="btn btn-primary" type="submit" aria-label="{{ A_("Save") }}">
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i>
</button>
</div>
</form>
@@ -92,21 +93,21 @@ First written: 2023/2/1
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-base-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<input id="accounting-base-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required" role="combobox" aria-expanded="true" aria-controls="accounting-base-selector-option-list" aria-autocomplete="list" aria-activedescendant="">
<label class="input-group-text" for="accounting-base-selector-query">
<i class="fa-solid fa-magnifying-glass"></i>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-base-selector-option-list" class="list-group accounting-selector-list">
<ul id="accounting-base-selector-option-list" class="list-group accounting-selector-list" role="listbox" tabindex="-1" aria-labelledby="accounting-base-selector-modal-label">
{% for base in form.base_options %}
<li class="list-group-item accounting-clickable accounting-base-selector-option" data-code="{{ base.code }}" data-text="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}" data-bs-dismiss="modal">
<li id="accounting-base-selector-option-{{ base.code }}" class="list-group-item accounting-clickable accounting-base-selector-option" role="option" aria-selected="false" data-code="{{ base.code }}" data-text="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}" data-bs-dismiss="modal">
{{ base }}
</li>
{% endfor %}
</ul>
<p id="accounting-base-selector-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
<p id="accounting-base-selector-option-no-result" class="d-none" role="status">{{ A_("There is no data.") }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
@@ -28,7 +28,7 @@ First written: 2023/1/30
<div class="mb-2 accounting-toolbar">
{% if accounting_can_edit() %}
<a class="btn btn-primary text-nowrap d-none d-md-block" role="button" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i>
<i class="fa-solid fa-plus" aria-hidden="true"></i>
{{ A_("New") }}
</a>
{% endif %}
@@ -36,7 +36,7 @@ First written: 2023/1/30
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Search") }}</span>
</button>
</label>
@@ -45,8 +45,8 @@ First written: 2023/1/30
{% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i>
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.create")|accounting_append_next }}" aria-label="{{ A_("New") }}">
<i class="fa-solid fa-plus" aria-hidden="true"></i>
</a>
</div>
{% endif %}
@@ -32,7 +32,7 @@ First written: 2023/2/2
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
<i class="fa-solid fa-circle-chevron-left" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a>
</div>
@@ -51,21 +51,21 @@ First written: 2023/2/2
<span id="accounting-order-{{ account.id }}-code">{{ account.code }}</span>
{{ account.title }}
</div>
<i class="fa-solid fa-bars"></i>
<i class="fa-solid fa-bars" aria-hidden="true"></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>
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></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 class="btn btn-primary" type="submit" aria-label="{{ A_("Save") }}">
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i>
</button>
</div>
</form>
@@ -27,7 +27,7 @@ First written: 2023/2/1
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
<i class="fa-solid fa-circle-chevron-left" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a>
</div>
@@ -30,7 +30,7 @@ First written: 2023/1/26
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Search") }}</span>
</button>
</label>
@@ -27,24 +27,24 @@ First written: 2023/2/6
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
<i class="fa-solid fa-circle-chevron-left" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a>
{% if accounting_can_edit() %}
<a class="btn btn-primary d-none d-md-inline" role="button" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
<i class="fa-solid fa-pen-to-square" aria-hidden="true"></i>
{{ A_("Edit") }}
</a>
{% endif %}
{% if accounting_can_edit() %}
{% if obj.can_delete %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i>
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Delete") }}</span>
</button>
{% else %}
<button class="btn btn-secondary" type="button" disabled="disabled">
<i class="fa-solid fa-trash"></i>
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Delete") }}</span>
</button>
{% endif %}
@@ -53,8 +53,8 @@ First written: 2023/2/6
{% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}" aria-label="{{ A_("Edit") }}">
<i class="fa-solid fa-pen-to-square" aria-hidden="true"></i>
</a>
</div>
{% endif %}
@@ -29,7 +29,7 @@ First written: 2023/2/6
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
<i class="fa-solid fa-circle-chevron-left" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a>
</div>
@@ -42,25 +42,25 @@ First written: 2023/2/6
<div class="form-floating mb-3">
<input id="accounting-code" class="form-control {% if form.code.errors %} is-invalid {% endif %}" type="text" name="code" value="{{ form.code.data|accounting_default }}" placeholder=" " required="required" data-exists-url="{{ url_for("accounting.currency-api.exists") }}" data-original="{% block original_code %}{% endblock %}" data-blocklist="{{ form.CODE_BLOCKLIST|tojson|forceescape }}">
<label class="form-label" for="accounting-code">{{ A_("Code") }}</label>
<div id="accounting-code-error" class="invalid-feedback">{% if form.code.errors %}{{ form.code.errors[0] }}{% endif %}</div>
<div id="accounting-code-error" class="invalid-feedback" role="alert">{% if form.code.errors %}{{ form.code.errors[0] }}{% endif %}</div>
</div>
<div class="form-floating mb-3">
<input id="accounting-name" class="form-control {% if form.name.errors %} is-invalid {% endif %}" type="text" name="name" value="{{ form.name.data|accounting_default }}" placeholder=" " required="required">
<label class="form-label" for="accounting-name">{{ A_("Name") }}</label>
<div id="accounting-name-error" class="invalid-feedback">{% if form.name.errors %}{{ form.name.errors[0] }}{% endif %}</div>
<div id="accounting-name-error" class="invalid-feedback" role="alert">{% if form.name.errors %}{{ form.name.errors[0] }}{% endif %}</div>
</div>
<div class="d-none d-md-block">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i>
{{ A_("Save") }}
</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 class="btn btn-primary" type="submit" aria-label="{{ A_("Save") }}">
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i>
</button>
</div>
</form>
@@ -28,7 +28,7 @@ First written: 2023/2/6
<div class="mb-2 accounting-toolbar">
{% if accounting_can_edit() %}
<a class="btn btn-primary text-nowrap d-none d-md-block" role="button" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i>
<i class="fa-solid fa-plus" aria-hidden="true"></i>
{{ A_("New") }}
</a>
{% endif %}
@@ -36,7 +36,7 @@ First written: 2023/2/6
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Search") }}</span>
</button>
</label>
@@ -45,8 +45,8 @@ First written: 2023/2/6
{% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i>
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.create")|accounting_append_next }}" aria-label="{{ A_("New") }}">
<i class="fa-solid fa-plus" aria-hidden="true"></i>
</a>
</div>
{% endif %}
@@ -22,39 +22,39 @@ First written: 2023/1/26
{# <ul> For SonarQube not to complain about incorrect HTML #}
{% if accounting_can_view() %}
<li class="nav-item dropdown">
<span class="nav-link dropdown-toggle" data-bs-toggle="dropdown">
<i class="fa-solid fa-file-invoice-dollar"></i>
<button class="nav-link dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-file-invoice-dollar" aria-hidden="true"></i>
{{ A_("Accounting") }}
</span>
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting-report.") %} active {% endif %}" href="{{ url_for("accounting-report.default") }}">
<i class="fa-solid fa-book"></i>
<i class="fa-solid fa-book" aria-hidden="true"></i>
{{ A_("Reports") }}
</a>
</li>
<li>
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}">
<i class="fa-solid fa-clipboard"></i>
<i class="fa-solid fa-clipboard" aria-hidden="true"></i>
{{ A_("Accounts") }}
</a>
</li>
<li>
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.base-account.") %} active {% endif %}" href="{{ url_for("accounting.base-account.list") }}">
<i class="fa-solid fa-list"></i>
<i class="fa-solid fa-list" aria-hidden="true"></i>
{{ A_("Base Accounts") }}
</a>
</li>
<li>
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.currency.") %} active {% endif %}" href="{{ url_for("accounting.currency.list") }}">
<i class="fa-solid fa-money-bill-wave"></i>
<i class="fa-solid fa-money-bill-wave" aria-hidden="true"></i>
{{ A_("Currencies") }}
</a>
</li>
{% if accounting_can_admin() %}
<li>
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.option.") %} active {% endif %}" href="{{ url_for("accounting.option.detail") }}">
<i class="fa-solid fa-gear"></i>
<i class="fa-solid fa-gear" aria-hidden="true"></i>
{{ A_("Settings") }}
</a>
</li>
@@ -38,7 +38,7 @@ First written: 2023/1/26
{% endif %}
{% endfor %}
<li class="page-item d-none d-md-inline active dropdown">
<div class="page-link dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<div class="page-link dropdown-toggle" role="button" tabindex="0" data-bs-toggle="dropdown" aria-expanded="false">
{{ pagination.page_size }}
</div>
<ul class="dropdown-menu">
@@ -2,7 +2,7 @@
The Mia! Accounting Project
detail.html: The cash disbursement journal entry detail
Copyright (c) 2023 imacat.
Copyright (c) 2023-2026 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -21,9 +21,9 @@ First written: 2023/2/26
#}
{% extends "accounting/journal-entry/include/detail.html" %}
{% block as_trasfer %}
{% block as_transfer %}
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-table-columns"></i>
<i class="fa-solid fa-table-columns" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
</a>
{% endblock %}
@@ -19,31 +19,31 @@ account-selector-modal.html: The modal for the account selector
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-account-selector-{{ debit_credit }}-modal" class="modal fade accounting-account-selector" data-debit-credit="{{ debit_credit }}" tabindex="-1" aria-labelledby="accounting-account-selector-{{ debit_credit }}-modal-label" aria-hidden="true">
<div id="accounting-account-selector-{{ debit_credit }}-modal" class="modal fade accounting-account-selector" data-debit-credit="{{ debit_credit }}" tabindex="-1" aria-labelledby="accounting-account-selector-{{ debit_credit }}-modal-label" aria-hidden="true" data-bs-keyboard="false">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-account-selector-{{ debit_credit }}-modal-label">{{ A_("Select Account") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
<button id="accounting-account-selector-{{ debit_credit }}-close" type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-account-selector-{{ debit_credit }}-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<input id="accounting-account-selector-{{ debit_credit }}-query" class="form-control form-control-sm" type="search" placeholder=" " required="required" role="combobox" aria-expanded="true" aria-controls="accounting-account-selector-{{ debit_credit }}-option-list" aria-autocomplete="list" aria-activedescendant="">
<label class="input-group-text" for="accounting-account-selector-{{ debit_credit }}-query">
<i class="fa-solid fa-magnifying-glass"></i>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-account-selector-{{ debit_credit }}-option-list" class="list-group accounting-selector-list">
<ul id="accounting-account-selector-{{ debit_credit }}-option-list" class="list-group accounting-selector-list" role="listbox" tabindex="-1" aria-labelledby="accounting-account-selector-{{ debit_credit }}-modal-label">
{% for account in account_options %}
<li id="accounting-account-selector-{{ debit_credit }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ debit_credit }}-option {% if account.is_in_use %} accounting-account-is-in-use {% endif %} {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" data-code="{{ account.code }}" data-title="{{ account.title }}" data-text="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<li id="accounting-account-selector-{{ debit_credit }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ debit_credit }}-option {% if account.is_in_use %} accounting-account-is-in-use {% endif %} {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" role="option" aria-selected="false" data-code="{{ account.code }}" data-title="{{ account.title }}" data-text="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
{{ account }}
</li>
{% endfor %}
<li id="accounting-account-selector-{{ debit_credit }}-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
<li id="accounting-account-selector-{{ debit_credit }}-more" class="list-group-item accounting-clickable" role="option" aria-selected="false">{{ A_("More…") }}</li>
</ul>
<p id="accounting-account-selector-{{ debit_credit }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
<p id="accounting-account-selector-{{ debit_credit }}-option-no-result" class="d-none" role="status">{{ A_("There is no data.") }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">{{ A_("Cancel") }}</button>
@@ -21,14 +21,14 @@ First written: 2023/2/28
#}
<form id="accounting-description-editor-{{ description_editor.debit_credit }}" class="accounting-description-editor" data-debit-credit="{{ description_editor.debit_credit }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label" aria-hidden="true">
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label" aria-hidden="true" data-bs-keyboard="false">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label">
<label for="accounting-description-editor-{{ description_editor.debit_credit }}-description">{{ A_("Description") }}</label>
</h1>
<button class="btn-close" type="button" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-close" class="btn-close" type="button" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="d-flex justify-content-between mb-3">
@@ -39,40 +39,40 @@ First written: 2023/2/28
</div>
{# Tab navigation #}
<ul class="nav nav-tabs mb-2">
<ul id="accounting-description-editor-{{ description_editor.debit_credit }}-tab-list" class="nav nav-tabs mb-2" role="tablist" aria-label="{{ A_("Description Type") }}">
<li class="nav-item">
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tab" class="nav-link active accounting-clickable" aria-current="page">
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tab" class="nav-link active accounting-clickable" type="button" tabindex="0" role="tab" aria-controls="accounting-description-editor-{{ description_editor.debit_credit }}-general-panel" aria-selected="true">
{{ A_("General") }}
</span>
</button>
</li>
<li class="nav-item">
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tab" class="nav-link accounting-clickable" aria-current="false">
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tab" class="nav-link accounting-clickable" type="button" tabindex="-1" role="tab" aria-controls="accounting-description-editor-{{ description_editor.debit_credit }}-travel-panel" aria-selected="false">
{{ A_("Travel") }}
</span>
</button>
</li>
<li class="nav-item">
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tab" class="nav-link accounting-clickable" aria-current="false">
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tab" class="nav-link accounting-clickable" type="button" tabindex="-1" role="tab" aria-controls="accounting-description-editor-{{ description_editor.debit_credit }}-bus-panel" aria-selected="false">
{{ A_("Bus") }}
</span>
</button>
</li>
<li class="nav-item">
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab" class="nav-link accounting-clickable" aria-current="false">
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab" class="nav-link accounting-clickable" type="button" tabindex="-1" role="tab" aria-controls="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-panel" aria-selected="false">
{{ A_("Recurring") }}
</span>
</button>
</li>
<li class="nav-item">
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-tab" class="nav-link accounting-clickable" aria-current="false">
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-tab" class="nav-link accounting-clickable" type="button" tabindex="-1" role="tab" aria-controls="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-panel" aria-selected="false">
{{ A_("Annotation") }}
</span>
</button>
</li>
</ul>
{# A general description with a tag #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-page" aria-current="page" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-general-tab">
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-panel" role="tabpanel" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-general-tab">
<div class="form-floating mb-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag">{{ A_("Tag") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag-error" class="invalid-feedback"></div>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag-error" class="invalid-feedback" role="alert"></div>
</div>
<div class="accounting-description-editor-buttons">
@@ -85,11 +85,11 @@ First written: 2023/2/28
</div>
{# A general trip with the origin and distination #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tab">
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-panel" class="d-none" role="tabpanel" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tab">
<div class="form-floating mb-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag">{{ A_("Tag") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag-error" class="invalid-feedback"></div>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag-error" class="invalid-feedback" role="alert"></div>
</div>
<div class="accounting-description-editor-buttons">
@@ -104,7 +104,7 @@ First written: 2023/2/28
<div class="form-floating">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from">{{ A_("From") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from-error" class="invalid-feedback"></div>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from-error" class="invalid-feedback" role="alert"></div>
</div>
<div class="btn-group-vertical ms-1 me-1">
<button class="btn btn-primary accounting-description-editor-{{ description_editor.debit_credit }}-travel-direction accounting-default" type="button" tabindex="-1" data-arrow="&rarr;">&rarr;</button>
@@ -113,23 +113,23 @@ First written: 2023/2/28
<div class="form-floating">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to">{{ A_("To") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to-error" class="invalid-feedback"></div>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to-error" class="invalid-feedback" role="alert"></div>
</div>
</div>
</div>
{# A bus trip with the route name or route number, the origin and distination #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tab">
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-panel" class="d-none" role="tabpanel" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tab">
<div class="d-flex justify-content-between mb-2">
<div class="form-floating me-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag">{{ A_("Tag") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag-error" class="invalid-feedback"></div>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag-error" class="invalid-feedback" role="alert"></div>
</div>
<div class="form-floating">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route">{{ A_("Route") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route-error" class="invalid-feedback"></div>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route-error" class="invalid-feedback" role="alert"></div>
</div>
</div>
@@ -145,18 +145,18 @@ First written: 2023/2/28
<div class="form-floating me-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from">{{ A_("From") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from-error" class="invalid-feedback"></div>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from-error" class="invalid-feedback" role="alert"></div>
</div>
<div class="form-floating">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to">{{ A_("To") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to-error" class="invalid-feedback"></div>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to-error" class="invalid-feedback" role="alert"></div>
</div>
</div>
</div>
{# A recurring transaction #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab">
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-panel" class="d-none" role="tabpanel" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab">
<div class="accounting-description-editor-buttons">
{% for recurring in description_editor.recurring %}
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-recurring-item" type="button" tabindex="-1" data-template="{{ recurring.description_template }}" data-accounts="{{ recurring.account_codes|tojson|forceescape }}">
@@ -167,17 +167,17 @@ First written: 2023/2/28
</div>
{# The annotation #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-tab">
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-panel" class="d-none" role="tabpanel" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-tab">
<div class="form-floating">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number" class="form-control" type="number" min="1" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number">{{ A_("The Number of Items") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number-error" class="invalid-feedback"></div>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number-error" class="invalid-feedback" role="alert"></div>
</div>
<div class="form-floating mt-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note">{{ A_("Note") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note-error" class="invalid-feedback"></div>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note-error" class="invalid-feedback" role="alert"></div>
</div>
</div>
@@ -2,7 +2,7 @@
The Mia! Accounting Project
detail.html: The account detail
Copyright (c) 2023 imacat.
Copyright (c) 2023-2026 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -27,29 +27,29 @@ First written: 2023/2/26
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting-report.default")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
<i class="fa-solid fa-circle-chevron-left" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a>
{% if accounting_can_edit() %}
<a class="btn btn-primary d-none d-md-inline" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
<i class="fa-solid fa-pen-to-square" aria-hidden="true"></i>
{{ A_("Edit") }}
</a>
{% endif %}
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.order", date=obj.date)|accounting_append_next }}">
<i class="fa-solid fa-bars-staggered"></i>
<i class="fa-solid fa-bars-staggered" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Order") }}</span>
</a>
{% if accounting_can_edit() %}
{% block as_trasfer %}{% endblock %}
{% block as_transfer %}{% endblock %}
{% if obj.can_delete %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i>
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Delete") }}</span>
</button>
{% else %}
<button class="btn btn-secondary" type="button" disabled="disabled">
<i class="fa-solid fa-trash"></i>
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Delete") }}</span>
</button>
{% endif %}
@@ -58,8 +58,8 @@ First written: 2023/2/26
{% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}" aria-label="{{ A_("Edit") }}">
<i class="fa-solid fa-pen-to-square" aria-hidden="true"></i>
</a>
</div>
{% endif %}
@@ -104,7 +104,7 @@ First written: 2023/2/26
{% if obj.note %}
<div class="card mb-3">
<div class="card-body">
<i class="far fa-comment-dots"></i>
<i class="far fa-comment-dots" aria-hidden="true"></i>
{{ obj.note|accounting_journal_entry_text2html|safe }}
</div>
</div>
@@ -31,17 +31,17 @@ First written: 2023/3/21
{% endfor %}
</select>
<label class="form-label" for="accounting-currency-{{ currency_index }}-code-select">{{ 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 id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback" role="alert">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
</div>
<div>
<button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
<i class="fas fa-minus"></i>
<button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}" aria-label="{{ A_("Remove") }}">
<i class="fas fa-minus" aria-hidden="true"></i>
</button>
</div>
</div>
{% block line_items %}{% endblock %}
</div>
<div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div>
<div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback" role="alert">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div>
</div>
@@ -20,8 +20,8 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/21
#}
<div class="mb-2">
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}" class="form-control accounting-material-text-field {% if line_item_forms %} accounting-not-empty {% else %} accounting-clickable {% endif %} {% if debit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-{{ debit_credit }}">{{ header }}</label>
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}" class="form-control accounting-material-text-field {% if line_item_forms %} accounting-not-empty {% else %} accounting-clickable {% endif %} {% if debit_errors %} is-invalid {% endif %}" {% if line_item_forms %} tabindex="-1" {% else %} role="button" tabindex="0" {% endif %}>
<span class="form-label">{{ header }}</span>
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-content" class="mt-2 {% if not line_item_forms %} d-none {% endif %}">
<ul id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-list" class="list-group accounting-line-item-list">
{% for line_item_form in line_item_forms %}
@@ -36,16 +36,16 @@ First written: 2023/3/21
<div class="d-flex justify-content-between mt-2 mb-2">
<div>{{ A_("Total") }}</div>
<div><span id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-total" class="badge rounded-pill bg-primary">{{ debit_credit_total }}</span></div>
<div><span id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-total" class="badge rounded-pill bg-primary" aria-live="polite">{{ debit_credit_total }}</span></div>
</div>
<div>
<button id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="{{ debit_credit }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<i class="fas fa-plus"></i>
<i class="fas fa-plus" aria-hidden="true"></i>
{{ A_("New") }}
</button>
</div>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-error" class="invalid-feedback">{% if debit_credit_errors %}{{ debit_credit_errors[0] }}{% endif %}</div>
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-error" class="invalid-feedback" role="alert">{% if debit_credit_errors %}{{ debit_credit_errors[0] }}{% endif %}</div>
</div>
@@ -30,7 +30,7 @@ First written: 2023/2/25
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description" value="{{ form.description.data|accounting_default }}">
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount" value="{{ form.amount.data|accounting_journal_entry_format_amount_input }}" data-min="{{ form.offset_total|accounting_default("0") }}">
<div class="accounting-line-item-content">
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-control" class="form-control clickable d-flex justify-content-between {% if form.all_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-control" class="form-control clickable d-flex justify-content-between {% if form.all_errors %} is-invalid {% endif %}" role="button" tabindex="0" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<div>
<div class="small">
<span id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account-text-code" class="d-none d-md-inline">{{ form.account_code.data|accounting_default }}</span>
@@ -65,12 +65,12 @@ First written: 2023/2/25
</div>
<div><span id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount-text" class="badge rounded-pill bg-primary">{{ form.amount.data|accounting_format_amount }}</span></div>
</div>
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-error" class="invalid-feedback">{% if form.all_errors %}{{ form.all_errors[0] }}{% endif %}</div>
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-error" class="invalid-feedback" role="alert">{% if form.all_errors %}{{ form.all_errors[0] }}{% endif %}</div>
</div>
<div>
<button id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_form or form.offsets %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}">
<i class="fas fa-minus"></i>
<button id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_form or form.offsets %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}" aria-label="{{ A_("Remove") }}">
<i class="fas fa-minus" aria-hidden="true"></i>
</button>
</div>
</li>
@@ -23,10 +23,12 @@ First written: 2023/2/26
{% 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/base-combobox.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/journal-entry-form.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/journal-entry-line-item-editor.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/journal-entry-account-selector.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/original-line-item-selector.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/base-tablist.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/description-editor.js") }}"></script>
{% endblock %}
@@ -34,7 +36,7 @@ First written: 2023/2/26
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
<i class="fa-solid fa-circle-chevron-left" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a>
</div>
@@ -48,7 +50,7 @@ First written: 2023/2/26
<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="{{ form.date.data|accounting_default }}" max="{{ form.max_date|accounting_default }}" min="{{ form.min_date|accounting_default }}" placeholder=" " required="required">
<label class="form-label" for="accounting-date">{{ A_("Date") }}</label>
<div id="accounting-date-error" class="invalid-feedback">{% if form.date.errors %}{{ form.date.errors[0] }}{% endif %}</div>
<div id="accounting-date-error" class="invalid-feedback" role="alert">{% if form.date.errors %}{{ form.date.errors[0] }}{% endif %}</div>
</div>
<div class="mb-3">
@@ -60,30 +62,30 @@ First written: 2023/2/26
<div>
<button id="accounting-add-currency" class="btn btn-primary" type="button">
<i class="fas fa-plus"></i>
<i class="fas fa-plus" aria-hidden="true"></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 id="accounting-currencies-error" class="invalid-feedback" role="alert">{% 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=" ">{{ form.note.data|accounting_default }}</textarea>
<label class="form-label" for="accounting-note">{{ A_("Note") }}</label>
<div id="accounting-note-error" class="invalid-feedback">{% if form.note.errors %}{{ form.note.errors[0] }}{% endif %}</div>
<div id="accounting-note-error" class="invalid-feedback" role="alert">{% 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>
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></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 class="btn btn-primary" type="submit" aria-label="{{ A_("Save") }}">
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i>
</button>
</div>
</form>
@@ -31,40 +31,40 @@ First written: 2023/2/25
<div class="modal-body">
<div id="accounting-line-item-editor-original-line-item-container" class="d-flex justify-content-between mb-3">
<div class="accounting-line-item-editor-original-line-item-content">
<div id="accounting-line-item-editor-original-line-item-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="#accounting-original-line-item-selector-modal">
<label class="form-label" for="accounting-line-item-editor-original-line-item">{{ A_("Original Line Item") }}</label>
<div id="accounting-line-item-editor-original-line-item"></div>
</div>
<div id="accounting-line-item-editor-original-line-item-error" class="invalid-feedback"></div>
<button id="accounting-line-item-editor-original-line-item-control" class="form-control text-start accounting-material-text-field" type="button" data-bs-toggle="modal" data-bs-target="#accounting-original-line-item-selector-modal">
<span class="form-label">{{ A_("Original Line Item") }}</span>
<span id="accounting-line-item-editor-original-line-item"></span>
</button>
<div id="accounting-line-item-editor-original-line-item-error" class="invalid-feedback" role="alert"></div>
</div>
<div>
<button id="accounting-line-item-editor-original-line-item-delete" class="btn btn-danger rounded-circle" type="button">
<i class="fas fa-minus"></i>
<button id="accounting-line-item-editor-original-line-item-delete" class="btn btn-danger rounded-circle" type="button" aria-label="{{ A_("Remove") }}">
<i class="fas fa-minus" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="mb-3">
<div id="accounting-line-item-editor-description-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
<label class="form-label" for="accounting-line-item-editor-description">{{ A_("Description") }}</label>
<div id="accounting-line-item-editor-description"></div>
</div>
<div id="accounting-line-item-editor-description-error" class="invalid-feedback"></div>
<button id="accounting-line-item-editor-description-control" class="form-control text-start accounting-material-text-field" type="button" data-bs-toggle="modal" data-bs-target="">
<span class="form-label">{{ A_("Description") }}</span>
<span id="accounting-line-item-editor-description"></span>
</button>
<div id="accounting-line-item-editor-description-error" class="invalid-feedback" role="alert"></div>
</div>
<div class="mb-3">
<div id="accounting-line-item-editor-account-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
<label class="form-label" for="accounting-line-item-editor-account">{{ A_("Account") }}</label>
<div id="accounting-line-item-editor-account"></div>
</div>
<div id="accounting-line-item-editor-account-error" class="invalid-feedback"></div>
<button id="accounting-line-item-editor-account-control" class="form-control text-start accounting-material-text-field" type="button" data-bs-toggle="modal" data-bs-target="">
<span class="form-label">{{ A_("Account") }}</span>
<span id="accounting-line-item-editor-account"></span>
</button>
<div id="accounting-line-item-editor-account-error" class="invalid-feedback" role="alert"></div>
</div>
<div class="form-floating mb-3">
<input id="accounting-line-item-editor-amount" class="form-control" type="number" value="" min="0" max="" step="0.01" placeholder=" " required="required">
<label for="accounting-line-item-editor-amount">{{ A_("Amount") }}</label>
<div id="accounting-line-item-editor-amount-error" class="invalid-feedback"></div>
<div id="accounting-line-item-editor-amount-error" class="invalid-feedback" role="alert"></div>
</div>
</div>
<div class="modal-footer">
@@ -19,25 +19,25 @@ original-line-item-selector-modal.html: The modal of the original line item sele
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-original-line-item-selector-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-original-line-item-selector-modal-label" aria-hidden="true">
<div id="accounting-original-line-item-selector-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-original-line-item-selector-modal-label" aria-hidden="true" data-bs-keyboard="false">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-original-line-item-selector-modal-label">{{ A_("Select Original Line Item") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
<button id="accounting-original-line-item-selector-close" type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-original-line-item-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<input id="accounting-original-line-item-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required" role="combobox" aria-expanded="true" aria-controls="accounting-original-line-item-selector-option-list" aria-autocomplete="list" aria-activedescendant="">
<label class="input-group-text" for="accounting-original-line-item-selector-query">
<i class="fa-solid fa-magnifying-glass"></i>
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-original-line-item-selector-option-list" class="list-group accounting-selector-list">
<ul id="accounting-original-line-item-selector-option-list" class="list-group accounting-selector-list" role="listbox" tabindex="-1" aria-labelledby="accounting-original-line-item-selector-modal-label">
{% for line_item in form.original_line_item_options %}
<li id="accounting-original-line-item-selector-option-{{ line_item.id }}" class="list-group-item d-flex justify-content-between accounting-clickable accounting-original-line-item-selector-option" data-id="{{ line_item.id }}" data-date="{{ line_item.journal_entry.date }}" data-debit-credit="{{ "debit" if line_item.is_debit else "credit" }}" data-currency-code="{{ line_item.currency.code }}" data-account-code="{{ line_item.account_code }}" data-account-title="{{ line_item.account.title }}" data-account-text="{{ line_item.account }}" data-description="{{ line_item.description|accounting_default }}" data-net-balance="{{ line_item.net_balance|accounting_journal_entry_format_amount_input }}" data-text="{{ line_item }}" data-query-values="{{ line_item.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<li id="accounting-original-line-item-selector-option-{{ line_item.id }}" class="list-group-item d-flex justify-content-between accounting-clickable accounting-original-line-item-selector-option" data-id="{{ line_item.id }}" data-date="{{ line_item.journal_entry.date }}" data-debit-credit="{{ "debit" if line_item.is_debit else "credit" }}" data-currency-code="{{ line_item.currency.code }}" data-account-code="{{ line_item.account_code }}" data-account-title="{{ line_item.account.title }}" data-account-text="{{ line_item.account }}" data-description="{{ line_item.description|accounting_default }}" data-net-balance="{{ line_item.net_balance|accounting_journal_entry_format_amount_input }}" data-text="{{ line_item }}" data-query-values="{{ line_item.query_values|tojson|forceescape }}" role="option" aria-selected="false" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<div>
<div class="small">
{{ line_item.journal_entry.date|accounting_format_date }}
@@ -55,7 +55,7 @@ First written: 2023/2/25
</li>
{% endfor %}
</ul>
<p id="accounting-original-line-item-selector-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
<p id="accounting-original-line-item-selector-option-no-result" class="d-none" role="status">{{ A_("There is no data.") }}</p>
</div>
</div>
</div>
@@ -32,7 +32,7 @@ First written: 2023/2/26
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting-report.default")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
<i class="fa-solid fa-circle-chevron-left" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a>
</div>
@@ -50,21 +50,21 @@ First written: 2023/2/26
{% with journal_entry = item %}
{% include "accounting/journal-entry/include/order-journal-entry.html" %}
{% endwith %}
<i class="fa-solid fa-bars"></i>
<i class="fa-solid fa-bars" aria-hidden="true"></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>
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></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 class="btn btn-primary" type="submit" aria-label="{{ A_("Save") }}">
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i>
</button>
</div>
</form>
@@ -2,7 +2,7 @@
The Mia! Accounting Project
detail.html: The cash receipt journal entry detail
Copyright (c) 2023 imacat.
Copyright (c) 2023-2026 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -21,9 +21,9 @@ First written: 2023/2/26
#}
{% extends "accounting/journal-entry/include/detail.html" %}
{% block as_trasfer %}
{% block as_transfer %}
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-table-columns"></i>
<i class="fa-solid fa-table-columns" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
</a>
{% endblock %}
@@ -41,7 +41,7 @@ First written: 2023/2/26
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
<div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div>
<div>{{ currency.credit_total|accounting_format_amount }}</div>
</div>
</li>
</ul>
@@ -2,7 +2,7 @@
The Mia! Accounting Project
detail.html: The transfer journal entry detail
Copyright (c) 2023 imacat.
Copyright (c) 2023-2026 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -53,7 +53,7 @@ First written: 2023/2/26
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
<div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div>
<div>{{ currency.credit_total|accounting_format_amount }}</div>
</div>
</li>
</ul>
@@ -27,14 +27,14 @@ First written: 2023/3/22
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary d-none d-md-inline" role="button" href="{{ url_for("accounting.option.edit")|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
<i class="fa-solid fa-pen-to-square" aria-hidden="true"></i>
{{ A_("Edit") }}
</a>
</div>
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.option.edit")|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.option.edit")|accounting_inherit_next }}" aria-label="{{ A_("Edit") }}">
<i class="fa-solid fa-pen-to-square" aria-hidden="true"></i>
</a>
</div>
@@ -23,6 +23,7 @@ First written: 2023/3/22
{% 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/base-combobox.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/option-form.js") }}"></script>
{% endblock %}
@@ -32,7 +33,7 @@ First written: 2023/3/22
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.option.detail")|accounting_inherit_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
<i class="fa-solid fa-circle-chevron-left" aria-hidden="true"></i>
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a>
</div>
@@ -49,7 +50,7 @@ First written: 2023/3/22
{% endfor %}
</select>
<label class="form-label" for="accounting-default-currency">{{ A_("Default Currency") }}</label>
<div id="accounting-default-currency-error" class="invalid-feedback">{% if form.default_currency_code.errors %}{{ form.default_currency_code.errors[0] }}{% endif %}</div>
<div id="accounting-default-currency-error" class="invalid-feedback" role="alert">{% if form.default_currency_code.errors %}{{ form.default_currency_code.errors[0] }}{% endif %}</div>
</div>
<div class="form-floating mb-3">
@@ -59,7 +60,7 @@ First written: 2023/3/22
{% endfor %}
</select>
<label class="form-label" for="accounting-default-ie-account">{{ A_("Default Account for the Income and Expenses Log") }}</label>
<div id="accounting-default-ie-account-error" class="invalid-feedback">{% if form.default_ie_account_code.errors %}{{ form.default_ie_account_code.errors[0] }}{% endif %}</div>
<div id="accounting-default-ie-account-error" class="invalid-feedback" role="alert">{% if form.default_ie_account_code.errors %}{{ form.default_ie_account_code.errors[0] }}{% endif %}</div>
</div>
{% with expense_income = "expense",
@@ -76,14 +77,14 @@ First written: 2023/3/22
<div class="d-none d-md-block">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i>
{{ A_("Save") }}
</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 class="btn btn-primary" type="submit" aria-label="{{ A_("Save") }}">
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i>
</button>
</div>
</form>
@@ -19,8 +19,8 @@ form-recurring-expense-income.html: The recurring expense or income sub-form in
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/22
#}
<div id="accounting-recurring-{{ expense_income }}" class="form-control mb-3 accounting-material-text-field {% if recurring_items %} accounting-not-empty {% else %} accounting-clickable {% endif %}">
<label class="form-label" for="accounting-recurring-{{ expense_income }}">{{ label }}</label>
<div id="accounting-recurring-{{ expense_income }}" class="form-control mb-3 accounting-material-text-field {% if recurring_items %} accounting-not-empty {% else %} accounting-clickable {% endif %}" {% if recurring_items %} tabindex="-1" {% else %} role="button" tabindex="0" {% endif %}>
<span class="form-label">{{ label }}</span>
<div id="accounting-recurring-{{ expense_income }}-content" class="{% if not recurring_items %} d-none {% endif %}">
<ul id="accounting-recurring-{{ expense_income }}-list" class="list-group mb-2 mt-2">
{% for recurring_item in recurring_items %}
@@ -33,7 +33,7 @@ First written: 2023/3/22
<div>
<button id="accounting-recurring-{{ expense_income }}-add" class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-recurring-item-editor-{{ expense_income }}-modal">
<i class="fas fa-plus"></i>
<i class="fas fa-plus" aria-hidden="true"></i>
{{ A_("New") }}
</button>
</div>

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