Compare commits
91 Commits
Author | SHA1 | Date | |
---|---|---|---|
bf2c7bb785 | |||
93ba086548 | |||
5c4f6017b8 | |||
cb16b2f0ff | |||
d2f11e8779 | |||
4ccaf01b3c | |||
7c512b1c15 | |||
dc432da398 | |||
c8504bcbf5 | |||
c865141583 | |||
8c1ecd6eac | |||
e8e4100677 | |||
6a8773c531 | |||
30e0c7682c | |||
eb5a7bef7e | |||
8a174d8847 | |||
7459afd63a | |||
a9afc385e9 | |||
a8be739ec7 | |||
0130bc58a9 | |||
821059fa80 | |||
5b4f57d0b3 | |||
4bfac2d545 | |||
f105f0cf7b | |||
5e320729d7 | |||
7515032082 | |||
361b18e411 | |||
7d084e570e | |||
cb397910f8 | |||
5f8b0dec98 | |||
8398d1e8bb | |||
562801692a | |||
faee1e61c6 | |||
57a4177037 | |||
fa1dedf207 | |||
7ed13dc0af | |||
52807c5322 | |||
231a71feea | |||
4902eecae0 | |||
889e4c058e | |||
7262a6cb42 | |||
c4ff4ecb3d | |||
2859f628ea | |||
e0355b2af1 | |||
b4d390c33a | |||
a4ab8a761c | |||
907ce6d06e | |||
7e1388735e | |||
6f773dd837 | |||
87fa5aa6bc | |||
35e05b3708 | |||
7ccc96bda0 | |||
283758ebe9 | |||
b673c7aeaf | |||
0ad2ac53dd | |||
7e90ec5a8f | |||
7755365467 | |||
979eea606a | |||
5a9e08f2c4 | |||
68c810d492 | |||
5f88260507 | |||
779d89f8c4 | |||
5d4bf4361b | |||
10170d613d | |||
c885c08c37 | |||
e2a4340f2a | |||
9728ff30e0 | |||
a4644ede5f | |||
8f477dd6f1 | |||
44ac53f15c | |||
5edb5465c5 | |||
067afdb165 | |||
37a4c26f86 | |||
89e43830b4 | |||
671dbfb692 | |||
2014344d25 | |||
f9c39709c8 | |||
b394c58ec6 | |||
0af3e2785b | |||
7066f75e72 | |||
619540da49 | |||
567004f7d9 | |||
761d5a5824 | |||
fa3cdace7f | |||
656762850c | |||
e2325f08d0 | |||
855356084e | |||
7aaeb32a3d | |||
b376cf1580 | |||
ccbdc779ac | |||
61ee08fda2 |
29
docs/source/accounting.option.rst
Normal file
29
docs/source/accounting.option.rst
Normal file
@ -0,0 +1,29 @@
|
||||
accounting.option package
|
||||
=========================
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
accounting.option.forms module
|
||||
------------------------------
|
||||
|
||||
.. automodule:: accounting.option.forms
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.option.views module
|
||||
------------------------------
|
||||
|
||||
.. automodule:: accounting.option.views
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: accounting.option
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
@ -28,14 +28,6 @@ accounting.report.utils.csv\_export module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.utils.ie\_account module
|
||||
------------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.utils.ie_account
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.utils.option\_link module
|
||||
-------------------------------------------
|
||||
|
||||
|
@ -11,12 +11,21 @@ Subpackages
|
||||
accounting.base_account
|
||||
accounting.currency
|
||||
accounting.journal_entry
|
||||
accounting.option
|
||||
accounting.report
|
||||
accounting.utils
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
accounting.forms module
|
||||
-----------------------
|
||||
|
||||
.. automodule:: accounting.forms
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.locale module
|
||||
------------------------
|
||||
|
||||
|
@ -12,6 +12,14 @@ accounting.utils.cast module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.current\_account module
|
||||
----------------------------------------
|
||||
|
||||
.. automodule:: accounting.utils.current_account
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.flash\_errors module
|
||||
-------------------------------------
|
||||
|
||||
@ -36,6 +44,14 @@ accounting.utils.next\_uri module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.options module
|
||||
-------------------------------
|
||||
|
||||
.. automodule:: accounting.utils.options
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.pagination module
|
||||
----------------------------------
|
||||
|
||||
|
@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath('../../src/'))
|
||||
project = 'Mia! Accounting Flask'
|
||||
copyright = '2023, imacat'
|
||||
author = 'imacat'
|
||||
release = '0.8.0'
|
||||
release = '0.9.1'
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||
|
@ -17,7 +17,7 @@
|
||||
|
||||
[metadata]
|
||||
name = mia-accounting-flask
|
||||
version = 0.8.0
|
||||
version = 0.9.1
|
||||
author = imacat
|
||||
author_email = imacat@mail.imacat.idv.tw
|
||||
description = The Mia! Accounting Flask project.
|
||||
|
@ -86,4 +86,7 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
|
||||
from . import report
|
||||
report.init_app(app, bp)
|
||||
|
||||
from . import option
|
||||
option.init_app(bp)
|
||||
|
||||
app.register_blueprint(bp)
|
||||
|
96
src/accounting/forms.py
Normal file
96
src/accounting/forms.py
Normal file
@ -0,0 +1,96 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The forms.
|
||||
|
||||
"""
|
||||
import re
|
||||
|
||||
from flask_babel import LazyString
|
||||
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
|
||||
|
||||
|
||||
ACCOUNT_REQUIRED: DataRequired = DataRequired(
|
||||
lazy_gettext("Please select the account."))
|
||||
"""The validator to check if the account code is empty."""
|
||||
|
||||
|
||||
class CurrencyExists:
|
||||
"""The validator to check if the account exists."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
if db.session.get(Currency, field.data) is None:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The currency does not exist."))
|
||||
|
||||
|
||||
class AccountExists:
|
||||
"""The validator to check if the account exists."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
if Account.find_by_code(field.data) is None:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The account does not exist."))
|
||||
|
||||
|
||||
class IsDebitAccount:
|
||||
"""The validator to check if the account is for debit line items."""
|
||||
|
||||
def __init__(self, message: str | LazyString):
|
||||
"""Constructs the validator.
|
||||
|
||||
:param message: The error message.
|
||||
"""
|
||||
self.__message: str | LazyString = message
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
if re.match(r"^(?:[1235689]|7[5678])", field.data) \
|
||||
and not field.data.startswith("3351-") \
|
||||
and not field.data.startswith("3353-"):
|
||||
return
|
||||
raise ValidationError(self.__message)
|
||||
|
||||
|
||||
class IsCreditAccount:
|
||||
"""The validator to check if the account is for credit line items."""
|
||||
|
||||
def __init__(self, message: str | LazyString):
|
||||
"""Constructs the validator.
|
||||
|
||||
:param message: The error message.
|
||||
"""
|
||||
self.__message: str | LazyString = message
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
if re.match(r"^(?:[123489]|7[1234])", field.data) \
|
||||
and not field.data.startswith("3351-") \
|
||||
and not field.data.startswith("3353-"):
|
||||
return
|
||||
raise ValidationError(self.__message)
|
@ -27,29 +27,20 @@ from wtforms import StringField, ValidationError, FieldList, IntegerField, \
|
||||
from wtforms.validators import DataRequired
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Currency, JournalEntryLineItem
|
||||
from accounting.forms import CurrencyExists
|
||||
from accounting.journal_entry.utils.offset_alias import offset_alias
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import JournalEntryLineItem
|
||||
from accounting.utils.cast import be
|
||||
from accounting.utils.strip_text import strip_text
|
||||
from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
|
||||
|
||||
|
||||
CURRENCY_REQUIRED: DataRequired = DataRequired(
|
||||
lazy_gettext("Please select the currency."))
|
||||
"""The validator to check if the currency code is empty."""
|
||||
|
||||
|
||||
class CurrencyExists:
|
||||
"""The validator to check if the account exists."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
if db.session.get(Currency, field.data) is None:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The currency does not exist."))
|
||||
|
||||
|
||||
class SameCurrencyAsOriginalLineItems:
|
||||
"""The validator to check if the currency is the same as the
|
||||
original line items."""
|
||||
|
@ -220,7 +220,7 @@ class JournalEntryForm(FlaskForm):
|
||||
:return: The selectable debit accounts.
|
||||
"""
|
||||
accounts: list[AccountOption] \
|
||||
= [AccountOption(x) for x in Account.debit()
|
||||
= [AccountOption(x) for x in Account.selectable_debit()
|
||||
if not (x.code[0] == "2" and x.is_need_offset)]
|
||||
in_use: set[int] = set(db.session.scalars(
|
||||
sa.select(JournalEntryLineItem.account_id)
|
||||
@ -237,7 +237,7 @@ class JournalEntryForm(FlaskForm):
|
||||
:return: The selectable credit accounts.
|
||||
"""
|
||||
accounts: list[AccountOption] \
|
||||
= [AccountOption(x) for x in Account.credit()
|
||||
= [AccountOption(x) for x in Account.selectable_credit()
|
||||
if not (x.code[0] == "1" and x.is_need_offset)]
|
||||
in_use: set[int] = set(db.session.scalars(
|
||||
sa.select(JournalEntryLineItem.account_id)
|
||||
|
@ -17,7 +17,6 @@
|
||||
"""The line item sub-forms for the journal entry management.
|
||||
|
||||
"""
|
||||
import re
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
@ -26,9 +25,11 @@ from flask_babel import LazyString
|
||||
from flask_wtf import FlaskForm
|
||||
from sqlalchemy.orm import selectinload
|
||||
from wtforms import StringField, ValidationError, DecimalField, IntegerField
|
||||
from wtforms.validators import DataRequired, Optional
|
||||
from wtforms.validators import Optional
|
||||
|
||||
from accounting import db
|
||||
from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
|
||||
IsCreditAccount
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Account, JournalEntryLineItem
|
||||
from accounting.template_filters import format_amount
|
||||
@ -37,10 +38,6 @@ 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
|
||||
|
||||
ACCOUNT_REQUIRED: DataRequired = DataRequired(
|
||||
lazy_gettext("Please select the account."))
|
||||
"""The validator to check if the account code is empty."""
|
||||
|
||||
|
||||
class OriginalLineItemExists:
|
||||
"""The validator to check if the original line item exists."""
|
||||
@ -105,45 +102,6 @@ class OriginalLineItemNotOffset:
|
||||
"The original line item cannot be an offset item."))
|
||||
|
||||
|
||||
class AccountExists:
|
||||
"""The validator to check if the account exists."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
if Account.find_by_code(field.data) is None:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The account does not exist."))
|
||||
|
||||
|
||||
class IsDebitAccount:
|
||||
"""The validator to check if the account is for debit line items."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
if re.match(r"^(?:[1235689]|7[5678])", field.data) \
|
||||
and not field.data.startswith("3351-") \
|
||||
and not field.data.startswith("3353-"):
|
||||
return
|
||||
raise ValidationError(lazy_gettext(
|
||||
"This account is not for debit line items."))
|
||||
|
||||
|
||||
class IsCreditAccount:
|
||||
"""The validator to check if the account is for credit line items."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
if re.match(r"^(?:[123489]|7[1234])", field.data) \
|
||||
and not field.data.startswith("3351-") \
|
||||
and not field.data.startswith("3353-"):
|
||||
return
|
||||
raise ValidationError(lazy_gettext(
|
||||
"This account is not for credit line items."))
|
||||
|
||||
|
||||
class SameAccountAsOriginalLineItem:
|
||||
"""The validator to check if the account is the same as the
|
||||
original line item."""
|
||||
@ -452,12 +410,14 @@ class DebitLineItemForm(LineItemForm):
|
||||
"""The ID of the original line item."""
|
||||
account_code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[ACCOUNT_REQUIRED,
|
||||
AccountExists(),
|
||||
IsDebitAccount(),
|
||||
SameAccountAsOriginalLineItem(),
|
||||
KeepAccountWhenHavingOffset(),
|
||||
NotStartPayableFromDebit()])
|
||||
validators=[
|
||||
ACCOUNT_REQUIRED,
|
||||
AccountExists(),
|
||||
IsDebitAccount(lazy_gettext(
|
||||
"This account is not for debit line items.")),
|
||||
SameAccountAsOriginalLineItem(),
|
||||
KeepAccountWhenHavingOffset(),
|
||||
NotStartPayableFromDebit()])
|
||||
"""The account code."""
|
||||
description = StringField(filters=[strip_text])
|
||||
"""The description."""
|
||||
@ -502,12 +462,14 @@ class CreditLineItemForm(LineItemForm):
|
||||
"""The ID of the original line item."""
|
||||
account_code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[ACCOUNT_REQUIRED,
|
||||
AccountExists(),
|
||||
IsCreditAccount(),
|
||||
SameAccountAsOriginalLineItem(),
|
||||
KeepAccountWhenHavingOffset(),
|
||||
NotStartReceivableFromCredit()])
|
||||
validators=[
|
||||
ACCOUNT_REQUIRED,
|
||||
AccountExists(),
|
||||
IsCreditAccount(lazy_gettext(
|
||||
"This account is not for credit line items.")),
|
||||
SameAccountAsOriginalLineItem(),
|
||||
KeepAccountWhenHavingOffset(),
|
||||
NotStartReceivableFromCredit()])
|
||||
"""The account code."""
|
||||
description = StringField(filters=[strip_text])
|
||||
"""The description."""
|
||||
|
@ -21,10 +21,10 @@ import re
|
||||
import typing as t
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import current_app
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import Account, JournalEntryLineItem
|
||||
from accounting.utils.options import options, Recurring
|
||||
|
||||
|
||||
class DescriptionAccount:
|
||||
@ -148,16 +148,16 @@ class DescriptionType:
|
||||
class DescriptionRecurring:
|
||||
"""A recurring transaction."""
|
||||
|
||||
def __init__(self, name: str, template: str, account: Account):
|
||||
def __init__(self, name: str, account: Account, description_template: str):
|
||||
"""Constructs a recurring transaction.
|
||||
|
||||
:param name: The name.
|
||||
:param template: The template.
|
||||
:param description_template: The description template.
|
||||
:param account: The account.
|
||||
"""
|
||||
self.name: str = name
|
||||
self.template: str = template
|
||||
self.account: DescriptionAccount = DescriptionAccount(account, 0)
|
||||
self.description_template: str = description_template
|
||||
|
||||
@property
|
||||
def account_codes(self) -> list[str]:
|
||||
@ -280,19 +280,17 @@ class DescriptionEditor:
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
if "ACCOUNTING_RECURRING" not in current_app.config:
|
||||
return
|
||||
data: list[tuple[t.Literal["debit", "credit"], str, str, str]] \
|
||||
= [x.split("|")
|
||||
for x in current_app.config["ACCOUNTING_RECURRING"].split(",")]
|
||||
debit_credit_dict: dict[t.Literal["debit", "credit"],
|
||||
DescriptionDebitCredit] \
|
||||
= {x.debit_credit: x for x in {self.debit, self.credit}}
|
||||
recurring: Recurring = options.recurring
|
||||
accounts: dict[str, Account] \
|
||||
= self.__get_accounts({x[1] for x in data})
|
||||
for row in data:
|
||||
debit_credit_dict[row[0]].recurring.append(
|
||||
DescriptionRecurring(row[2], row[3], accounts[row[1]]))
|
||||
= self.__get_accounts(recurring.codes)
|
||||
self.debit.recurring \
|
||||
= [DescriptionRecurring(x.name, accounts[x.account_code],
|
||||
x.description_template)
|
||||
for x in recurring.expenses]
|
||||
self.credit.recurring \
|
||||
= [DescriptionRecurring(x.name, accounts[x.account_code],
|
||||
x.description_template)
|
||||
for x in recurring.incomes]
|
||||
|
||||
@staticmethod
|
||||
def __get_accounts(codes: set[str]) -> dict[str, Account]:
|
||||
@ -301,6 +299,9 @@ class DescriptionEditor:
|
||||
:param codes: The account codes.
|
||||
:return: The account.
|
||||
"""
|
||||
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,\
|
||||
|
@ -269,13 +269,15 @@ class Account(db.Model):
|
||||
cls.no == int(m.group(2))).first()
|
||||
|
||||
@classmethod
|
||||
def debit(cls) -> list[t.Self]:
|
||||
"""Returns the debit accounts.
|
||||
def selectable_debit(cls) -> list[t.Self]:
|
||||
"""Returns the selectable debit accounts.
|
||||
Payable line items can not start from debit.
|
||||
|
||||
:return: The debit accounts.
|
||||
:return: The selectable debit accounts.
|
||||
"""
|
||||
return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
|
||||
cls.base_code.startswith("2"),
|
||||
sa.and_(cls.base_code.startswith("2"),
|
||||
sa.not_(cls.is_need_offset)),
|
||||
cls.base_code.startswith("3"),
|
||||
cls.base_code.startswith("5"),
|
||||
cls.base_code.startswith("6"),
|
||||
@ -290,12 +292,14 @@ class Account(db.Model):
|
||||
.order_by(cls.base_code, cls.no).all()
|
||||
|
||||
@classmethod
|
||||
def credit(cls) -> list[t.Self]:
|
||||
"""Returns the debit accounts.
|
||||
def selectable_credit(cls) -> list[t.Self]:
|
||||
"""Returns the selectable debit accounts.
|
||||
Receivable line items can not start from credit.
|
||||
|
||||
:return: The debit accounts.
|
||||
:return: The selectable debit accounts.
|
||||
"""
|
||||
return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
|
||||
return cls.query.filter(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"),
|
||||
cls.base_code.startswith("4"),
|
||||
@ -756,7 +760,7 @@ class JournalEntryLineItem(db.Model):
|
||||
setattr(self, "__net_balance", net_balance)
|
||||
|
||||
@property
|
||||
def query_values(self) -> tuple[list[str], list[str]]:
|
||||
def query_values(self) -> list[str]:
|
||||
"""Returns the values to be queried.
|
||||
|
||||
:return: The values to be queried.
|
||||
@ -766,16 +770,38 @@ class JournalEntryLineItem(db.Model):
|
||||
frac: Decimal = (value - whole).normalize()
|
||||
return str(whole) + str(abs(frac))[1:]
|
||||
|
||||
journal_entry_day: date = self.journal_entry.date
|
||||
description: str = "" if self.description is None else self.description
|
||||
return ([description],
|
||||
[str(journal_entry_day.year),
|
||||
"{}/{}".format(journal_entry_day.year,
|
||||
journal_entry_day.month),
|
||||
"{}/{}".format(journal_entry_day.month,
|
||||
journal_entry_day.day),
|
||||
"{}/{}/{}".format(journal_entry_day.year,
|
||||
journal_entry_day.month,
|
||||
journal_entry_day.day),
|
||||
format_amount(self.amount),
|
||||
format_amount(self.net_balance)])
|
||||
return ["{}/{}/{}".format(self.journal_entry.date.year,
|
||||
self.journal_entry.date.month,
|
||||
self.journal_entry.date.day),
|
||||
"" if self.description is None else self.description,
|
||||
format_amount(self.amount)]
|
||||
|
||||
|
||||
class Option(db.Model):
|
||||
"""An option."""
|
||||
__tablename__ = "accounting_options"
|
||||
"""The table name."""
|
||||
name = db.Column(db.String, nullable=False, primary_key=True)
|
||||
"""The name."""
|
||||
value = db.Column(db.Text, nullable=False)
|
||||
"""The option value."""
|
||||
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||
server_default=db.func.now())
|
||||
"""The time of creation."""
|
||||
created_by_id = db.Column(db.Integer,
|
||||
db.ForeignKey(user_pk_column,
|
||||
onupdate="CASCADE"),
|
||||
nullable=False)
|
||||
"""The ID of the creator."""
|
||||
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
|
||||
"""The creator."""
|
||||
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||
server_default=db.func.now())
|
||||
"""The time of last update."""
|
||||
updated_by_id = db.Column(db.Integer,
|
||||
db.ForeignKey(user_pk_column,
|
||||
onupdate="CASCADE"),
|
||||
nullable=False)
|
||||
"""The ID of the updator."""
|
||||
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
|
||||
"""The updator."""
|
||||
|
30
src/accounting/option/__init__.py
Normal file
30
src/accounting/option/__init__.py
Normal file
@ -0,0 +1,30 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The option management.
|
||||
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
|
||||
def init_app(bp: Blueprint) -> None:
|
||||
"""Initialize the application.
|
||||
|
||||
:param bp: The blueprint of the accounting application.
|
||||
:return: None.
|
||||
"""
|
||||
from .views import bp as option_bp
|
||||
bp.register_blueprint(option_bp, url_prefix="/options")
|
269
src/accounting/option/forms.py
Normal file
269
src/accounting/option/forms.py
Normal file
@ -0,0 +1,269 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The forms for the option management.
|
||||
|
||||
"""
|
||||
from flask import render_template
|
||||
from flask_babel import LazyString
|
||||
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, \
|
||||
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
|
||||
|
||||
|
||||
class CurrentAccountExists:
|
||||
"""The validator to check that the current account exists."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None or field.data == CurrentAccount.CURRENT_AL_CODE:
|
||||
return
|
||||
if Account.find_by_code(field.data) is None:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The account does not exist."))
|
||||
|
||||
|
||||
class AccountNotCurrent:
|
||||
"""The validator to check that the account is a current account."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None or field.data == CurrentAccount.CURRENT_AL_CODE:
|
||||
return
|
||||
if field.data[:2] not in {"11", "12", "21", "22"}:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"This is not a current account."))
|
||||
|
||||
|
||||
class NotStartPayableFromExpense:
|
||||
"""The validator to check that a payable line item does not start from
|
||||
expense."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None or field.data[0] != "2":
|
||||
return
|
||||
account: Account | None = Account.find_by_code(field.data)
|
||||
if account is not None and account.is_need_offset:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"You cannot select a payable account as expense."))
|
||||
|
||||
|
||||
class NotStartReceivableFromIncome:
|
||||
"""The validator to check that a receivable line item does not start
|
||||
from income."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None or field.data[0] != "1":
|
||||
return
|
||||
account: Account | None = Account.find_by_code(field.data)
|
||||
if account is not None and account.is_need_offset:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"You cannot select a receivable account as income."))
|
||||
|
||||
|
||||
class RecurringItemForm(FlaskForm):
|
||||
"""The base sub-form to add or update the recurring item."""
|
||||
no = IntegerField()
|
||||
"""The order number of this recurring item."""
|
||||
name = StringField()
|
||||
"""The name of the recurring item."""
|
||||
account_code = StringField()
|
||||
"""The account code."""
|
||||
description_template = StringField()
|
||||
"""The description template."""
|
||||
|
||||
@property
|
||||
def account_text(self) -> str | None:
|
||||
"""Returns the account text.
|
||||
|
||||
:return: The account text.
|
||||
"""
|
||||
if self.account_code.data is None:
|
||||
return None
|
||||
account: Account | None = Account.find_by_code(self.account_code.data)
|
||||
return None if account is None else str(account)
|
||||
|
||||
@property
|
||||
def all_errors(self) -> list[str | LazyString]:
|
||||
"""Returns all the errors of the form.
|
||||
|
||||
:return: All the errors of the form.
|
||||
"""
|
||||
all_errors: list[str | LazyString] = []
|
||||
for key in self.errors:
|
||||
if key != "csrf_token":
|
||||
all_errors.extend(self.errors[key])
|
||||
return all_errors
|
||||
|
||||
|
||||
class RecurringExpenseForm(RecurringItemForm):
|
||||
"""The sub-form to add or update the recurring expenses."""
|
||||
no = IntegerField()
|
||||
"""The order number of this recurring item."""
|
||||
name = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[DataRequired(lazy_gettext("Please fill in the name."))])
|
||||
"""The name of the recurring item."""
|
||||
account_code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[
|
||||
ACCOUNT_REQUIRED,
|
||||
AccountExists(),
|
||||
IsDebitAccount(lazy_gettext("This account is not for expense.")),
|
||||
NotStartPayableFromExpense()])
|
||||
"""The account code."""
|
||||
description_template = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[
|
||||
DataRequired(lazy_gettext(
|
||||
"Please fill in the description template."))])
|
||||
"""The template for the line item description."""
|
||||
|
||||
|
||||
class RecurringIncomeForm(RecurringItemForm):
|
||||
"""The sub-form to add or update the recurring incomes."""
|
||||
no = IntegerField()
|
||||
"""The order number of this recurring item."""
|
||||
name = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[DataRequired(lazy_gettext("Please fill in the name."))])
|
||||
"""The name of the recurring item."""
|
||||
account_code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[
|
||||
ACCOUNT_REQUIRED,
|
||||
AccountExists(),
|
||||
IsCreditAccount(lazy_gettext("This account is not for income.")),
|
||||
NotStartReceivableFromIncome()])
|
||||
"""The account code."""
|
||||
description_template = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[
|
||||
DataRequired(lazy_gettext(
|
||||
"Please fill in the description template."))])
|
||||
"""The description template."""
|
||||
|
||||
|
||||
class RecurringForm(RecurringItemForm):
|
||||
"""The sub-form for the recurring expenses and incomes."""
|
||||
expenses = FieldList(FormField(RecurringExpenseForm), name="expense")
|
||||
"""The recurring expenses."""
|
||||
incomes = FieldList(FormField(RecurringIncomeForm), name="income")
|
||||
"""The recurring incomes."""
|
||||
|
||||
@property
|
||||
def item_template(self) -> str:
|
||||
"""Returns the template of a recurring item.
|
||||
|
||||
:return: The template of a recurring item.
|
||||
"""
|
||||
return render_template(
|
||||
"accounting/option/include/form-recurring-item.html",
|
||||
expense_income="EXPENSE_INCOME",
|
||||
item_index="ITEM_INDEX",
|
||||
form=RecurringItemForm())
|
||||
|
||||
@property
|
||||
def expense_accounts(self) -> list[Account]:
|
||||
"""The expense accounts.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
return Account.selectable_debit()
|
||||
|
||||
@property
|
||||
def income_accounts(self) -> list[Account]:
|
||||
"""The income accounts.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
return Account.selectable_credit()
|
||||
|
||||
@property
|
||||
def as_data(self) -> dict[str, list[tuple[str, str, str]]]:
|
||||
"""Returns the form data.
|
||||
|
||||
:return: The form data.
|
||||
"""
|
||||
def as_tuple(item: RecurringItemForm) -> tuple[str, str, str]:
|
||||
return (item.name.data, item.account_code.data,
|
||||
item.description_template.data)
|
||||
|
||||
expenses: list[RecurringItemForm] = [x.form for x in self.expenses]
|
||||
self.__sort_item_forms(expenses)
|
||||
incomes: list[RecurringItemForm] = [x.form for x in self.incomes]
|
||||
self.__sort_item_forms(incomes)
|
||||
return {"expense": [as_tuple(x) for x in expenses],
|
||||
"income": [as_tuple(x) for x in incomes]}
|
||||
|
||||
@staticmethod
|
||||
def __sort_item_forms(forms: list[RecurringItemForm]) -> None:
|
||||
"""Sorts the recurring item sub-forms.
|
||||
|
||||
:param forms: The recurring item sub-forms.
|
||||
:return: None.
|
||||
"""
|
||||
ord_by_form: dict[RecurringItemForm, int] \
|
||||
= {forms[i]: i for i in range(len(forms))}
|
||||
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
|
||||
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
|
||||
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
|
||||
ord_by_form.get(x)))
|
||||
|
||||
|
||||
class OptionForm(FlaskForm):
|
||||
"""The form to update the options."""
|
||||
default_currency_code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[
|
||||
DataRequired(lazy_gettext("Please select the default currency.")),
|
||||
CurrencyExists()])
|
||||
"""The default currency code."""
|
||||
default_ie_account_code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[
|
||||
DataRequired(lazy_gettext(
|
||||
"Please select the default account"
|
||||
" for the income and expenses log.")),
|
||||
CurrentAccountExists(),
|
||||
AccountNotCurrent()])
|
||||
"""The default account code for the income and expenses log."""
|
||||
recurring = FormField(RecurringForm)
|
||||
"""The recurring expenses and incomes."""
|
||||
|
||||
def populate_obj(self, obj: Options) -> None:
|
||||
"""Populates the form data into a currency object.
|
||||
|
||||
:param obj: The currency object.
|
||||
:return: None.
|
||||
"""
|
||||
obj.default_currency_code = self.default_currency_code.data
|
||||
obj.default_ie_account_code = self.default_ie_account_code.data
|
||||
obj.recurring_data = self.recurring.form.as_data
|
||||
|
||||
@property
|
||||
def current_accounts(self) -> list[CurrentAccount]:
|
||||
"""Returns the current accounts.
|
||||
|
||||
:return: The current accounts.
|
||||
"""
|
||||
return CurrentAccount.accounts()
|
83
src/accounting/option/views.py
Normal file
83
src/accounting/option/views.py
Normal file
@ -0,0 +1,83 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The views for the option management.
|
||||
|
||||
"""
|
||||
from urllib.parse import parse_qsl, urlencode
|
||||
|
||||
from flask import Blueprint, render_template, redirect, session, request, \
|
||||
flash, url_for
|
||||
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
|
||||
|
||||
bp: Blueprint = Blueprint("option", __name__)
|
||||
"""The view blueprint for the currency management."""
|
||||
|
||||
|
||||
@bp.get("", endpoint="detail")
|
||||
@has_permission(can_admin)
|
||||
def show_options() -> str:
|
||||
"""Shows the options.
|
||||
|
||||
:return: The options.
|
||||
"""
|
||||
return render_template("accounting/option/detail.html", obj=options)
|
||||
|
||||
|
||||
@bp.get("edit", endpoint="edit")
|
||||
@has_permission(can_admin)
|
||||
def show_option_form() -> str:
|
||||
"""Shows the option form.
|
||||
|
||||
:return: The option form.
|
||||
"""
|
||||
form: OptionForm
|
||||
if "form" in session:
|
||||
form = OptionForm(ImmutableMultiDict(parse_qsl(session["form"])))
|
||||
del session["form"]
|
||||
form.validate()
|
||||
else:
|
||||
form = OptionForm(obj=options)
|
||||
return render_template("accounting/option/form.html", form=form)
|
||||
|
||||
|
||||
@bp.post("update", endpoint="update")
|
||||
@has_permission(can_admin)
|
||||
def update_options() -> redirect:
|
||||
"""Updates the options.
|
||||
|
||||
:return: The redirection to the option form.
|
||||
"""
|
||||
form = OptionForm(request.form)
|
||||
if not form.validate():
|
||||
flash_form_errors(form)
|
||||
session["form"] = urlencode(list(request.form.items()))
|
||||
return redirect(inherit_next(url_for("accounting.option.edit")))
|
||||
form.populate_obj(options)
|
||||
if not options.is_modified:
|
||||
flash(s(lazy_gettext("The settings were not modified.")), "success")
|
||||
return redirect(inherit_next(url_for("accounting.option.detail")))
|
||||
options.commit()
|
||||
flash(s(lazy_gettext("The settings are saved successfully.")), "success")
|
||||
return redirect(inherit_next(url_for("accounting.option.detail")))
|
@ -23,8 +23,8 @@ 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 .utils.ie_account import IncomeExpensesAccount
|
||||
|
||||
|
||||
class PeriodConverter(BaseConverter):
|
||||
@ -55,22 +55,22 @@ class IncomeExpensesAccountConverter(BaseConverter):
|
||||
"""The supplier converter to convert the income and expenses log pseudo
|
||||
account code from and to the corresponding pseudo account in the routes."""
|
||||
|
||||
def to_python(self, value: str) -> IncomeExpensesAccount:
|
||||
def to_python(self, value: str) -> CurrentAccount:
|
||||
"""Converts an account code to an account.
|
||||
|
||||
:param value: The account code.
|
||||
:return: The corresponding account.
|
||||
"""
|
||||
if value == IncomeExpensesAccount.CURRENT_AL_CODE:
|
||||
return IncomeExpensesAccount.current_assets_and_liabilities()
|
||||
if value == CurrentAccount.CURRENT_AL_CODE:
|
||||
return CurrentAccount.current_assets_and_liabilities()
|
||||
if not re.match("^[12][12]", value):
|
||||
abort(404)
|
||||
account: Account | None = Account.find_by_code(value)
|
||||
if account is None:
|
||||
abort(404)
|
||||
return IncomeExpensesAccount(account)
|
||||
return CurrentAccount(account)
|
||||
|
||||
def to_url(self, value: IncomeExpensesAccount) -> str:
|
||||
def to_url(self, value: CurrentAccount) -> str:
|
||||
"""Converts an account to account code.
|
||||
|
||||
:param value: The account.
|
||||
|
@ -33,12 +33,12 @@ from accounting.report.utils.base_page_params import BasePageParams
|
||||
from accounting.report.utils.base_report import BaseReport
|
||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
|
||||
period_spec
|
||||
from accounting.report.utils.ie_account import IncomeExpensesAccount
|
||||
from accounting.report.utils.option_link import OptionLink
|
||||
from accounting.report.utils.report_chooser import ReportChooser
|
||||
from accounting.report.utils.report_type import ReportType
|
||||
from accounting.report.utils.urls import income_expenses_url
|
||||
from accounting.utils.cast import be
|
||||
from accounting.utils.current_account import CurrentAccount
|
||||
from accounting.utils.pagination import Pagination
|
||||
|
||||
|
||||
@ -84,7 +84,7 @@ class ReportLineItem:
|
||||
class LineItemCollector:
|
||||
"""The line item collector."""
|
||||
|
||||
def __init__(self, currency: Currency, account: IncomeExpensesAccount,
|
||||
def __init__(self, currency: Currency, account: CurrentAccount,
|
||||
period: Period):
|
||||
"""Constructs the line item collector.
|
||||
|
||||
@ -94,7 +94,7 @@ class LineItemCollector:
|
||||
"""
|
||||
self.__currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.__account: IncomeExpensesAccount = account
|
||||
self.__account: CurrentAccount = account
|
||||
"""The account."""
|
||||
self.__period: Period = period
|
||||
"""The period"""
|
||||
@ -173,11 +173,8 @@ class LineItemCollector:
|
||||
|
||||
@property
|
||||
def __account_condition(self) -> sa.BinaryExpression:
|
||||
if self.__account.code == IncomeExpensesAccount.CURRENT_AL_CODE:
|
||||
return sa.or_(Account.base_code.startswith("11"),
|
||||
Account.base_code.startswith("12"),
|
||||
Account.base_code.startswith("21"),
|
||||
Account.base_code.startswith("22"))
|
||||
if self.__account.code == CurrentAccount.CURRENT_AL_CODE:
|
||||
return CurrentAccount.sql_condition()
|
||||
return Account.id == self.__account.id
|
||||
|
||||
def __get_total(self) -> ReportLineItem | None:
|
||||
@ -264,7 +261,7 @@ class PageParams(BasePageParams):
|
||||
"""The HTML page parameters."""
|
||||
|
||||
def __init__(self, currency: Currency,
|
||||
account: IncomeExpensesAccount,
|
||||
account: CurrentAccount,
|
||||
period: Period,
|
||||
has_data: bool,
|
||||
pagination: Pagination[ReportLineItem],
|
||||
@ -283,7 +280,7 @@ class PageParams(BasePageParams):
|
||||
"""
|
||||
self.currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.account: IncomeExpensesAccount = account
|
||||
self.account: CurrentAccount = account
|
||||
"""The account."""
|
||||
self.period: Period = period
|
||||
"""The period."""
|
||||
@ -341,8 +338,8 @@ class PageParams(BasePageParams):
|
||||
|
||||
:return: The account options.
|
||||
"""
|
||||
current_al: IncomeExpensesAccount \
|
||||
= IncomeExpensesAccount.current_assets_and_liabilities()
|
||||
current_al: CurrentAccount \
|
||||
= CurrentAccount.current_assets_and_liabilities()
|
||||
options: list[OptionLink] \
|
||||
= [OptionLink(str(current_al),
|
||||
income_expenses_url(self.currency, current_al,
|
||||
@ -352,15 +349,12 @@ class PageParams(BasePageParams):
|
||||
.join(Account)\
|
||||
.filter(be(JournalEntryLineItem.currency_code
|
||||
== self.currency.code),
|
||||
sa.or_(Account.base_code.startswith("11"),
|
||||
Account.base_code.startswith("12"),
|
||||
Account.base_code.startswith("21"),
|
||||
Account.base_code.startswith("22")))\
|
||||
CurrentAccount.sql_condition())\
|
||||
.group_by(JournalEntryLineItem.account_id)
|
||||
options.extend([OptionLink(str(x),
|
||||
income_expenses_url(
|
||||
self.currency,
|
||||
IncomeExpensesAccount(x),
|
||||
CurrentAccount(x),
|
||||
self.period),
|
||||
x.id == self.account.id)
|
||||
for x in Account.query.filter(Account.id.in_(in_use))
|
||||
@ -371,7 +365,7 @@ class PageParams(BasePageParams):
|
||||
class IncomeExpenses(BaseReport):
|
||||
"""The income and expenses log."""
|
||||
|
||||
def __init__(self, currency: Currency, account: IncomeExpensesAccount,
|
||||
def __init__(self, currency: Currency, account: CurrentAccount,
|
||||
period: Period):
|
||||
"""Constructs an income and expenses log.
|
||||
|
||||
@ -381,7 +375,7 @@ class IncomeExpenses(BaseReport):
|
||||
"""
|
||||
self.__currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.__account: IncomeExpensesAccount = account
|
||||
self.__account: CurrentAccount = account
|
||||
"""The account."""
|
||||
self.__period: Period = period
|
||||
"""The period."""
|
||||
|
@ -30,7 +30,7 @@ from accounting.locale import gettext
|
||||
from accounting.models import Currency, Account
|
||||
from accounting.report.period import Period, get_period
|
||||
from accounting.template_globals import default_currency_code
|
||||
from .ie_account import IncomeExpensesAccount
|
||||
from accounting.utils.current_account import CurrentAccount
|
||||
from .option_link import OptionLink
|
||||
from .report_type import ReportType
|
||||
from .urls import journal_url, ledger_url, income_expenses_url, \
|
||||
@ -113,7 +113,7 @@ class ReportChooser:
|
||||
account: Account = Account.cash()
|
||||
return OptionLink(gettext("Income and Expenses Log"),
|
||||
income_expenses_url(self.__currency,
|
||||
IncomeExpensesAccount(account),
|
||||
CurrentAccount(account),
|
||||
self.__period),
|
||||
self.__active_report == ReportType.INCOME_EXPENSES,
|
||||
fa_icon="fa-solid fa-money-bill-wave")
|
||||
|
@ -22,7 +22,8 @@ from flask import url_for
|
||||
from accounting.models import Currency, Account
|
||||
from accounting.report.period import Period
|
||||
from accounting.template_globals import default_currency_code
|
||||
from .ie_account import IncomeExpensesAccount, default_ie_account_code
|
||||
from accounting.utils.current_account import CurrentAccount
|
||||
from accounting.utils.options import options
|
||||
|
||||
|
||||
def journal_url(period: Period) \
|
||||
@ -54,7 +55,7 @@ def ledger_url(currency: Currency, account: Account, period: Period) \
|
||||
period=period)
|
||||
|
||||
|
||||
def income_expenses_url(currency: Currency, account: IncomeExpensesAccount,
|
||||
def income_expenses_url(currency: Currency, account: CurrentAccount,
|
||||
period: Period) -> str:
|
||||
"""Returns the URL of an income and expenses log.
|
||||
|
||||
@ -64,7 +65,7 @@ def income_expenses_url(currency: Currency, account: IncomeExpensesAccount,
|
||||
:return: The URL of the income and expenses log.
|
||||
"""
|
||||
if currency.code == default_currency_code() \
|
||||
and account.code == default_ie_account_code() \
|
||||
and account.code == options.default_ie_account_code \
|
||||
and period.is_default:
|
||||
return url_for("accounting.report.default")
|
||||
if period.is_default:
|
||||
|
@ -23,11 +23,12 @@ from accounting import db
|
||||
from accounting.models import Currency, Account
|
||||
from accounting.report.period import Period, get_period
|
||||
from accounting.template_globals import default_currency_code
|
||||
from accounting.utils.current_account import CurrentAccount
|
||||
from accounting.utils.options import options
|
||||
from accounting.utils.permission import has_permission, can_view
|
||||
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
|
||||
IncomeStatement, BalanceSheet, Search
|
||||
from .template_filters import format_amount
|
||||
from .utils.ie_account import IncomeExpensesAccount, default_ie_account
|
||||
|
||||
bp: Blueprint = Blueprint("report", __name__)
|
||||
"""The view blueprint for the reports."""
|
||||
@ -43,7 +44,7 @@ def get_default_report() -> str | Response:
|
||||
"""
|
||||
return __get_income_expenses(
|
||||
db.session.get(Currency, default_currency_code()),
|
||||
default_ie_account(),
|
||||
options.default_ie_account,
|
||||
get_period())
|
||||
|
||||
|
||||
@ -126,8 +127,7 @@ def __get_ledger(currency: Currency, account: Account, period: Period) \
|
||||
@bp.get("income-expenses/<currency:currency>/<ieAccount:account>",
|
||||
endpoint="income-expenses-default")
|
||||
@has_permission(can_view)
|
||||
def get_default_income_expenses(currency: Currency,
|
||||
account: IncomeExpensesAccount) \
|
||||
def get_default_income_expenses(currency: Currency, account: CurrentAccount) \
|
||||
-> str | Response:
|
||||
"""Returns the income and expenses log in the default period.
|
||||
|
||||
@ -142,7 +142,7 @@ def get_default_income_expenses(currency: Currency,
|
||||
"income-expenses/<currency:currency>/<ieAccount:account>/<period:period>",
|
||||
endpoint="income-expenses")
|
||||
@has_permission(can_view)
|
||||
def get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
|
||||
def get_income_expenses(currency: Currency, account: CurrentAccount,
|
||||
period: Period) -> str | Response:
|
||||
"""Returns the income and expenses log.
|
||||
|
||||
@ -154,7 +154,7 @@ def get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
|
||||
return __get_income_expenses(currency, account, period)
|
||||
|
||||
|
||||
def __get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
|
||||
def __get_income_expenses(currency: Currency, account: CurrentAccount,
|
||||
period: Period) -> str | Response:
|
||||
"""Returns the income and expenses log.
|
||||
|
||||
|
@ -72,14 +72,11 @@
|
||||
}
|
||||
}
|
||||
@media(max-width:767px) {
|
||||
.accounting-toolbar {
|
||||
width: 100%;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
.accounting-toolbar > .btn:not(form), .accounting-toolbar > .btn-group > .btn {
|
||||
height: 3.2rem;
|
||||
width: 3.2rem;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
.accounting-toolbar > a.btn, .accounting-toolbar > .btn-group > a.btn {
|
||||
padding-top: 0.7rem;
|
||||
@ -334,6 +331,14 @@ a.accounting-report-table-row {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
/* The illustration of the description template for the recurring transactions */
|
||||
.accounting-recurring-description-template-illustration p {
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
.accounting-recurring-description-template-illustration ul {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* The Material Design text field (floating form control in Bootstrap) */
|
||||
.accounting-material-text-field {
|
||||
position: relative;
|
||||
|
@ -110,14 +110,23 @@ class AccountForm {
|
||||
this.#isNeedOffsetControl = document.getElementById("accounting-is-need-offset-control");
|
||||
this.#isNeedOffset = document.getElementById("accounting-is-need-offset");
|
||||
this.#formElement.onsubmit = () => {
|
||||
return this.#validateForm();
|
||||
return this.#validate();
|
||||
};
|
||||
this.#baseControl.onclick = () => {
|
||||
this.#baseControl.classList.add("accounting-not-empty");
|
||||
this.#baseAccountSelector.onOpen(this.#baseCode.value);
|
||||
this.#baseAccountSelector.onOpen();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base code.
|
||||
*
|
||||
* @return {string|null}
|
||||
*/
|
||||
get baseCode() {
|
||||
return this.#baseCode.value === ""? null: this.#baseCode.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback when the base account selector is closed.
|
||||
*
|
||||
@ -129,15 +138,14 @@ class AccountForm {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the base account.
|
||||
* Saves the selected base account.
|
||||
*
|
||||
* @param code {string} the base account code
|
||||
* @param text {string} the text for the base account
|
||||
* @param account {BaseAccountOption} the selected base account
|
||||
*/
|
||||
setBaseAccount(code, text) {
|
||||
this.#baseCode.value = code;
|
||||
this.#base.innerText = text;
|
||||
if (["1", "2", "3"].includes(code.substring(0, 1))) {
|
||||
saveBaseAccount(account) {
|
||||
this.#baseCode.value = account.code;
|
||||
this.#base.innerText = account.text;
|
||||
if (["1", "2", "3"].includes(account.code.substring(0, 1))) {
|
||||
this.#isNeedOffsetControl.classList.remove("d-none");
|
||||
this.#isNeedOffset.disabled = false;
|
||||
} else {
|
||||
@ -163,7 +171,7 @@ class AccountForm {
|
||||
*
|
||||
* @returns {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validateForm() {
|
||||
#validate() {
|
||||
let isValid = true;
|
||||
isValid = this.#validateBase() && isValid;
|
||||
isValid = this.#validateTitle() && isValid;
|
||||
@ -225,7 +233,7 @@ class BaseAccountSelector {
|
||||
* The account form
|
||||
* @type {AccountForm}
|
||||
*/
|
||||
#form;
|
||||
form;
|
||||
|
||||
/**
|
||||
* The selector modal
|
||||
@ -253,7 +261,7 @@ class BaseAccountSelector {
|
||||
|
||||
/**
|
||||
* The options
|
||||
* @type {HTMLLIElement[]}
|
||||
* @type {BaseAccountOption[]}
|
||||
*/
|
||||
#options;
|
||||
|
||||
@ -269,83 +277,54 @@ class BaseAccountSelector {
|
||||
* @param form {AccountForm} the form
|
||||
*/
|
||||
constructor(form) {
|
||||
this.#form = form;
|
||||
this.#modal = document.getElementById("accounting-base-selector-modal");
|
||||
this.#query = document.getElementById("accounting-base-selector-query");
|
||||
this.#optionList = document.getElementById("accounting-base-selector-option-list");
|
||||
// noinspection JSValidateTypes
|
||||
this.#options = Array.from(document.getElementsByClassName("accounting-base-selector-option"));
|
||||
this.#clearButton = document.getElementById("accounting-base-selector-clear");
|
||||
this.#queryNoResult = document.getElementById("accounting-base-selector-option-no-result");
|
||||
this.#modal.addEventListener("hidden.bs.modal", () => {
|
||||
this.#form.onBaseAccountSelectorClosed();
|
||||
});
|
||||
for (const option of this.#options) {
|
||||
option.onclick = () => {
|
||||
this.#form.setBaseAccount(option.dataset.code, option.dataset.content);
|
||||
};
|
||||
}
|
||||
this.#clearButton.onclick = () => {
|
||||
this.#form.clearBaseAccount();
|
||||
};
|
||||
this.#initializeBaseAccountQuery();
|
||||
this.form = form;
|
||||
const prefix = "accounting-base-selector";
|
||||
this.#modal = document.getElementById(`${prefix}-modal`);
|
||||
this.#query = document.getElementById(`${prefix}-query`);
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the query.
|
||||
* Filters the options.
|
||||
*
|
||||
*/
|
||||
#initializeBaseAccountQuery() {
|
||||
this.#query.addEventListener("input", () => {
|
||||
if (this.#query.value === "") {
|
||||
for (const option of this.#options) {
|
||||
option.classList.remove("d-none");
|
||||
}
|
||||
this.#optionList.classList.remove("d-none");
|
||||
this.#queryNoResult.classList.add("d-none");
|
||||
return
|
||||
}
|
||||
let hasAnyMatched = false;
|
||||
for (const option of this.#options) {
|
||||
const queryValues = JSON.parse(option.dataset.queryValues);
|
||||
let isMatched = false;
|
||||
for (const queryValue of queryValues) {
|
||||
if (queryValue.toLowerCase().includes(this.#query.value.toLowerCase())) {
|
||||
isMatched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isMatched) {
|
||||
option.classList.remove("d-none");
|
||||
hasAnyMatched = true;
|
||||
} else {
|
||||
option.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
if (!hasAnyMatched) {
|
||||
this.#optionList.classList.add("d-none");
|
||||
this.#queryNoResult.classList.remove("d-none");
|
||||
#filterOptions() {
|
||||
let isAnyMatched = false;
|
||||
for (const option of this.#options) {
|
||||
if (option.isMatched(this.#query.value)) {
|
||||
option.setShown(true);
|
||||
isAnyMatched = true;
|
||||
} else {
|
||||
this.#optionList.classList.remove("d-none");
|
||||
this.#queryNoResult.classList.add("d-none");
|
||||
option.setShown(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!isAnyMatched) {
|
||||
this.#optionList.classList.add("d-none");
|
||||
this.#queryNoResult.classList.remove("d-none");
|
||||
} else {
|
||||
this.#optionList.classList.remove("d-none");
|
||||
this.#queryNoResult.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback when the base account selector is shown.
|
||||
*
|
||||
* @param baseCode {string} the active base code
|
||||
*/
|
||||
onOpen(baseCode) {
|
||||
onOpen() {
|
||||
this.#query.value = "";
|
||||
this.#filterOptions();
|
||||
for (const option of this.#options) {
|
||||
if (option.dataset.code === baseCode) {
|
||||
option.classList.add("active");
|
||||
} else {
|
||||
option.classList.remove("active");
|
||||
}
|
||||
option.setActive(option.code === this.form.baseCode);
|
||||
}
|
||||
if (baseCode === "") {
|
||||
if (this.form.baseCode === null) {
|
||||
this.#clearButton.classList.add("btn-secondary")
|
||||
this.#clearButton.classList.remove("btn-danger");
|
||||
this.#clearButton.disabled = true;
|
||||
@ -356,3 +335,100 @@ class BaseAccountSelector {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A base account option.
|
||||
*
|
||||
*/
|
||||
class BaseAccountOption {
|
||||
|
||||
/**
|
||||
* The base account selector
|
||||
* @type {BaseAccountSelector}
|
||||
*/
|
||||
#selector;
|
||||
|
||||
/**
|
||||
* The element
|
||||
* @type {HTMLLIElement}
|
||||
*/
|
||||
#element;
|
||||
|
||||
/**
|
||||
* The account code
|
||||
* @type {string}
|
||||
*/
|
||||
code;
|
||||
|
||||
/**
|
||||
* The account text
|
||||
* @type {string}
|
||||
*/
|
||||
text;
|
||||
|
||||
/**
|
||||
* The values to query against
|
||||
* @type {string[]}
|
||||
*/
|
||||
#queryValues;
|
||||
|
||||
/**
|
||||
* Constructs the account in the base account selector.
|
||||
*
|
||||
* @param selector {BaseAccountSelector} the base account selector
|
||||
* @param element {HTMLLIElement} the element
|
||||
*/
|
||||
constructor(selector, element) {
|
||||
this.#selector = selector;
|
||||
this.#element = element;
|
||||
this.code = element.dataset.code;
|
||||
this.text = element.dataset.text;
|
||||
this.#queryValues = JSON.parse(element.dataset.queryValues);
|
||||
|
||||
this.#element.onclick = () => this.#selector.form.saveBaseAccount(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the account matches the query.
|
||||
*
|
||||
* @param query {string} the query term
|
||||
* @return {boolean} true if the option matches, or false otherwise
|
||||
*/
|
||||
isMatched(query) {
|
||||
if (query === "") {
|
||||
return true;
|
||||
}
|
||||
for (const queryValue of this.#queryValues) {
|
||||
if (queryValue.toLowerCase().includes(query.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,224 +0,0 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
* account-selector.js: The JavaScript for the account selector
|
||||
*/
|
||||
|
||||
/* Copyright (c) 2023 imacat.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
* First written: 2023/2/28
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* The account selector.
|
||||
*
|
||||
*/
|
||||
class AccountSelector {
|
||||
|
||||
/**
|
||||
* The line item editor
|
||||
* @type {JournalEntryLineItemEditor}
|
||||
*/
|
||||
#lineItemEditor;
|
||||
|
||||
/**
|
||||
* Either "debit" or "credit"
|
||||
* @type {string}
|
||||
*/
|
||||
#debitCredit;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* @type {string}
|
||||
*/
|
||||
#prefix;
|
||||
|
||||
/**
|
||||
* The button to clear the account
|
||||
* @type {HTMLButtonElement}
|
||||
*/
|
||||
#clearButton
|
||||
|
||||
/**
|
||||
* The query input
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#query;
|
||||
|
||||
/**
|
||||
* The error message when the query has no result
|
||||
* @type {HTMLParagraphElement}
|
||||
*/
|
||||
#queryNoResult;
|
||||
|
||||
/**
|
||||
* The option list
|
||||
* @type {HTMLUListElement}
|
||||
*/
|
||||
#optionList;
|
||||
|
||||
/**
|
||||
* The options
|
||||
* @type {HTMLLIElement[]}
|
||||
*/
|
||||
#options;
|
||||
|
||||
/**
|
||||
* The more item to show all accounts
|
||||
* @type {HTMLLIElement}
|
||||
*/
|
||||
#more;
|
||||
|
||||
/**
|
||||
* Constructs an account selector.
|
||||
*
|
||||
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
|
||||
* @param debitCredit {string} either "debit" or "credit"
|
||||
*/
|
||||
constructor(lineItemEditor, debitCredit) {
|
||||
this.#lineItemEditor = lineItemEditor
|
||||
this.#debitCredit = debitCredit;
|
||||
this.#prefix = "accounting-account-selector-" + debitCredit;
|
||||
this.#query = document.getElementById(this.#prefix + "-query");
|
||||
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
|
||||
this.#optionList = document.getElementById(this.#prefix + "-option-list");
|
||||
// noinspection JSValidateTypes
|
||||
this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
|
||||
this.#more = document.getElementById(this.#prefix + "-more");
|
||||
this.#clearButton = document.getElementById(this.#prefix + "-btn-clear");
|
||||
this.#more.onclick = () => {
|
||||
this.#more.classList.add("d-none");
|
||||
this.#filterOptions();
|
||||
};
|
||||
this.#clearButton.onclick = () => this.#lineItemEditor.clearAccount();
|
||||
for (const option of this.#options) {
|
||||
option.onclick = () => this.#lineItemEditor.saveAccount(option.dataset.code, option.dataset.content, option.classList.contains("accounting-account-is-need-offset"));
|
||||
}
|
||||
this.#query.addEventListener("input", () => {
|
||||
this.#filterOptions();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the options.
|
||||
*
|
||||
*/
|
||||
#filterOptions() {
|
||||
const codesInUse = this.#getCodesUsedInForm();
|
||||
let shouldAnyShow = false;
|
||||
for (const option of this.#options) {
|
||||
const shouldShow = this.#shouldOptionShow(option, this.#more, codesInUse, this.#query);
|
||||
if (shouldShow) {
|
||||
option.classList.remove("d-none");
|
||||
shouldAnyShow = true;
|
||||
} else {
|
||||
option.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
if (!shouldAnyShow && this.#more.classList.contains("d-none")) {
|
||||
this.#optionList.classList.add("d-none");
|
||||
this.#queryNoResult.classList.remove("d-none");
|
||||
} else {
|
||||
this.#optionList.classList.remove("d-none");
|
||||
this.#queryNoResult.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the account codes that are used in the form.
|
||||
*
|
||||
* @return {string[]} the account codes that are used in the form
|
||||
*/
|
||||
#getCodesUsedInForm() {
|
||||
const inUse = this.#lineItemEditor.form.getAccountCodesUsed(this.#debitCredit);
|
||||
if (this.#lineItemEditor.accountCode !== null) {
|
||||
inUse.push(this.#lineItemEditor.accountCode);
|
||||
}
|
||||
return inUse
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether an option should show.
|
||||
*
|
||||
* @param option {HTMLLIElement} the option
|
||||
* @param more {HTMLLIElement} the more element
|
||||
* @param inUse {string[]} the account codes that are used in the form
|
||||
* @param query {HTMLInputElement} the query element, if any
|
||||
* @return {boolean} true if the option should show, or false otherwise
|
||||
*/
|
||||
#shouldOptionShow(option, more, inUse, query) {
|
||||
const isQueryMatched = () => {
|
||||
if (query.value === "") {
|
||||
return true;
|
||||
}
|
||||
const queryValues = JSON.parse(option.dataset.queryValues);
|
||||
for (const queryValue of queryValues) {
|
||||
if (queryValue.toLowerCase().includes(query.value.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const isMoreMatched = () => {
|
||||
if (more.classList.contains("d-none")) {
|
||||
return true;
|
||||
}
|
||||
return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code);
|
||||
};
|
||||
return isMoreMatched() && isQueryMatched();
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback when the account selector is shown.
|
||||
*
|
||||
*/
|
||||
onOpen() {
|
||||
this.#query.value = "";
|
||||
this.#more.classList.remove("d-none");
|
||||
this.#filterOptions();
|
||||
for (const option of this.#options) {
|
||||
if (option.dataset.code === this.#lineItemEditor.accountCode) {
|
||||
option.classList.add("active");
|
||||
} else {
|
||||
option.classList.remove("active");
|
||||
}
|
||||
}
|
||||
if (this.#lineItemEditor.accountCode === null) {
|
||||
this.#clearButton.classList.add("btn-secondary");
|
||||
this.#clearButton.classList.remove("btn-danger");
|
||||
this.#clearButton.disabled = true;
|
||||
} else {
|
||||
this.#clearButton.classList.add("btn-danger");
|
||||
this.#clearButton.classList.remove("btn-secondary");
|
||||
this.#clearButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the account selector instances.
|
||||
*
|
||||
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
|
||||
* @return {{debit: AccountSelector, credit: AccountSelector}}
|
||||
*/
|
||||
static getInstances(lineItemEditor) {
|
||||
const selectors = {}
|
||||
const modals = Array.from(document.getElementsByClassName("accounting-account-selector"));
|
||||
for (const modal of modals) {
|
||||
selectors[modal.dataset.debitCredit] = new AccountSelector(lineItemEditor, modal.dataset.debitCredit);
|
||||
}
|
||||
return selectors;
|
||||
}
|
||||
}
|
@ -81,7 +81,7 @@ class CurrencyForm {
|
||||
this.#validateName();
|
||||
};
|
||||
this.#formElement.onsubmit = () => {
|
||||
this.#validateForm().then((isValid) => {
|
||||
this.#validate().then((isValid) => {
|
||||
if (isValid) {
|
||||
this.#formElement.submit();
|
||||
}
|
||||
@ -95,7 +95,7 @@ class CurrencyForm {
|
||||
*
|
||||
* @returns {Promise<boolean>} true if valid, or false otherwise
|
||||
*/
|
||||
async #validateForm() {
|
||||
async #validate() {
|
||||
let isValid = true;
|
||||
isValid = await this.#validateCode() && isValid;
|
||||
isValid = this.#validateName() && isValid;
|
||||
|
@ -41,7 +41,7 @@ class DescriptionEditor {
|
||||
#form;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* The prefix of the HTML ID and class names
|
||||
* @type {string}
|
||||
*/
|
||||
prefix;
|
||||
@ -278,7 +278,7 @@ class TabPlane {
|
||||
editor;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and classes
|
||||
* The prefix of the HTML ID and class names
|
||||
* @type {string}
|
||||
*/
|
||||
prefix;
|
||||
@ -984,7 +984,6 @@ class RecurringTransactionTab extends TabPlane {
|
||||
*/
|
||||
#itemButtons;
|
||||
|
||||
// noinspection JSValidateTypes
|
||||
/**
|
||||
* Constructs a tab plane.
|
||||
*
|
||||
@ -1019,7 +1018,7 @@ class RecurringTransactionTab extends TabPlane {
|
||||
* @return {string} the description of the recurring item
|
||||
*/
|
||||
#getDescription(itemButton) {
|
||||
const today = new Date(this.editor.lineItemEditor.form.getDate());
|
||||
const today = new Date(this.editor.lineItemEditor.form.date);
|
||||
const thisMonth = today.getMonth() + 1;
|
||||
const lastMonth = (thisMonth + 10) % 12 + 1;
|
||||
const lastBimonthlyFrom = ((thisMonth + thisMonth % 2 + 8) % 12 + 1);
|
||||
|
319
src/accounting/static/js/journal-entry-account-selector.js
Normal file
319
src/accounting/static/js/journal-entry-account-selector.js
Normal file
@ -0,0 +1,319 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
* journal-entry-account-selector.js: The JavaScript for the account selector of the journal entry form
|
||||
*/
|
||||
|
||||
/* Copyright (c) 2023 imacat.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
* First written: 2023/2/28
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* The account selector.
|
||||
*
|
||||
*/
|
||||
class JournalEntryAccountSelector {
|
||||
|
||||
/**
|
||||
* The line item editor
|
||||
* @type {JournalEntryLineItemEditor}
|
||||
*/
|
||||
lineItemEditor;
|
||||
|
||||
/**
|
||||
* Either "debit" or "credit"
|
||||
* @type {string}
|
||||
*/
|
||||
#debitCredit;
|
||||
|
||||
/**
|
||||
* The button to clear the account
|
||||
* @type {HTMLButtonElement}
|
||||
*/
|
||||
#clearButton
|
||||
|
||||
/**
|
||||
* The query input
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#query;
|
||||
|
||||
/**
|
||||
* The error message when the query has no result
|
||||
* @type {HTMLParagraphElement}
|
||||
*/
|
||||
#queryNoResult;
|
||||
|
||||
/**
|
||||
* The option list
|
||||
* @type {HTMLUListElement}
|
||||
*/
|
||||
#optionList;
|
||||
|
||||
/**
|
||||
* The options
|
||||
* @type {JournalEntryAccountOption[]}
|
||||
*/
|
||||
#options;
|
||||
|
||||
/**
|
||||
* The more item to show all accounts
|
||||
* @type {HTMLLIElement}
|
||||
*/
|
||||
#more;
|
||||
|
||||
/**
|
||||
* Whether to show all accounts
|
||||
* @type {boolean}
|
||||
*/
|
||||
#isShowMore = false;
|
||||
|
||||
/**
|
||||
* Constructs an account selector.
|
||||
*
|
||||
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
|
||||
* @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");
|
||||
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 = () => {
|
||||
this.#isShowMore = true;
|
||||
this.#more.classList.add("d-none");
|
||||
this.#filterOptions();
|
||||
};
|
||||
this.#query.oninput = () => this.#filterOptions();
|
||||
this.#clearButton.onclick = () => this.lineItemEditor.clearAccount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the options.
|
||||
*
|
||||
*/
|
||||
#filterOptions() {
|
||||
const codesInUse = this.#getCodesUsedInForm();
|
||||
let isAnyMatched = false;
|
||||
for (const option of this.#options) {
|
||||
if (option.isMatched(this.#isShowMore, codesInUse, this.#query.value)) {
|
||||
option.setShown(true);
|
||||
isAnyMatched = true;
|
||||
} else {
|
||||
option.setShown(false);
|
||||
}
|
||||
}
|
||||
if (!isAnyMatched) {
|
||||
this.#optionList.classList.add("d-none");
|
||||
this.#queryNoResult.classList.remove("d-none");
|
||||
} else {
|
||||
this.#optionList.classList.remove("d-none");
|
||||
this.#queryNoResult.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the account codes that are used in the form.
|
||||
*
|
||||
* @return {string[]} the account codes that are used in the form
|
||||
*/
|
||||
#getCodesUsedInForm() {
|
||||
const inUse = this.lineItemEditor.form.getAccountCodesUsed(this.#debitCredit);
|
||||
if (this.lineItemEditor.accountCode !== null) {
|
||||
inUse.push(this.lineItemEditor.accountCode);
|
||||
}
|
||||
return inUse
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback when the account selector is shown.
|
||||
*
|
||||
*/
|
||||
onOpen() {
|
||||
this.#query.value = "";
|
||||
this.#isShowMore = false;
|
||||
this.#more.classList.remove("d-none");
|
||||
this.#filterOptions();
|
||||
for (const option of this.#options) {
|
||||
option.setActive(option.code === this.lineItemEditor.accountCode);
|
||||
}
|
||||
if (this.lineItemEditor.accountCode === null) {
|
||||
this.#clearButton.classList.add("btn-secondary");
|
||||
this.#clearButton.classList.remove("btn-danger");
|
||||
this.#clearButton.disabled = true;
|
||||
} else {
|
||||
this.#clearButton.classList.add("btn-danger");
|
||||
this.#clearButton.classList.remove("btn-secondary");
|
||||
this.#clearButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the account selector instances.
|
||||
*
|
||||
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
|
||||
* @return {{debit: JournalEntryAccountSelector, credit: JournalEntryAccountSelector}}
|
||||
*/
|
||||
static getInstances(lineItemEditor) {
|
||||
const selectors = {}
|
||||
const modals = Array.from(document.getElementsByClassName("accounting-account-selector"));
|
||||
for (const modal of modals) {
|
||||
selectors[modal.dataset.debitCredit] = new JournalEntryAccountSelector(lineItemEditor, modal.dataset.debitCredit);
|
||||
}
|
||||
return selectors;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An account option
|
||||
*
|
||||
*/
|
||||
class JournalEntryAccountOption {
|
||||
|
||||
/**
|
||||
* The account selector
|
||||
* @type {JournalEntryAccountSelector}
|
||||
*/
|
||||
#selector;
|
||||
|
||||
/**
|
||||
* The element
|
||||
* @type {HTMLLIElement}
|
||||
*/
|
||||
#element;
|
||||
|
||||
/**
|
||||
* The account code
|
||||
* @type {string}
|
||||
*/
|
||||
code;
|
||||
|
||||
/**
|
||||
* The account text
|
||||
* @type {string}
|
||||
*/
|
||||
text;
|
||||
|
||||
/**
|
||||
* Whether the account is in use
|
||||
* @type {boolean}
|
||||
*/
|
||||
#isInUse;
|
||||
|
||||
/**
|
||||
* Whether line items in the account need offset
|
||||
* @type {boolean}
|
||||
*/
|
||||
isNeedOffset;
|
||||
|
||||
/**
|
||||
* The values to query against
|
||||
* @type {string[]}
|
||||
*/
|
||||
#queryValues;
|
||||
|
||||
/**
|
||||
* Constructs the account in the account selector.
|
||||
*
|
||||
* @param selector {JournalEntryAccountSelector} the account selector
|
||||
* @param element {HTMLLIElement} the element
|
||||
*/
|
||||
constructor(selector, element) {
|
||||
this.#selector = selector;
|
||||
this.#element = element;
|
||||
this.code = element.dataset.code;
|
||||
this.text = element.dataset.text;
|
||||
this.#isInUse = element.classList.contains("accounting-account-is-in-use");
|
||||
this.isNeedOffset = element.classList.contains("accounting-account-is-need-offset");
|
||||
this.#queryValues = JSON.parse(element.dataset.queryValues);
|
||||
|
||||
this.#element.onclick = () => this.#selector.lineItemEditor.saveAccount(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 this.#isInUseMatched(isShowMore, codesInUse) && this.#isQueryMatched(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the account matches the "in-use" condition.
|
||||
*
|
||||
* @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
|
||||
* @return {boolean} true if the option matches, or false otherwise
|
||||
*/
|
||||
#isInUseMatched(isShowMore, codesInUse) {
|
||||
return isShowMore || this.#isInUse || codesInUse.includes(this.code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the account matches the query term.
|
||||
*
|
||||
* @param query {string} the query term
|
||||
* @return {boolean} true if the option matches, or false otherwise
|
||||
*/
|
||||
#isQueryMatched(query) {
|
||||
if (query === "") {
|
||||
return true;
|
||||
}
|
||||
for (const queryValue of this.#queryValues) {
|
||||
if (queryValue.toLowerCase().includes(query.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
@ -111,6 +111,7 @@ class JournalEntryForm {
|
||||
constructor() {
|
||||
this.#element = document.getElementById("accounting-form");
|
||||
this.lineItemTemplate = this.#element.dataset.lineItemTemplate;
|
||||
this.lineItemEditor = new JournalEntryLineItemEditor(this);
|
||||
this.#date = document.getElementById("accounting-date");
|
||||
this.#dateError = document.getElementById("accounting-date-error");
|
||||
this.#currencyControl = document.getElementById("accounting-currencies");
|
||||
@ -121,7 +122,6 @@ class JournalEntryForm {
|
||||
this.#addCurrencyButton = document.getElementById("accounting-add-currency");
|
||||
this.#note = document.getElementById("accounting-note");
|
||||
this.#noteError = document.getElementById("accounting-note-error");
|
||||
this.lineItemEditor = new JournalEntryLineItemEditor(this);
|
||||
|
||||
this.#addCurrencyButton.onclick = () => {
|
||||
const newIndex = 1 + (this.#currencies.length === 0? 0: Math.max(...this.#currencies.map((currency) => currency.index)));
|
||||
@ -159,7 +159,7 @@ class JournalEntryForm {
|
||||
*/
|
||||
#resetDeleteCurrencyButtons() {
|
||||
if (this.#currencies.length === 1) {
|
||||
this.#currencies[0].deleteButton.classList.add("d-none");
|
||||
this.#currencies[0].setDeleteButtonShown(false);
|
||||
} else {
|
||||
for (const currency of this.#currencies) {
|
||||
let isAnyLineItemMatched = false;
|
||||
@ -169,11 +169,7 @@ class JournalEntryForm {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isAnyLineItemMatched) {
|
||||
currency.deleteButton.classList.add("d-none");
|
||||
} else {
|
||||
currency.deleteButton.classList.remove("d-none");
|
||||
}
|
||||
currency.setDeleteButtonShown(!isAnyLineItemMatched);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -184,10 +180,8 @@ class JournalEntryForm {
|
||||
*/
|
||||
#initializeDragAndDropReordering() {
|
||||
initializeDragAndDropReordering(this.#currencyList, () => {
|
||||
const currencyId = Array.from(this.#currencyList.children).map((currency) => currency.id);
|
||||
this.#currencies.sort((a, b) => currencyId.indexOf(a.element.id) - currencyId.indexOf(b.element.id));
|
||||
for (let i = 0; i < this.#currencies.length; i++) {
|
||||
this.#currencies[i].no.value = String(i + 1);
|
||||
for (const currency of this.#currencies) {
|
||||
currency.resetNo();
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -213,7 +207,7 @@ class JournalEntryForm {
|
||||
* @return {string[]} the account codes used in the form
|
||||
*/
|
||||
getAccountCodesUsed(debitCredit) {
|
||||
return this.getLineItems(debitCredit).map((lineItem) => lineItem.getAccountCode())
|
||||
return this.getLineItems(debitCredit).map((lineItem) => lineItem.accountCode)
|
||||
.filter((code) => code !== null);
|
||||
}
|
||||
|
||||
@ -222,7 +216,7 @@ class JournalEntryForm {
|
||||
*
|
||||
* @return {string} the date
|
||||
*/
|
||||
getDate() {
|
||||
get date() {
|
||||
return this.#date.value;
|
||||
}
|
||||
|
||||
@ -233,7 +227,7 @@ class JournalEntryForm {
|
||||
updateMinDate() {
|
||||
let lastOriginalLineItemDate = null;
|
||||
for (const lineItem of this.getLineItems()) {
|
||||
const date = lineItem.getOriginalLineItemDate();
|
||||
const date = lineItem.originalLineItemDate;
|
||||
if (date !== null) {
|
||||
if (lastOriginalLineItemDate === null || lastOriginalLineItemDate < date) {
|
||||
lastOriginalLineItemDate = date;
|
||||
@ -349,7 +343,7 @@ class CurrencySubForm {
|
||||
* The element
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
element;
|
||||
#element;
|
||||
|
||||
/**
|
||||
* The journal entry form
|
||||
@ -363,12 +357,6 @@ class CurrencySubForm {
|
||||
*/
|
||||
index;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* @type {string}
|
||||
*/
|
||||
#prefix;
|
||||
|
||||
/**
|
||||
* The control
|
||||
* @type {HTMLDivElement}
|
||||
@ -385,7 +373,7 @@ class CurrencySubForm {
|
||||
* The number
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
no;
|
||||
#no;
|
||||
|
||||
/**
|
||||
* The currency code
|
||||
@ -403,7 +391,7 @@ class CurrencySubForm {
|
||||
* The button to delete the currency
|
||||
* @type {HTMLButtonElement}
|
||||
*/
|
||||
deleteButton;
|
||||
#deleteButton;
|
||||
|
||||
/**
|
||||
* The debit sub-form
|
||||
@ -424,36 +412,58 @@ class CurrencySubForm {
|
||||
* @param element {HTMLDivElement} the currency sub-form element
|
||||
*/
|
||||
constructor(form, element) {
|
||||
this.element = element;
|
||||
this.#element = element;
|
||||
this.form = form;
|
||||
this.index = parseInt(this.element.dataset.index);
|
||||
this.#prefix = "accounting-currency-" + String(this.index);
|
||||
this.#control = document.getElementById(this.#prefix + "-control");
|
||||
this.#error = document.getElementById(this.#prefix + "-error");
|
||||
this.no = document.getElementById(this.#prefix + "-no");
|
||||
this.#code = document.getElementById(this.#prefix + "-code");
|
||||
this.#codeSelect = document.getElementById(this.#prefix + "-code-select");
|
||||
this.deleteButton = document.getElementById(this.#prefix + "-delete");
|
||||
const debitElement = document.getElementById(this.#prefix + "-debit");
|
||||
this.index = parseInt(this.#element.dataset.index);
|
||||
const prefix = "accounting-currency-" + String(this.index);
|
||||
this.#control = document.getElementById(prefix + "-control");
|
||||
this.#error = document.getElementById(prefix + "-error");
|
||||
this.#no = document.getElementById(prefix + "-no");
|
||||
this.#code = document.getElementById(prefix + "-code");
|
||||
this.#codeSelect = document.getElementById(prefix + "-code-select");
|
||||
this.#deleteButton = document.getElementById(prefix + "-delete");
|
||||
const debitElement = document.getElementById(prefix + "-debit");
|
||||
this.#debit = debitElement === null? null: new DebitCreditSubForm(this, debitElement, "debit");
|
||||
const creditElement = document.getElementById(this.#prefix + "-credit");
|
||||
const creditElement = document.getElementById(prefix + "-credit");
|
||||
this.#credit = creditElement == null? null: new DebitCreditSubForm(this, creditElement, "credit");
|
||||
this.#codeSelect.onchange = () => this.#code.value = this.#codeSelect.value;
|
||||
this.deleteButton.onclick = () => {
|
||||
this.element.parentElement.removeChild(this.element);
|
||||
this.#deleteButton.onclick = () => {
|
||||
this.#element.parentElement.removeChild(this.#element);
|
||||
this.form.deleteCurrency(this);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the order number.
|
||||
*
|
||||
*/
|
||||
resetNo() {
|
||||
const siblings = Array.from(this.#element.parentElement.children);
|
||||
this.#no.value = String(siblings.indexOf(this.#element) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currency code.
|
||||
*
|
||||
* @return {string} the currency code
|
||||
*/
|
||||
getCurrencyCode() {
|
||||
get currencyCode() {
|
||||
return this.#code.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the delete button is shown.
|
||||
*
|
||||
* @param isShown {boolean} true to show, or false otherwise
|
||||
*/
|
||||
setDeleteButtonShown(isShown) {
|
||||
if (isShown) {
|
||||
this.#deleteButton.classList.remove("d-none");
|
||||
} else {
|
||||
this.#deleteButton.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the line items in the form.
|
||||
*
|
||||
@ -479,7 +489,7 @@ class CurrencySubForm {
|
||||
updateCodeSelectorStatus() {
|
||||
let isEnabled = true;
|
||||
for (const lineItem of this.getLineItems()) {
|
||||
if (lineItem.getOriginalLineItemId() !== null) {
|
||||
if (lineItem.originalLineItemId !== null) {
|
||||
isEnabled = false;
|
||||
break;
|
||||
}
|
||||
@ -511,7 +521,7 @@ class CurrencySubForm {
|
||||
*/
|
||||
validateBalance() {
|
||||
if (this.#debit !== null && this.#credit !== null) {
|
||||
if (!this.#debit.getTotal().equals(this.#credit.getTotal())) {
|
||||
if (!this.#debit.total.equals(this.#credit.total)) {
|
||||
this.#control.classList.add("is-invalid");
|
||||
this.#error.innerText = A_("The totals of the debit and credit amounts do not match.");
|
||||
return false;
|
||||
@ -541,6 +551,12 @@ class DebitCreditSubForm {
|
||||
*/
|
||||
#element;
|
||||
|
||||
/**
|
||||
* The content
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#content;
|
||||
|
||||
/**
|
||||
* The currencyIndex
|
||||
* @type {number}
|
||||
@ -554,7 +570,7 @@ class DebitCreditSubForm {
|
||||
debitCredit;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* The prefix of the HTML ID and class names
|
||||
* @type {string}
|
||||
*/
|
||||
#prefix;
|
||||
@ -602,24 +618,36 @@ class DebitCreditSubForm {
|
||||
this.#currencyIndex = currency.index;
|
||||
this.debitCredit = debitCredit;
|
||||
this.#prefix = "accounting-currency-" + String(this.#currencyIndex) + "-" + debitCredit;
|
||||
this.#content = document.getElementById(this.#prefix + "-content");
|
||||
this.#error = document.getElementById(this.#prefix + "-error");
|
||||
this.#lineItemList = document.getElementById(this.#prefix + "-list");
|
||||
// noinspection JSValidateTypes
|
||||
this.lineItems = Array.from(document.getElementsByClassName(this.#prefix)).map((element) => new LineItemSubForm(this, element));
|
||||
this.#total = document.getElementById(this.#prefix + "-total");
|
||||
this.#addLineItemButton = document.getElementById(this.#prefix + "-add-line-item");
|
||||
|
||||
this.#resetContent();
|
||||
this.#addLineItemButton.onclick = () => this.currency.form.lineItemEditor.onAddNew(this);
|
||||
this.#resetDeleteLineItemButtons();
|
||||
this.#initializeDragAndDropReordering();
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback when the line item editor is closed.
|
||||
*
|
||||
*/
|
||||
onLineItemEditorClosed() {
|
||||
if (this.lineItems.length === 0) {
|
||||
this.#element.classList.remove("accounting-not-empty");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new line item sub-form
|
||||
*
|
||||
* @returns {LineItemSubForm} the newly-added line item sub-form
|
||||
*/
|
||||
addLineItem() {
|
||||
const newIndex = 1 + (this.lineItems.length === 0? 0: Math.max(...this.lineItems.map((lineItem) => lineItem.lineItemIndex)));
|
||||
const newIndex = 1 + (this.lineItems.length === 0? 0: Math.max(...this.lineItems.map((lineItem) => lineItem.index)));
|
||||
const html = this.currency.form.lineItemTemplate
|
||||
.replaceAll("CURRENCY_INDEX", escapeHtml(String(this.#currencyIndex)))
|
||||
.replaceAll("DEBIT_CREDIT", escapeHtml(this.debitCredit))
|
||||
@ -627,6 +655,7 @@ class DebitCreditSubForm {
|
||||
this.#lineItemList.insertAdjacentHTML("beforeend", html);
|
||||
const lineItem = new LineItemSubForm(this, document.getElementById(this.#prefix + "-" + String(newIndex)));
|
||||
this.lineItems.push(lineItem);
|
||||
this.#resetContent();
|
||||
this.#resetDeleteLineItemButtons();
|
||||
this.#initializeDragAndDropReordering();
|
||||
this.validate();
|
||||
@ -644,6 +673,7 @@ class DebitCreditSubForm {
|
||||
this.updateTotal();
|
||||
this.currency.updateCodeSelectorStatus();
|
||||
this.currency.form.updateMinDate();
|
||||
this.#resetContent();
|
||||
this.#resetDeleteLineItemButtons();
|
||||
}
|
||||
|
||||
@ -653,27 +683,48 @@ class DebitCreditSubForm {
|
||||
*/
|
||||
#resetDeleteLineItemButtons() {
|
||||
if (this.lineItems.length === 1) {
|
||||
this.lineItems[0].deleteButton.classList.add("d-none");
|
||||
this.lineItems[0].setDeleteButtonShown(false);
|
||||
} else {
|
||||
for (const lineItem of this.lineItems) {
|
||||
if (lineItem.isMatched) {
|
||||
lineItem.deleteButton.classList.add("d-none");
|
||||
} else {
|
||||
lineItem.deleteButton.classList.remove("d-none");
|
||||
}
|
||||
lineItem.setDeleteButtonShown(!lineItem.isMatched);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the layout of the content.
|
||||
*
|
||||
*/
|
||||
#resetContent() {
|
||||
if (this.lineItems.length === 0) {
|
||||
this.#element.classList.remove("accounting-not-empty");
|
||||
this.#element.classList.add("accounting-clickable");
|
||||
this.#element.dataset.bsToggle = "modal"
|
||||
this.#element.dataset.bsTarget = "#" + this.currency.form.lineItemEditor.modal.id;
|
||||
this.#element.onclick = () => {
|
||||
this.#element.classList.add("accounting-not-empty");
|
||||
this.currency.form.lineItemEditor.onAddNew(this);
|
||||
};
|
||||
this.#content.classList.add("d-none");
|
||||
} 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.#content.classList.remove("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total amount.
|
||||
*
|
||||
* @return {Decimal} the total amount
|
||||
*/
|
||||
getTotal() {
|
||||
get total() {
|
||||
let total = new Decimal("0");
|
||||
for (const lineItem of this.lineItems) {
|
||||
const amount = lineItem.getAmount();
|
||||
const amount = lineItem.amount;
|
||||
if (amount !== null) {
|
||||
total = total.plus(amount);
|
||||
}
|
||||
@ -686,7 +737,7 @@ class DebitCreditSubForm {
|
||||
*
|
||||
*/
|
||||
updateTotal() {
|
||||
this.#total.innerText = formatDecimal(this.getTotal());
|
||||
this.#total.innerText = formatDecimal(this.total);
|
||||
this.currency.validateBalance();
|
||||
}
|
||||
|
||||
@ -696,10 +747,8 @@ class DebitCreditSubForm {
|
||||
*/
|
||||
#initializeDragAndDropReordering() {
|
||||
initializeDragAndDropReordering(this.#lineItemList, () => {
|
||||
const lineItemId = Array.from(this.#lineItemList.children).map((lineItem) => lineItem.id);
|
||||
this.lineItems.sort((a, b) => lineItemId.indexOf(a.element.id) - lineItemId.indexOf(b.element.id));
|
||||
for (let i = 0; i < this.lineItems.length; i++) {
|
||||
this.lineItems[i].no.value = String(i + 1);
|
||||
for (const lineItem of this.lineItems) {
|
||||
lineItem.resetNo();
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -751,7 +800,7 @@ class LineItemSubForm {
|
||||
* The element
|
||||
* @type {HTMLLIElement}
|
||||
*/
|
||||
element;
|
||||
#element;
|
||||
|
||||
/**
|
||||
* Either "debit" or "credit"
|
||||
@ -763,7 +812,7 @@ class LineItemSubForm {
|
||||
* The line item index
|
||||
* @type {number}
|
||||
*/
|
||||
lineItemIndex;
|
||||
index;
|
||||
|
||||
/**
|
||||
* Whether this is an original line item with offsets
|
||||
@ -771,12 +820,6 @@ class LineItemSubForm {
|
||||
*/
|
||||
isMatched;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* @type {string}
|
||||
*/
|
||||
#prefix;
|
||||
|
||||
/**
|
||||
* The control
|
||||
* @type {HTMLDivElement}
|
||||
@ -793,7 +836,7 @@ class LineItemSubForm {
|
||||
* The number
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
no;
|
||||
#no;
|
||||
|
||||
/**
|
||||
* The account code
|
||||
@ -853,7 +896,7 @@ class LineItemSubForm {
|
||||
* The button to delete line item
|
||||
* @type {HTMLButtonElement}
|
||||
*/
|
||||
deleteButton;
|
||||
#deleteButton;
|
||||
|
||||
/**
|
||||
* Constructs the line item sub-form.
|
||||
@ -863,38 +906,47 @@ class LineItemSubForm {
|
||||
*/
|
||||
constructor(debitCredit, element) {
|
||||
this.debitCreditSubForm = debitCredit;
|
||||
this.element = element;
|
||||
this.#element = element;
|
||||
this.debitCredit = element.dataset.debitCredit;
|
||||
this.lineItemIndex = parseInt(element.dataset.lineItemIndex);
|
||||
this.index = parseInt(element.dataset.lineItemIndex);
|
||||
this.isMatched = element.classList.contains("accounting-matched-line-item");
|
||||
this.#prefix = "accounting-currency-" + element.dataset.currencyIndex + "-" + this.debitCredit + "-" + this.lineItemIndex;
|
||||
this.#control = document.getElementById(this.#prefix + "-control");
|
||||
this.#error = document.getElementById(this.#prefix + "-error");
|
||||
this.no = document.getElementById(this.#prefix + "-no");
|
||||
this.#accountCode = document.getElementById(this.#prefix + "-account-code");
|
||||
this.#accountText = document.getElementById(this.#prefix + "-account-text");
|
||||
this.#description = document.getElementById(this.#prefix + "-description");
|
||||
this.#descriptionText = document.getElementById(this.#prefix + "-description-text");
|
||||
this.#originalLineItemId = document.getElementById(this.#prefix + "-original-line-item-id");
|
||||
this.#originalLineItemText = document.getElementById(this.#prefix + "-original-line-item-text");
|
||||
this.#offsets = document.getElementById(this.#prefix + "-offsets");
|
||||
this.#amount = document.getElementById(this.#prefix + "-amount");
|
||||
this.#amountText = document.getElementById(this.#prefix + "-amount-text");
|
||||
this.deleteButton = document.getElementById(this.#prefix + "-delete");
|
||||
const prefix = "accounting-currency-" + element.dataset.currencyIndex + "-" + this.debitCredit + "-" + String(this.index);
|
||||
this.#control = document.getElementById(prefix + "-control");
|
||||
this.#error = document.getElementById(prefix + "-error");
|
||||
this.#no = document.getElementById(prefix + "-no");
|
||||
this.#accountCode = document.getElementById(prefix + "-account-code");
|
||||
this.#accountText = document.getElementById(prefix + "-account-text");
|
||||
this.#description = document.getElementById(prefix + "-description");
|
||||
this.#descriptionText = document.getElementById(prefix + "-description-text");
|
||||
this.#originalLineItemId = document.getElementById(prefix + "-original-line-item-id");
|
||||
this.#originalLineItemText = document.getElementById(prefix + "-original-line-item-text");
|
||||
this.#offsets = document.getElementById(prefix + "-offsets");
|
||||
this.#amount = document.getElementById(prefix + "-amount");
|
||||
this.#amountText = document.getElementById(prefix + "-amount-text");
|
||||
this.#deleteButton = document.getElementById(prefix + "-delete");
|
||||
this.#control.onclick = () => this.debitCreditSubForm.currency.form.lineItemEditor.onEdit(this);
|
||||
this.deleteButton.onclick = () => {
|
||||
this.element.parentElement.removeChild(this.element);
|
||||
this.#deleteButton.onclick = () => {
|
||||
this.#element.parentElement.removeChild(this.#element);
|
||||
this.debitCreditSubForm.deleteLineItem(this);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the order number.
|
||||
*
|
||||
*/
|
||||
resetNo() {
|
||||
const siblings = Array.from(this.#element.parentElement.children);
|
||||
this.#no.value = String(siblings.indexOf(this.#element) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the line item needs offset.
|
||||
*
|
||||
* @return {boolean} true if the line item needs offset, or false otherwise
|
||||
*/
|
||||
isNeedOffset() {
|
||||
return "isNeedOffset" in this.element.dataset;
|
||||
get isNeedOffset() {
|
||||
return "isNeedOffset" in this.#element.dataset;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -902,7 +954,7 @@ class LineItemSubForm {
|
||||
*
|
||||
* @return {string|null} the ID of the original line item
|
||||
*/
|
||||
getOriginalLineItemId() {
|
||||
get originalLineItemId() {
|
||||
return this.#originalLineItemId.value === ""? null: this.#originalLineItemId.value;
|
||||
}
|
||||
|
||||
@ -911,7 +963,7 @@ class LineItemSubForm {
|
||||
*
|
||||
* @return {string|null} the date of the original line item
|
||||
*/
|
||||
getOriginalLineItemDate() {
|
||||
get originalLineItemDate() {
|
||||
return this.#originalLineItemId.dataset.date === ""? null: this.#originalLineItemId.dataset.date;
|
||||
}
|
||||
|
||||
@ -920,7 +972,7 @@ class LineItemSubForm {
|
||||
*
|
||||
* @return {string|null} the text of the original line item
|
||||
*/
|
||||
getOriginalLineItemText() {
|
||||
get originalLineItemText() {
|
||||
return this.#originalLineItemId.dataset.text === ""? null: this.#originalLineItemId.dataset.text;
|
||||
}
|
||||
|
||||
@ -929,7 +981,7 @@ class LineItemSubForm {
|
||||
*
|
||||
* @return {string|null} the description
|
||||
*/
|
||||
getDescription() {
|
||||
get description() {
|
||||
return this.#description.value === ""? null: this.#description.value;
|
||||
}
|
||||
|
||||
@ -938,7 +990,7 @@ class LineItemSubForm {
|
||||
*
|
||||
* @return {string|null} the account code
|
||||
*/
|
||||
getAccountCode() {
|
||||
get accountCode() {
|
||||
return this.#accountCode.value === ""? null: this.#accountCode.value;
|
||||
}
|
||||
|
||||
@ -947,7 +999,7 @@ class LineItemSubForm {
|
||||
*
|
||||
* @return {string|null} the account text
|
||||
*/
|
||||
getAccountText() {
|
||||
get accountText() {
|
||||
return this.#accountCode.dataset.text === ""? null: this.#accountCode.dataset.text;
|
||||
}
|
||||
|
||||
@ -956,7 +1008,7 @@ class LineItemSubForm {
|
||||
*
|
||||
* @return {Decimal|null} the amount
|
||||
*/
|
||||
getAmount() {
|
||||
get amount() {
|
||||
return this.#amount.value === ""? null: new Decimal(this.#amount.value);
|
||||
}
|
||||
|
||||
@ -965,10 +1017,23 @@ class LineItemSubForm {
|
||||
*
|
||||
* @return {Decimal|null} the minimal amount
|
||||
*/
|
||||
getAmountMin() {
|
||||
get amountMin() {
|
||||
return this.#amount.dataset.min === ""? null: new Decimal(this.#amount.dataset.min);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the delete button is shown.
|
||||
*
|
||||
* @param isShown {boolean} true to show, or false otherwise
|
||||
*/
|
||||
setDeleteButtonShown(isShown) {
|
||||
if (isShown) {
|
||||
this.#deleteButton.classList.remove("d-none");
|
||||
} else {
|
||||
this.#deleteButton.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the form.
|
||||
*
|
||||
|
@ -44,7 +44,7 @@ class JournalEntryLineItemEditor {
|
||||
* The bootstrap modal
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#modal;
|
||||
modal;
|
||||
|
||||
/**
|
||||
* Either "debit" or "credit"
|
||||
@ -53,7 +53,7 @@ class JournalEntryLineItemEditor {
|
||||
debitCredit;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* The prefix of the HTML ID and class names
|
||||
* @type {string}
|
||||
*/
|
||||
#prefix = "accounting-line-item-editor"
|
||||
@ -190,12 +190,6 @@ class JournalEntryLineItemEditor {
|
||||
*/
|
||||
description = null;
|
||||
|
||||
/**
|
||||
* The amount
|
||||
* @type {string}
|
||||
*/
|
||||
amount = "";
|
||||
|
||||
/**
|
||||
* The description editors
|
||||
* @type {{debit: DescriptionEditor, credit: DescriptionEditor}}
|
||||
@ -204,7 +198,7 @@ class JournalEntryLineItemEditor {
|
||||
|
||||
/**
|
||||
* The account selectors
|
||||
* @type {{debit: AccountSelector, credit: AccountSelector}}
|
||||
* @type {{debit: JournalEntryAccountSelector, credit: JournalEntryAccountSelector}}
|
||||
*/
|
||||
#accountSelectors;
|
||||
|
||||
@ -222,7 +216,7 @@ class JournalEntryLineItemEditor {
|
||||
constructor(form) {
|
||||
this.form = form;
|
||||
this.#element = document.getElementById(this.#prefix);
|
||||
this.#modal = document.getElementById(this.#prefix + "-modal");
|
||||
this.modal = document.getElementById(this.#prefix + "-modal");
|
||||
this.#originalLineItemContainer = document.getElementById(this.#prefix + "-original-line-item-container");
|
||||
this.#originalLineItemControl = document.getElementById(this.#prefix + "-original-line-item-control");
|
||||
this.#originalLineItemText = document.getElementById(this.#prefix + "-original-line-item");
|
||||
@ -237,8 +231,9 @@ class JournalEntryLineItemEditor {
|
||||
this.#amountInput = document.getElementById(this.#prefix + "-amount");
|
||||
this.#amountError = document.getElementById(this.#prefix + "-amount-error");
|
||||
this.#descriptionEditors = DescriptionEditor.getInstances(this);
|
||||
this.#accountSelectors = AccountSelector.getInstances(this);
|
||||
this.#accountSelectors = JournalEntryAccountSelector.getInstances(this);
|
||||
this.originalLineItemSelector = new OriginalLineItemSelector(this);
|
||||
|
||||
this.#originalLineItemControl.onclick = () => this.originalLineItemSelector.onOpen()
|
||||
this.#originalLineItemDelete.onclick = () => this.clearOriginalLineItem();
|
||||
this.#descriptionControl.onclick = () => this.#descriptionEditors[this.debitCredit].onOpen();
|
||||
@ -249,12 +244,30 @@ class JournalEntryLineItemEditor {
|
||||
if (this.lineItem === null) {
|
||||
this.lineItem = this.#debitCreditSubForm.addLineItem();
|
||||
}
|
||||
this.amount = this.#amountInput.value;
|
||||
this.lineItem.save(this);
|
||||
bootstrap.Modal.getInstance(this.#modal).hide();
|
||||
bootstrap.Modal.getInstance(this.modal).hide();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
this.modal.addEventListener("hidden.bs.modal", () => this.#debitCreditSubForm.onLineItemEditorClosed());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the amount.
|
||||
*
|
||||
* @return {string} the amount
|
||||
*/
|
||||
get amount() {
|
||||
return this.#amountInput.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currency code.
|
||||
*
|
||||
* @return {string} the currency code
|
||||
*/
|
||||
get currencyCode() {
|
||||
return this.#debitCreditSubForm.currency.currencyCode;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -308,15 +321,6 @@ class JournalEntryLineItemEditor {
|
||||
this.#amountInput.max = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currency code.
|
||||
*
|
||||
* @return {string} the currency code
|
||||
*/
|
||||
getCurrencyCode() {
|
||||
return this.#debitCreditSubForm.currency.getCurrencyCode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the description from the description editor.
|
||||
*
|
||||
@ -331,7 +335,7 @@ class JournalEntryLineItemEditor {
|
||||
this.description = description === ""? null: description;
|
||||
this.#descriptionText.innerText = description;
|
||||
this.#validateDescription();
|
||||
bootstrap.Modal.getOrCreateInstance(this.#modal).show();
|
||||
bootstrap.Modal.getOrCreateInstance(this.modal).show();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -366,18 +370,16 @@ class JournalEntryLineItemEditor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the account.
|
||||
* Saves the selected account.
|
||||
*
|
||||
* @param code {string} the account code
|
||||
* @param text {string} the account text
|
||||
* @param isNeedOffset {boolean} true if the line items in the account need offset or false otherwise
|
||||
* @param account {JournalEntryAccountOption} the selected account
|
||||
*/
|
||||
saveAccount(code, text, isNeedOffset) {
|
||||
this.isNeedOffset = isNeedOffset;
|
||||
saveAccount(account) {
|
||||
this.isNeedOffset = account.isNeedOffset;
|
||||
this.#accountControl.classList.add("accounting-not-empty");
|
||||
this.accountCode = code;
|
||||
this.accountText = text;
|
||||
this.#accountText.innerText = text;
|
||||
this.accountCode = account.code;
|
||||
this.accountText = account.text;
|
||||
this.#accountText.innerText = account.text;
|
||||
this.#validateAccount();
|
||||
}
|
||||
|
||||
@ -520,10 +522,10 @@ class JournalEntryLineItemEditor {
|
||||
this.lineItem = lineItem;
|
||||
this.#debitCreditSubForm = lineItem.debitCreditSubForm;
|
||||
this.debitCredit = this.#debitCreditSubForm.debitCredit;
|
||||
this.isNeedOffset = lineItem.isNeedOffset();
|
||||
this.originalLineItemId = lineItem.getOriginalLineItemId();
|
||||
this.originalLineItemDate = lineItem.getOriginalLineItemDate();
|
||||
this.originalLineItemText = lineItem.getOriginalLineItemText();
|
||||
this.isNeedOffset = lineItem.isNeedOffset;
|
||||
this.originalLineItemId = lineItem.originalLineItemId;
|
||||
this.originalLineItemDate = lineItem.originalLineItemDate;
|
||||
this.originalLineItemText = lineItem.originalLineItemText;
|
||||
this.#originalLineItemText.innerText = this.originalLineItemText;
|
||||
if (this.originalLineItemId === null) {
|
||||
this.#originalLineItemContainer.classList.add("d-none");
|
||||
@ -533,25 +535,25 @@ class JournalEntryLineItemEditor {
|
||||
this.#originalLineItemControl.classList.add("accounting-not-empty");
|
||||
}
|
||||
this.#setEnableDescriptionAccount(!lineItem.isMatched && this.originalLineItemId === null);
|
||||
this.description = lineItem.getDescription();
|
||||
this.description = lineItem.description;
|
||||
if (this.description === null) {
|
||||
this.#descriptionControl.classList.remove("accounting-not-empty");
|
||||
} else {
|
||||
this.#descriptionControl.classList.add("accounting-not-empty");
|
||||
}
|
||||
this.#descriptionText.innerText = this.description === null? "": this.description;
|
||||
if (lineItem.getAccountCode() === null) {
|
||||
if (lineItem.accountCode === null) {
|
||||
this.#accountControl.classList.remove("accounting-not-empty");
|
||||
} else {
|
||||
this.#accountControl.classList.add("accounting-not-empty");
|
||||
}
|
||||
this.accountCode = lineItem.getAccountCode();
|
||||
this.accountText = lineItem.getAccountText();
|
||||
this.accountCode = lineItem.accountCode;
|
||||
this.accountText = lineItem.accountText;
|
||||
this.#accountText.innerText = this.accountText;
|
||||
this.#amountInput.value = lineItem.getAmount() === null? "": String(lineItem.getAmount());
|
||||
this.#amountInput.value = lineItem.amount === null? "": String(lineItem.amount);
|
||||
const maxAmount = this.#getMaxAmount();
|
||||
this.#amountInput.max = maxAmount === null? "": maxAmount;
|
||||
this.#amountInput.min = lineItem.getAmountMin() === null? "": String(lineItem.getAmountMin());
|
||||
this.#amountInput.min = lineItem.amountMin === null? "": String(lineItem.amountMin);
|
||||
this.#validate();
|
||||
}
|
||||
|
||||
|
1036
src/accounting/static/js/option-form.js
Normal file
1036
src/accounting/static/js/option-form.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -35,7 +35,7 @@ class OriginalLineItemSelector {
|
||||
lineItemEditor;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* The prefix of the HTML ID and class names
|
||||
* @type {string}
|
||||
*/
|
||||
#prefix = "accounting-original-line-item-selector";
|
||||
@ -96,9 +96,7 @@ class OriginalLineItemSelector {
|
||||
for (const option of this.#options) {
|
||||
this.#optionById[option.id] = option;
|
||||
}
|
||||
this.#query.addEventListener("input", () => {
|
||||
this.#filterOptions();
|
||||
});
|
||||
this.#query.oninput = () => this.#filterOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -113,8 +111,8 @@ class OriginalLineItemSelector {
|
||||
const otherLineItems = form.getLineItems().filter((lineItem) => lineItem !== currentLineItem);
|
||||
let otherOffset = new Decimal(0);
|
||||
for (const otherLineItem of otherLineItems) {
|
||||
if (otherLineItem.getOriginalLineItemId() === originalLineItemId) {
|
||||
const amount = otherLineItem.getAmount();
|
||||
if (otherLineItem.originalLineItemId === originalLineItemId) {
|
||||
const amount = otherLineItem.amount;
|
||||
if (amount !== null) {
|
||||
otherOffset = otherOffset.plus(amount);
|
||||
}
|
||||
@ -131,8 +129,8 @@ class OriginalLineItemSelector {
|
||||
const otherLineItems = this.lineItemEditor.form.getLineItems().filter((lineItem) => lineItem !== this.lineItemEditor.lineItem);
|
||||
const otherOffsets = {}
|
||||
for (const otherLineItem of otherLineItems) {
|
||||
const otherOriginalLineItemId = otherLineItem.getOriginalLineItemId();
|
||||
const amount = otherLineItem.getAmount();
|
||||
const otherOriginalLineItemId = otherLineItem.originalLineItemId;
|
||||
const amount = otherLineItem.amount;
|
||||
if (otherOriginalLineItemId === null || amount === null) {
|
||||
continue;
|
||||
}
|
||||
@ -155,16 +153,16 @@ class OriginalLineItemSelector {
|
||||
*
|
||||
*/
|
||||
#filterOptions() {
|
||||
let hasAnyMatched = false;
|
||||
let isAnyMatched = false;
|
||||
for (const option of this.#options) {
|
||||
if (option.isMatched(this.#debitCredit, this.#currencyCode, this.#query.value)) {
|
||||
option.setShown(true);
|
||||
hasAnyMatched = true;
|
||||
isAnyMatched = true;
|
||||
} else {
|
||||
option.setShown(false);
|
||||
}
|
||||
}
|
||||
if (!hasAnyMatched) {
|
||||
if (!isAnyMatched) {
|
||||
this.#optionList.classList.add("d-none");
|
||||
this.#queryNoResult.classList.remove("d-none");
|
||||
} else {
|
||||
@ -178,7 +176,7 @@ class OriginalLineItemSelector {
|
||||
*
|
||||
*/
|
||||
onOpen() {
|
||||
this.#currencyCode = this.lineItemEditor.getCurrencyCode();
|
||||
this.#currencyCode = this.lineItemEditor.currencyCode;
|
||||
this.#debitCredit = this.lineItemEditor.debitCredit;
|
||||
for (const option of this.#options) {
|
||||
option.setActive(option.id === this.lineItemEditor.originalLineItemId);
|
||||
@ -275,7 +273,7 @@ class OriginalLineItem {
|
||||
|
||||
/**
|
||||
* The values to query against
|
||||
* @type {string[][]}
|
||||
* @type {string[]}
|
||||
*/
|
||||
#queryValues;
|
||||
|
||||
@ -341,10 +339,10 @@ class OriginalLineItem {
|
||||
*/
|
||||
isMatched(debitCredit, currencyCode, query = null) {
|
||||
return this.netBalance.greaterThan(0)
|
||||
&& this.date <= this.#selector.lineItemEditor.form.getDate()
|
||||
&& this.#isDebitCreditMatches(debitCredit)
|
||||
&& this.date <= this.#selector.lineItemEditor.form.date
|
||||
&& this.#isDebitCreditMatched(debitCredit)
|
||||
&& this.#currencyCode === currencyCode
|
||||
&& this.#isQueryMatches(query);
|
||||
&& this.#isQueryMatched(query);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -353,34 +351,43 @@ class OriginalLineItem {
|
||||
* @param debitCredit {string} either "debit" or credit
|
||||
* @return {boolean} true if the option matches, or false otherwise
|
||||
*/
|
||||
#isDebitCreditMatches(debitCredit) {
|
||||
#isDebitCreditMatched(debitCredit) {
|
||||
return (debitCredit === "debit" && this.#debitCredit === "credit")
|
||||
|| (debitCredit === "credit" && this.#debitCredit === "debit");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the original line item matches the query.
|
||||
* Returns whether the original line item matches the query term.
|
||||
*
|
||||
* @param query {string|null} the query term
|
||||
* @return {boolean} true if the option matches, or false otherwise
|
||||
*/
|
||||
#isQueryMatches(query) {
|
||||
#isQueryMatched(query) {
|
||||
if (query === "") {
|
||||
return true;
|
||||
}
|
||||
for (const queryValue of this.#queryValues[0]) {
|
||||
if (this.#getNetBalanceForQuery().includes(query.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
for (const queryValue of this.#queryValues) {
|
||||
if (queryValue.toLowerCase().includes(query.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const queryValue of this.#queryValues[1]) {
|
||||
if (queryValue === query) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the net balance in the format for query match.
|
||||
*
|
||||
* @return {string} the net balance in the format for query match
|
||||
*/
|
||||
#getNetBalanceForQuery() {
|
||||
const frac = this.netBalance.modulo(1);
|
||||
const whole = Number(this.netBalance.minus(frac));
|
||||
return String(whole) + String(frac).substring(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the option is shown.
|
||||
*
|
||||
|
@ -33,12 +33,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
*/
|
||||
class PeriodChooser {
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* @type {string}
|
||||
*/
|
||||
prefix;
|
||||
|
||||
/**
|
||||
* The modal of the period chooser
|
||||
* @type {HTMLDivElement}
|
||||
@ -56,8 +50,8 @@ class PeriodChooser {
|
||||
*
|
||||
*/
|
||||
constructor() {
|
||||
this.prefix = "accounting-period-chooser";
|
||||
this.modal = document.getElementById(this.prefix + "-modal");
|
||||
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;
|
||||
@ -94,7 +88,7 @@ class TabPlane {
|
||||
chooser;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* The prefix of the HTML ID and class names
|
||||
* @type {string}
|
||||
*/
|
||||
prefix;
|
||||
|
@ -17,9 +17,8 @@
|
||||
"""The template globals.
|
||||
|
||||
"""
|
||||
from flask import current_app
|
||||
|
||||
from accounting.models import Currency
|
||||
from accounting.utils.options import options
|
||||
|
||||
|
||||
def currency_options() -> str:
|
||||
@ -35,4 +34,4 @@ def default_currency_code() -> str:
|
||||
|
||||
:return: The default currency code.
|
||||
"""
|
||||
return current_app.config.get("ACCOUNTING_DEFAULT_CURRENCY", "USD")
|
||||
return options.default_currency_code
|
||||
|
@ -26,17 +26,17 @@ First written: 2023/1/31
|
||||
{% block content %}
|
||||
|
||||
<div class="mb-3 accounting-toolbar">
|
||||
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
|
||||
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
|
||||
<i class="fa-solid fa-circle-chevron-left"></i>
|
||||
{{ A_("Back") }}
|
||||
<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" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
|
||||
<a class="btn btn-primary d-none d-md-inline" role="button" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
{{ A_("Edit") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.order", base=obj.base)|accounting_append_next }}">
|
||||
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.order", base=obj.base)|accounting_append_next }}">
|
||||
<i class="fa-solid fa-bars-staggered"></i>
|
||||
<span class="d-none d-md-inline">{{ A_("Order") }}</span>
|
||||
</a>
|
||||
@ -57,7 +57,7 @@ First written: 2023/1/31
|
||||
|
||||
{% if accounting_can_edit() %}
|
||||
<div class="d-md-none accounting-material-fab">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
|
||||
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
@ -27,10 +27,10 @@ First written: 2023/2/1
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="mb-3 d-none d-md-block">
|
||||
<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>
|
||||
{{ A_("Back") }}
|
||||
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@ -101,7 +101,7 @@ First written: 2023/2/1
|
||||
|
||||
<ul id="accounting-base-selector-option-list" class="list-group accounting-selector-list">
|
||||
{% for base in form.base_options %}
|
||||
<li class="list-group-item accounting-clickable accounting-base-selector-option" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}" data-bs-dismiss="modal">
|
||||
<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">
|
||||
{{ base }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
@ -27,7 +27,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" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
|
||||
<a class="btn btn-primary text-nowrap d-none d-md-block" role="button" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
{{ A_("New") }}
|
||||
</a>
|
||||
@ -45,7 +45,7 @@ First written: 2023/1/30
|
||||
|
||||
{% if accounting_can_edit() %}
|
||||
<div class="d-md-none accounting-material-fab">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
|
||||
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
@ -30,10 +30,10 @@ First written: 2023/2/2
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="mb-3 d-none d-md-block">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
|
||||
<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>
|
||||
{{ A_("Back") }}
|
||||
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
@ -25,10 +25,10 @@ First written: 2023/2/1
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="mb-3 d-none d-md-block">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
|
||||
<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>
|
||||
{{ A_("Back") }}
|
||||
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@ -38,7 +38,7 @@ First written: 2023/2/1
|
||||
{% if obj.accounts %}
|
||||
<div>
|
||||
{% for account in obj.accounts %}
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.detail", account=account)|accounting_append_next }}">
|
||||
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.detail", account=account)|accounting_append_next }}">
|
||||
{{ account }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
@ -26,12 +26,12 @@ First written: 2023/2/6
|
||||
{% block content %}
|
||||
|
||||
<div class="mb-3 accounting-toolbar">
|
||||
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.currency.list")|accounting_or_next }}">
|
||||
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.list")|accounting_or_next }}">
|
||||
<i class="fa-solid fa-circle-chevron-left"></i>
|
||||
{{ A_("Back") }}
|
||||
<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" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
|
||||
<a class="btn btn-primary d-none d-md-inline" role="button" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
{{ A_("Edit") }}
|
||||
</a>
|
||||
@ -53,7 +53,7 @@ First written: 2023/2/6
|
||||
|
||||
{% if accounting_can_edit() %}
|
||||
<div class="d-md-none accounting-material-fab">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
|
||||
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
@ -27,10 +27,10 @@ First written: 2023/2/6
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="mb-3 d-none d-md-block">
|
||||
<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>
|
||||
{{ A_("Back") }}
|
||||
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
@ -27,7 +27,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" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
|
||||
<a class="btn btn-primary text-nowrap d-none d-md-block" role="button" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
{{ A_("New") }}
|
||||
</a>
|
||||
@ -45,7 +45,7 @@ First written: 2023/2/6
|
||||
|
||||
{% if accounting_can_edit() %}
|
||||
<div class="d-md-none accounting-material-fab">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
|
||||
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
@ -51,6 +51,14 @@ First written: 2023/1/26
|
||||
{{ 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>
|
||||
{{ A_("Settings") }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
@ -22,7 +22,7 @@ First written: 2023/2/26
|
||||
{% extends "accounting/journal-entry/include/detail.html" %}
|
||||
|
||||
{% block as_trasfer %}
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
|
||||
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
|
||||
<i class="fa-solid fa-table-columns"></i>
|
||||
<span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
|
||||
</a>
|
||||
|
@ -37,7 +37,7 @@ First written: 2023/2/25
|
||||
|
||||
<ul id="accounting-account-selector-{{ debit_credit }}-option-list" class="list-group accounting-selector-list">
|
||||
{% 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-in-use {% endif %} {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" data-code="{{ account.code }}" data-content="{{ 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 %}" data-code="{{ account.code }}" 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 %}
|
||||
|
@ -158,7 +158,7 @@ First written: 2023/2/28
|
||||
<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 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.template }}" data-accounts="{{ recurring.account_codes|tojson|forceescape }}">
|
||||
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-recurring-item" type="button" tabindex="-1" data-template="{{ recurring.description_template }}" data-accounts="{{ recurring.account_codes|tojson|forceescape }}">
|
||||
{{ recurring.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
|
@ -26,17 +26,17 @@ First written: 2023/2/26
|
||||
{% block content %}
|
||||
|
||||
<div class="mb-3 accounting-toolbar">
|
||||
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.report.default")|accounting_or_next }}">
|
||||
<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>
|
||||
{{ A_("Back") }}
|
||||
<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" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}">
|
||||
<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>
|
||||
{{ A_("Edit") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.order", journal_entry_date=obj.date)|accounting_append_next }}">
|
||||
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.order", journal_entry_date=obj.date)|accounting_append_next }}">
|
||||
<i class="fa-solid fa-bars-staggered"></i>
|
||||
<span class="d-none d-md-inline">{{ A_("Order") }}</span>
|
||||
</a>
|
||||
@ -58,7 +58,7 @@ First written: 2023/2/26
|
||||
|
||||
{% if accounting_can_edit() %}
|
||||
<div class="d-md-none accounting-material-fab">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}">
|
||||
<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>
|
||||
</div>
|
||||
|
@ -20,29 +20,31 @@ 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 accounting-not-empty {% if debit_errors %} is-invalid {% endif %}">
|
||||
<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>
|
||||
<ul id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-list" class="list-group accounting-line-item-list">
|
||||
{% for line_item_form in line_item_forms %}
|
||||
{% with currency_index = currency_index,
|
||||
line_item_index = loop.index,
|
||||
only_one_line_item_form = line_item_forms|length == 1,
|
||||
form = line_item_form.form %}
|
||||
{% include "accounting/journal-entry/include/form-line-item.html" %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<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 %}
|
||||
{% with currency_index = currency_index,
|
||||
line_item_index = loop.index,
|
||||
only_one_line_item_form = line_item_forms|length == 1,
|
||||
form = line_item_form.form %}
|
||||
{% include "accounting/journal-entry/include/form-line-item.html" %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<div>{{ A_("Total") }}</div>
|
||||
<div><span id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-total" class="badge rounded-pill bg-primary">{{ debit_credit_total }}</span></div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{{ A_("New") }}
|
||||
</button>
|
||||
<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>
|
||||
{{ 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>
|
||||
|
@ -25,14 +25,14 @@ First written: 2023/2/26
|
||||
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/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/account-selector.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/description-editor.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="mb-3 d-none d-md-block">
|
||||
<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>
|
||||
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
|
||||
|
@ -21,7 +21,6 @@ First written: 2023/3/21
|
||||
#}
|
||||
<a class="small w-100 accounting-journal-entry-order-item" href="{{ url_for("accounting.journal-entry.detail", journal_entry=journal_entry)|accounting_append_next }}">
|
||||
<div>
|
||||
{{ journal_entry.date|accounting_format_date }}
|
||||
{% if journal_entry.is_cash_disbursement %}
|
||||
{{ A_("Cash Disbursement") }}
|
||||
{% elif journal_entry.is_cash_receipt %}
|
||||
|
@ -26,14 +26,14 @@ First written: 2023/2/26
|
||||
<script src="{{ url_for("accounting.static", filename="js/journal-entry-order.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Journal Entries on %(date)s", date=date) }}{% endblock %}{% endblock %}
|
||||
{% block header %}{% block title %}{{ A_("Journal Entries on %(date)s", date=date|accounting_format_date) }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="mb-3 d-none d-md-block">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.report.default")|accounting_or_next }}">
|
||||
<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>
|
||||
{{ A_("Back") }}
|
||||
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
@ -22,7 +22,7 @@ First written: 2023/2/26
|
||||
{% extends "accounting/journal-entry/include/detail.html" %}
|
||||
|
||||
{% block as_trasfer %}
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
|
||||
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
|
||||
<i class="fa-solid fa-bars-staggered"></i>
|
||||
<span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
|
||||
</a>
|
||||
|
86
src/accounting/templates/accounting/option/detail.html
Normal file
86
src/accounting/templates/accounting/option/detail.html
Normal file
@ -0,0 +1,86 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
detail.html: The option detail
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/22
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Settings") }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<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>
|
||||
{{ 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>
|
||||
</div>
|
||||
|
||||
<table class="table table-striped table-hover table-light" aria-label="{{ A_("Settings") }}">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">{{ A_("Default Currency") }}</th>
|
||||
<td>{{ obj.default_currency_text }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{{ A_("Default Account for the Income and Expenses Log") }}</th>
|
||||
<td>{{ obj.default_ie_account_code_text }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>{{ A_("Recurring Expense") }}</h2>
|
||||
|
||||
{% if obj.recurring.expenses %}
|
||||
<ul class="list-group mb-3 accounting-list-group-stripped accounting-list-group-hover">
|
||||
{% for recurring_item in obj.recurring.expenses %}
|
||||
<li class="list-group-item">
|
||||
<div class="small">{{ recurring_item.account_text }}</div>
|
||||
<div>{{ recurring_item.name }}</div>
|
||||
<div class="small">{{ recurring_item.description_template }}</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>{{ A_("There is no data.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
<h2>{{ A_("Recurring Income") }}</h2>
|
||||
|
||||
{% if obj.recurring.incomes %}
|
||||
<ul class="list-group mb-3 accounting-list-group-stripped accounting-list-group-hover">
|
||||
{% for recurring_item in obj.recurring.incomes %}
|
||||
<li class="list-group-item">
|
||||
<div class="small">{{ recurring_item.account_text }}</div>
|
||||
<div>{{ recurring_item.name }}</div>
|
||||
<div class="small">{{ recurring_item.description_template }}</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>{{ A_("There is no data.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
109
src/accounting/templates/accounting/option/form.html
Normal file
109
src/accounting/templates/accounting/option/form.html
Normal file
@ -0,0 +1,109 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
form.html: The option form
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/22
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block accounting_scripts %}
|
||||
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/option-form.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Settings") }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<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>
|
||||
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form id="accounting-form" action="{{ url_for("accounting.option.update") }}" method="post" data-recurring-item-template="{{ form.recurring.item_template }}">
|
||||
{{ form.csrf_token }}
|
||||
{% if request.args.next %}
|
||||
<input type="hidden" name="next" value="{{ request.args.next }}">
|
||||
{% endif %}
|
||||
<div class="form-floating mb-3">
|
||||
<select id="accounting-default-currency" class="form-select {% if form.default_currency_code.errors %} is-invalid {% endif %}" name="default_currency_code">
|
||||
{% for currency in accounting_currency_options() %}
|
||||
<option value="{{ currency.code }}" {% if currency.code == form.default_currency_code.data %} selected="selected" {% endif %}>{{ currency }}</option>
|
||||
{% 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>
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
<select id="accounting-default-ie-account" class="form-select {% if form.default_ie_account_code.errors %} is-invalid {% endif %}" name="default_ie_account_code">
|
||||
{% for account in form.current_accounts %}
|
||||
<option value="{{ account.code }}" {% if account.code == form.default_ie_account_code.data %} selected="selected" {% endif %}>{{ account }}</option>
|
||||
{% 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>
|
||||
|
||||
{% with expense_income = "expense",
|
||||
label = A_("Recurring Expense"),
|
||||
recurring_items = form.recurring.expenses %}
|
||||
{% include "accounting/option/include/form-recurring-expense-income.html" %}
|
||||
{% endwith %}
|
||||
|
||||
{% with expense_income = "income",
|
||||
label = A_("Recurring Income"),
|
||||
recurring_items = form.recurring.incomes %}
|
||||
{% include "accounting/option/include/form-recurring-expense-income.html" %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="d-none d-md-block">
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<i class="fa-solid fa-floppy-disk"></i>
|
||||
{{ A_("Save") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="d-md-none accounting-material-fab">
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<i class="fa-solid fa-floppy-disk"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% with expense_income = "expense",
|
||||
title = A_("Recurring Expense") %}
|
||||
{% include "accounting/option/include/recurring-item-editor-modal.html" %}
|
||||
{% endwith %}
|
||||
{% with expense_income = "income",
|
||||
title = A_("Recurring Income") %}
|
||||
{% include "accounting/option/include/recurring-item-editor-modal.html" %}
|
||||
{% endwith %}
|
||||
|
||||
{% with expense_income = "expense",
|
||||
accounts = form.recurring.expense_accounts %}
|
||||
{% include "accounting/option/include/recurring-account-selector-modal.html" %}
|
||||
{% endwith %}
|
||||
{% with expense_income = "income",
|
||||
accounts = form.recurring.income_accounts %}
|
||||
{% include "accounting/option/include/recurring-account-selector-modal.html" %}
|
||||
{% endwith %}
|
||||
|
||||
{% endblock %}
|
@ -0,0 +1,41 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
form-recurring-expense-income.html: The recurring expense or income sub-form in the option form
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/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 }}-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 %}
|
||||
{% with form = recurring_item.form,
|
||||
item_index = loop.index %}
|
||||
{% include "accounting/option/include/form-recurring-item.html" %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<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>
|
||||
{{ A_("New") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,45 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
form-recurring-item.html: The recurring item sub-form in the option form
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/22
|
||||
#}
|
||||
{# <ul> For SonarQube not to complain about incorrect HTML #}
|
||||
<li id="accounting-recurring-{{ expense_income }}-{{ item_index }}" class="list-group-item list-group-item-action accounting-recurring-{{ expense_income }}-item" data-item-index="{{ item_index }}">
|
||||
<input id="accounting-recurring-{{ expense_income }}-{{ item_index }}-no" type="hidden" name="recurring-{{ expense_income }}-{{ item_index }}-no" value="{{ item_index }}">
|
||||
<input id="accounting-recurring-{{ expense_income }}-{{ item_index }}-name" type="hidden" name="recurring-{{ expense_income }}-{{ item_index }}-name" value="{{ form.name.data|accounting_default }}">
|
||||
<input id="accounting-recurring-{{ expense_income }}-{{ item_index }}-account-code" type="hidden" name="recurring-{{ expense_income }}-{{ item_index }}-account_code" value="{{ form.account_code.data|accounting_default }}" data-text="{{ form.account_text|accounting_default }}">
|
||||
<input id="accounting-recurring-{{ expense_income }}-{{ item_index }}-description-template" type="hidden" name="recurring-{{ expense_income }}-{{ item_index }}-description_template" value="{{ form.description_template.data|accounting_default }}">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div class="w-100">
|
||||
<div id="accounting-recurring-{{ expense_income }}-{{ item_index }}-control" class="form-control accounting-clickable {% if form.all_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-recurring-item-editor-{{ expense_income }}-modal">
|
||||
<div id="accounting-recurring-{{ expense_income }}-{{ item_index }}-account-text" class="small">{{ form.account_text|accounting_default }}</div>
|
||||
<div id="accounting-recurring-{{ expense_income }}-{{ item_index }}-name-text">{{ form.name.data|accounting_default }}</div>
|
||||
<div id="accounting-recurring-{{ expense_income }}-{{ item_index }}-description-template-text" class="small">{{ form.description_template.data|accounting_default }}</div>
|
||||
</div>
|
||||
<div id="accounting-recurring-{{ expense_income }}-{{ item_index }}-error" class="invalid-feedback">{% if form.all_errors %}{{ form.all_errors[0] }}{% endif %}</div>
|
||||
</div>
|
||||
|
||||
<div class="ms-2">
|
||||
<button id="accounting-recurring-{{ expense_income }}-{{ item_index }}-delete" class="btn btn-danger rounded-circle" type="button">
|
||||
<i class="fas fa-minus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{# </ul> For SonarQube not to complain about incorrect HTML #}
|
@ -0,0 +1,53 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
recurring-account-selector-modal.html: The modal of the account selector for the recurring item editor
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/22
|
||||
#}
|
||||
<div id="accounting-recurring-accounting-selector-{{ expense_income }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-recurring-accounting-selector-{{ expense_income }}-modal-label" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="accounting-recurring-accounting-selector-{{ expense_income }}-modal-label">{{ A_("Select Account") }}</h1>
|
||||
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-recurring-item-editor-{{ expense_income }}-modal" aria-label="{{ A_("Close") }}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="input-group mb-2">
|
||||
<input id="accounting-recurring-accounting-selector-{{ expense_income }}-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
|
||||
<label class="input-group-text" for="accounting-recurring-accounting-selector-{{ expense_income }}-query">
|
||||
<i class="fa-solid fa-magnifying-glass"></i>
|
||||
{{ A_("Search") }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ul id="accounting-recurring-accounting-selector-{{ expense_income }}-option-list" class="list-group accounting-selector-list">
|
||||
{% for account in accounts %}
|
||||
<li id="accounting-recurring-accounting-selector-{{ expense_income }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-recurring-accounting-selector-{{ expense_income }}-option" data-code="{{ account.code }}" data-text="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-recurring-item-editor-{{ expense_income }}-modal">
|
||||
{{ account }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p id="accounting-recurring-accounting-selector-{{ expense_income }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-recurring-item-editor-{{ expense_income }}-modal">{{ A_("Cancel") }}</button>
|
||||
<button id="accounting-recurring-accounting-selector-{{ expense_income }}-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-recurring-item-editor-{{ expense_income }}-modal">{{ A_("Clear") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,71 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
recurring-item-editor-modal.html: The modal of the recurring item editor
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/22
|
||||
#}
|
||||
<form id="accounting-recurring-item-editor-{{ expense_income }}">
|
||||
<div id="accounting-recurring-item-editor-{{ expense_income }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-recurring-item-editor-{{ expense_income }}-modal-label" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="accounting-recurring-item-editor-{{ expense_income }}-modal-label">{{ title }}</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-floating mb-3">
|
||||
<input id="accounting-recurring-item-editor-{{ expense_income }}-name" class="form-control" type="text" value="" placeholder=" " required="required">
|
||||
<label for="accounting-recurring-item-editor-{{ expense_income }}-name">{{ A_("Name") }}</label>
|
||||
<div id="accounting-recurring-item-editor-{{ expense_income }}-name-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div id="accounting-recurring-item-editor-{{ expense_income }}-account-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="#accounting-recurring-accounting-selector-{{ expense_income }}-modal">
|
||||
<label class="form-label" for="accounting-recurring-item-editor-{{ expense_income }}-account">{{ A_("Account") }}</label>
|
||||
<div id="accounting-recurring-item-editor-{{ expense_income }}-account"></div>
|
||||
</div>
|
||||
<div id="accounting-recurring-item-editor-{{ expense_income }}-account-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
<input id="accounting-recurring-item-editor-{{ expense_income }}-description-template" class="form-control" type="text" value="" placeholder=" " required="required">
|
||||
<label for="accounting-recurring-item-editor-{{ expense_income }}-description-template">{{ A_("Description Template") }}</label>
|
||||
<div id="accounting-recurring-item-editor-{{ expense_income }}-description-template-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 border-top accounting-recurring-description-template-illustration">
|
||||
<p>{{ A_("Available template variables:") }}</p>
|
||||
<ul>
|
||||
<li><code>{this_month_number}</code>: {{ A_("This month, as a number.") }}</li>
|
||||
<li><code>{this_month_name}</code>: {{ A_("This month, in its name.") }}</li>
|
||||
<li><code>{last_month_number}</code>: {{ A_("Last month, as a number.") }}</li>
|
||||
<li><code>{last_month_name}</code>: {{ A_("Last month, in its name.") }}</li>
|
||||
<li><code>{last_bimonthly_number}</code>: {{ A_("The previous bimonthly period, as numbers.") }}</li>
|
||||
<li><code>{last_bimonthly_name}</code>: {{ A_("The previous bimonthly period, as their names.") }}</li>
|
||||
</ul>
|
||||
<p>{{ A_("Example:") }} <code>{{ A_("Water bill for {last_bimonthly_name}") }}</code></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
|
||||
<button type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
@ -22,13 +22,13 @@ First written: 2023/2/25
|
||||
{% if accounting_can_edit() %}
|
||||
<div id="accounting-material-fab-speed-dial" class="d-md-none accounting-material-fab">
|
||||
<div id="accounting-material-fab-speed-dial-actions" class="d-md-none accounting-material-fab-speed-dial-group">
|
||||
<a class="btn rounded-pill" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_DISBURSEMENT)|accounting_append_next }}">
|
||||
<a class="btn rounded-pill" role="button" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_DISBURSEMENT)|accounting_append_next }}">
|
||||
{{ A_("Cash Disbursement") }}
|
||||
</a>
|
||||
<a class="btn rounded-pill" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_RECEIPT)|accounting_append_next }}">
|
||||
<a class="btn rounded-pill" role="button" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_RECEIPT)|accounting_append_next }}">
|
||||
{{ A_("Cash Receipt") }}
|
||||
</a>
|
||||
<a class="btn rounded-pill" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.TRANSFER)|accounting_append_next }}">
|
||||
<a class="btn rounded-pill" role="button" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.TRANSFER)|accounting_append_next }}">
|
||||
{{ A_("Transfer") }}
|
||||
</a>
|
||||
</div>
|
||||
|
@ -8,8 +8,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
|
||||
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
|
||||
"POT-Creation-Date: 2023-03-22 01:47+0800\n"
|
||||
"PO-Revision-Date: 2023-03-22 01:47+0800\n"
|
||||
"POT-Creation-Date: 2023-03-23 00:45+0800\n"
|
||||
"PO-Revision-Date: 2023-03-23 00:46+0800\n"
|
||||
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
|
||||
"Language: zh_Hant\n"
|
||||
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
|
||||
@ -19,22 +19,38 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.12.1\n"
|
||||
|
||||
#: src/accounting/models.py:559
|
||||
#: src/accounting/forms.py:33
|
||||
#: src/accounting/static/js/journal-entry-form.js:980
|
||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:430
|
||||
#: src/accounting/static/js/option-form.js:530
|
||||
#: src/accounting/static/js/option-form.js:796
|
||||
msgid "Please select the account."
|
||||
msgstr "請選擇科目。"
|
||||
|
||||
#: src/accounting/forms.py:44
|
||||
msgid "The currency does not exist."
|
||||
msgstr "沒有這個貨幣。"
|
||||
|
||||
#: src/accounting/forms.py:55 src/accounting/option/forms.py:42
|
||||
msgid "The account does not exist."
|
||||
msgstr "沒有這個科目。"
|
||||
|
||||
#: src/accounting/models.py:563
|
||||
#, python-format
|
||||
msgid "Cash Disbursement Journal Entry#%(id)s"
|
||||
msgstr "現金支出傳票#%(id)s"
|
||||
|
||||
#: src/accounting/models.py:562
|
||||
#: src/accounting/models.py:566
|
||||
#, python-format
|
||||
msgid "Cash Receipt Journal Entry#%(id)s"
|
||||
msgstr "現金收入傳票#%(id)s"
|
||||
|
||||
#: src/accounting/models.py:563
|
||||
#: src/accounting/models.py:567
|
||||
#, python-format
|
||||
msgid "Transfer Journal Entry#%(id)s"
|
||||
msgstr "轉帳傳票#%(id)s"
|
||||
|
||||
#: src/accounting/models.py:696
|
||||
#: src/accounting/models.py:700
|
||||
#, python-format
|
||||
msgid "%(date)s %(description)s %(amount)s"
|
||||
msgstr "%(date)s %(description)s %(amount)s"
|
||||
@ -141,8 +157,11 @@ msgstr "代碼限為三個大寫英文字母。"
|
||||
msgid "This code is not available."
|
||||
msgstr "不能用這個代碼。"
|
||||
|
||||
#: src/accounting/currency/forms.py:62
|
||||
#: src/accounting/currency/forms.py:62 src/accounting/option/forms.py:124
|
||||
#: src/accounting/option/forms.py:148
|
||||
#: src/accounting/static/js/currency-form.js:153
|
||||
#: src/accounting/static/js/option-form.js:525
|
||||
#: src/accounting/static/js/option-form.js:780
|
||||
msgid "Please fill in the name."
|
||||
msgstr "請填上名稱。"
|
||||
|
||||
@ -186,28 +205,24 @@ msgstr "傳票不可刪除。"
|
||||
msgid "The journal entry is deleted successfully."
|
||||
msgstr "傳票刪掉了"
|
||||
|
||||
#: src/accounting/journal_entry/forms/currency.py:38
|
||||
#: src/accounting/journal_entry/forms/currency.py:40
|
||||
msgid "Please select the currency."
|
||||
msgstr "請選擇貨幣。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/currency.py:49
|
||||
msgid "The currency does not exist."
|
||||
msgstr "沒有這個貨幣。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/currency.py:72
|
||||
#: src/accounting/journal_entry/forms/currency.py:63
|
||||
msgid "The currency must be the same as the original line item."
|
||||
msgstr "貨幣需和原始分錄相同。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/currency.py:99
|
||||
#: src/accounting/journal_entry/forms/currency.py:90
|
||||
msgid "The currency must not be changed when there is offset."
|
||||
msgstr "抵銷過不可變更貨幣。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/currency.py:108
|
||||
#: src/accounting/journal_entry/forms/currency.py:99
|
||||
#: src/accounting/static/js/journal-entry-form.js:729
|
||||
msgid "Please add some line items."
|
||||
msgstr "請加上分錄。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/currency.py:121
|
||||
#: src/accounting/journal_entry/forms/currency.py:112
|
||||
#: src/accounting/static/js/journal-entry-form.js:516
|
||||
msgid "The totals of the debit and credit amounts do not match."
|
||||
msgstr "借方貸方合計不符。 "
|
||||
@ -236,62 +251,44 @@ msgstr "請加上貨幣。"
|
||||
msgid "Line items with offset cannot be deleted."
|
||||
msgstr "無法刪除抵銷過的分錄。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:41
|
||||
#: src/accounting/static/js/journal-entry-form.js:980
|
||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:430
|
||||
msgid "Please select the account."
|
||||
msgstr "請選擇科目。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:52
|
||||
#: src/accounting/journal_entry/forms/line_item.py:49
|
||||
msgid "The original line item does not exist."
|
||||
msgstr "沒有這筆原始分錄。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:73
|
||||
#: src/accounting/journal_entry/forms/line_item.py:70
|
||||
msgid "The original line item is on the same debit or credit."
|
||||
msgstr "原始分錄在借貸同一邊。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:88
|
||||
#: src/accounting/journal_entry/forms/line_item.py:85
|
||||
msgid "The original line item does not need offset."
|
||||
msgstr "這筆原始分錄不需抵銷。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:104
|
||||
#: src/accounting/journal_entry/forms/line_item.py:101
|
||||
msgid "The original line item cannot be an offset item."
|
||||
msgstr "原始分錄不可以是抵銷分錄。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:115
|
||||
msgid "The account does not exist."
|
||||
msgstr "沒有這個科目。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:129
|
||||
msgid "This account is not for debit line items."
|
||||
msgstr "科目不是借方科目。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:143
|
||||
msgid "This account is not for credit line items."
|
||||
msgstr "科目不是貸方科目。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:161
|
||||
#: src/accounting/journal_entry/forms/line_item.py:119
|
||||
msgid "The account must be the same as the original line item."
|
||||
msgstr "科目需和原始分錄相同。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:179
|
||||
#: src/accounting/journal_entry/forms/line_item.py:137
|
||||
msgid "The account must not be changed when there is offset."
|
||||
msgstr "抵銷過不可變更科目。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:195
|
||||
#: src/accounting/journal_entry/forms/line_item.py:153
|
||||
msgid "A payable line item cannot start from debit."
|
||||
msgstr "不可由借方新建應付款。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:211
|
||||
#: src/accounting/journal_entry/forms/line_item.py:169
|
||||
msgid "A receivable line item cannot start from credit."
|
||||
msgstr "不可由貸方新建應收款。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:222
|
||||
#: src/accounting/journal_entry/forms/line_item.py:180
|
||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:455
|
||||
msgid "Please fill in a positive amount."
|
||||
msgstr "金額請填正數。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:264
|
||||
#: src/accounting/journal_entry/forms/line_item.py:222
|
||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:461
|
||||
#, python-format
|
||||
msgid ""
|
||||
@ -299,12 +296,64 @@ msgid ""
|
||||
"line item."
|
||||
msgstr "金額不可超過原始分錄凈額 %(balance)s 。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:285
|
||||
#: src/accounting/journal_entry/forms/line_item.py:243
|
||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:469
|
||||
#, python-format
|
||||
msgid "The amount must not be less than the offset total %(total)s."
|
||||
msgstr "金額不可低於抵銷總額 %(total)s 。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:416
|
||||
msgid "This account is not for debit line items."
|
||||
msgstr "科目不是借方科目。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:468
|
||||
msgid "This account is not for credit line items."
|
||||
msgstr "科目不是貸方科目。"
|
||||
|
||||
#: src/accounting/option/forms.py:53
|
||||
msgid "This is not a current account."
|
||||
msgstr "這不是流動科目。"
|
||||
|
||||
#: src/accounting/option/forms.py:66
|
||||
msgid "You cannot select a payable account as expense."
|
||||
msgstr "支出不能選應付科目。"
|
||||
|
||||
#: src/accounting/option/forms.py:79
|
||||
msgid "You cannot select a receivable account as income."
|
||||
msgstr "收入不能選應收科目。"
|
||||
|
||||
#: src/accounting/option/forms.py:131
|
||||
msgid "This account is not for expense."
|
||||
msgstr "科目不是支出科目。"
|
||||
|
||||
#: src/accounting/option/forms.py:137 src/accounting/option/forms.py:161
|
||||
#: src/accounting/static/js/option-form.js:535
|
||||
#: src/accounting/static/js/option-form.js:813
|
||||
msgid "Please fill in the description template."
|
||||
msgstr "請填上摘要範本。"
|
||||
|
||||
#: src/accounting/option/forms.py:155
|
||||
msgid "This account is not for income."
|
||||
msgstr "科目不是收入科目。"
|
||||
|
||||
#: src/accounting/option/forms.py:238
|
||||
#: src/accounting/static/js/option-form.js:136
|
||||
msgid "Please select the default currency."
|
||||
msgstr "請選擇預設貨幣。"
|
||||
|
||||
#: src/accounting/option/forms.py:244
|
||||
#: src/accounting/static/js/option-form.js:152
|
||||
msgid "Please select the default account for the income and expenses log."
|
||||
msgstr "請選擇收支帳的預設科目。"
|
||||
|
||||
#: src/accounting/option/views.py:79
|
||||
msgid "The settings were not modified."
|
||||
msgstr "設定未異動。"
|
||||
|
||||
#: src/accounting/option/views.py:82
|
||||
msgid "The settings are saved successfully."
|
||||
msgstr "設定存好了。"
|
||||
|
||||
#: src/accounting/report/period/description.py:33
|
||||
msgid "for all time"
|
||||
msgstr "全部"
|
||||
@ -363,8 +412,8 @@ msgstr "全部"
|
||||
#: src/accounting/report/reports/balance_sheet.py:426
|
||||
#: src/accounting/report/reports/balance_sheet.py:438
|
||||
#: src/accounting/report/reports/balance_sheet.py:440
|
||||
#: src/accounting/report/reports/income_expenses.py:192
|
||||
#: src/accounting/report/reports/income_expenses.py:429
|
||||
#: src/accounting/report/reports/income_expenses.py:189
|
||||
#: src/accounting/report/reports/income_expenses.py:423
|
||||
#: src/accounting/report/reports/income_statement.py:299
|
||||
#: src/accounting/report/reports/ledger.py:171
|
||||
#: src/accounting/report/reports/ledger.py:380
|
||||
@ -392,7 +441,7 @@ msgstr "合計"
|
||||
msgid "Brought forward"
|
||||
msgstr "前期轉入"
|
||||
|
||||
#: src/accounting/report/reports/income_expenses.py:413
|
||||
#: src/accounting/report/reports/income_expenses.py:407
|
||||
#: src/accounting/report/reports/journal.py:155
|
||||
#: src/accounting/report/reports/ledger.py:366
|
||||
#: src/accounting/templates/accounting/journal-entry/include/form.html:50
|
||||
@ -404,10 +453,11 @@ msgstr "前期轉入"
|
||||
msgid "Date"
|
||||
msgstr "日期"
|
||||
|
||||
#: src/accounting/report/reports/income_expenses.py:413
|
||||
#: src/accounting/report/reports/income_expenses.py:407
|
||||
#: src/accounting/report/reports/journal.py:156
|
||||
#: src/accounting/report/reports/trial_balance.py:224
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:57
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:39
|
||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:90
|
||||
#: src/accounting/templates/accounting/report/income-expenses.html:56
|
||||
#: src/accounting/templates/accounting/report/journal.html:55
|
||||
@ -416,7 +466,7 @@ msgstr "日期"
|
||||
msgid "Account"
|
||||
msgstr "科目"
|
||||
|
||||
#: src/accounting/report/reports/income_expenses.py:414
|
||||
#: src/accounting/report/reports/income_expenses.py:408
|
||||
#: src/accounting/report/reports/journal.py:156
|
||||
#: src/accounting/report/reports/ledger.py:366
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:28
|
||||
@ -428,24 +478,24 @@ msgstr "科目"
|
||||
msgid "Description"
|
||||
msgstr "摘要"
|
||||
|
||||
#: src/accounting/report/reports/income_expenses.py:414
|
||||
#: src/accounting/report/reports/income_expenses.py:408
|
||||
#: src/accounting/templates/accounting/report/income-expenses.html:58
|
||||
msgid "Income"
|
||||
msgstr "收入"
|
||||
|
||||
#: src/accounting/report/reports/income_expenses.py:415
|
||||
#: src/accounting/report/reports/income_expenses.py:409
|
||||
#: src/accounting/templates/accounting/report/income-expenses.html:59
|
||||
msgid "Expense"
|
||||
msgstr "支出"
|
||||
|
||||
#: src/accounting/report/reports/income_expenses.py:415
|
||||
#: src/accounting/report/reports/income_expenses.py:409
|
||||
#: src/accounting/report/reports/ledger.py:368
|
||||
#: src/accounting/templates/accounting/report/income-expenses.html:60
|
||||
#: src/accounting/templates/accounting/report/ledger.html:60
|
||||
msgid "Balance"
|
||||
msgstr "餘額"
|
||||
|
||||
#: src/accounting/report/reports/income_expenses.py:416
|
||||
#: src/accounting/report/reports/income_expenses.py:410
|
||||
#: src/accounting/report/reports/journal.py:158
|
||||
#: src/accounting/report/reports/ledger.py:368
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:178
|
||||
@ -515,10 +565,6 @@ msgstr "借方"
|
||||
msgid "Credit"
|
||||
msgstr "貸方"
|
||||
|
||||
#: src/accounting/report/utils/ie_account.py:64
|
||||
msgid "current assets and liabilities"
|
||||
msgstr "流動資產與負債"
|
||||
|
||||
#: src/accounting/report/utils/report_chooser.py:81
|
||||
#: src/accounting/templates/accounting/account/include/form.html:98
|
||||
#: src/accounting/templates/accounting/account/list.html:40
|
||||
@ -526,6 +572,7 @@ msgstr "流動資產與負債"
|
||||
#: src/accounting/templates/accounting/currency/list.html:40
|
||||
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:34
|
||||
#: src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html:34
|
||||
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:34
|
||||
#: src/accounting/templates/accounting/report/include/search-modal.html:27
|
||||
#: src/accounting/templates/accounting/report/include/search-modal.html:33
|
||||
#: src/accounting/templates/accounting/report/include/search-modal.html:38
|
||||
@ -678,12 +725,14 @@ msgstr "新增科目"
|
||||
#: src/accounting/templates/accounting/journal-entry/include/detail.html:31
|
||||
#: src/accounting/templates/accounting/journal-entry/include/form.html:38
|
||||
#: src/accounting/templates/accounting/journal-entry/order.html:36
|
||||
#: src/accounting/templates/accounting/option/form.html:36
|
||||
msgid "Back"
|
||||
msgstr "回上頁"
|
||||
|
||||
#: src/accounting/templates/accounting/account/detail.html:36
|
||||
#: src/accounting/templates/accounting/currency/detail.html:36
|
||||
#: src/accounting/templates/accounting/journal-entry/include/detail.html:36
|
||||
#: src/accounting/templates/accounting/option/detail.html:31
|
||||
msgid "Edit"
|
||||
msgstr "編輯"
|
||||
|
||||
@ -713,6 +762,8 @@ msgstr "確認刪除科目"
|
||||
#: src/accounting/templates/accounting/journal-entry/include/detail.html:78
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:28
|
||||
#: src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html:27
|
||||
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:27
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:28
|
||||
#: src/accounting/templates/accounting/report/include/period-chooser.html:27
|
||||
#: src/accounting/templates/accounting/report/include/search-modal.html:28
|
||||
msgid "Close"
|
||||
@ -729,6 +780,8 @@ msgstr "你確定要刪掉這個科目嗎?"
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:193
|
||||
#: src/accounting/templates/accounting/journal-entry/include/detail.html:84
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:70
|
||||
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:48
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:65
|
||||
#: src/accounting/templates/accounting/report/include/search-modal.html:37
|
||||
msgid "Cancel"
|
||||
msgstr "取消"
|
||||
@ -773,6 +826,7 @@ msgstr "科目管理"
|
||||
#: src/accounting/templates/accounting/currency/list.html:32
|
||||
#: src/accounting/templates/accounting/journal-entry/include/form-debit-credit.html:44
|
||||
#: src/accounting/templates/accounting/journal-entry/include/form.html:64
|
||||
#: src/accounting/templates/accounting/option/include/form-recurring-expense-income.html:37
|
||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:26
|
||||
msgid "New"
|
||||
msgstr "新增"
|
||||
@ -785,6 +839,7 @@ msgstr "新增"
|
||||
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:46
|
||||
#: src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html:51
|
||||
#: src/accounting/templates/accounting/journal-entry/order.html:82
|
||||
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:45
|
||||
#: src/accounting/templates/accounting/report/balance-sheet.html:110
|
||||
#: src/accounting/templates/accounting/report/income-expenses.html:113
|
||||
#: src/accounting/templates/accounting/report/income-statement.html:96
|
||||
@ -807,6 +862,8 @@ msgstr "%(base)s下的科目"
|
||||
#: src/accounting/templates/accounting/journal-entry/include/form.html:80
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:71
|
||||
#: src/accounting/templates/accounting/journal-entry/order.html:61
|
||||
#: src/accounting/templates/accounting/option/form.html:80
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:66
|
||||
msgid "Save"
|
||||
msgstr "儲存"
|
||||
|
||||
@ -833,6 +890,7 @@ msgstr "選擇基本科目"
|
||||
#: src/accounting/templates/accounting/account/include/form.html:114
|
||||
#: src/accounting/templates/accounting/account/include/form.html:116
|
||||
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:50
|
||||
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:49
|
||||
msgid "Clear"
|
||||
msgstr "清除"
|
||||
|
||||
@ -866,6 +924,7 @@ msgid "Code"
|
||||
msgstr "代碼"
|
||||
|
||||
#: src/accounting/templates/accounting/currency/include/form.html:50
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:33
|
||||
msgid "Name"
|
||||
msgstr "名稱"
|
||||
|
||||
@ -889,6 +948,12 @@ msgstr "基本科目"
|
||||
msgid "Currencies"
|
||||
msgstr "貨幣"
|
||||
|
||||
#: src/accounting/templates/accounting/include/nav.html:58
|
||||
#: src/accounting/templates/accounting/option/detail.html:24
|
||||
#: src/accounting/templates/accounting/option/form.html:29
|
||||
msgid "Settings"
|
||||
msgstr "設定"
|
||||
|
||||
#: src/accounting/templates/accounting/include/pagination.html:23
|
||||
msgid "Page navigation"
|
||||
msgstr "分頁瀏覽"
|
||||
@ -923,6 +988,7 @@ msgid "Editing %(journal_entry)s"
|
||||
msgstr "編輯%(journal_entry)s"
|
||||
|
||||
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:26
|
||||
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:26
|
||||
msgid "Select Account"
|
||||
msgstr "選擇科目"
|
||||
|
||||
@ -1016,19 +1082,19 @@ msgstr "分錄內容"
|
||||
msgid "Original Line Item"
|
||||
msgstr "原始分錄"
|
||||
|
||||
#: src/accounting/templates/accounting/journal-entry/include/order-journal-entry.html:26
|
||||
#: src/accounting/templates/accounting/journal-entry/include/order-journal-entry.html:25
|
||||
#: src/accounting/templates/accounting/report/include/add-journal-entry-material-fab.html:26
|
||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:31
|
||||
msgid "Cash Disbursement"
|
||||
msgstr "現金支出"
|
||||
|
||||
#: src/accounting/templates/accounting/journal-entry/include/order-journal-entry.html:28
|
||||
#: src/accounting/templates/accounting/journal-entry/include/order-journal-entry.html:27
|
||||
#: src/accounting/templates/accounting/report/include/add-journal-entry-material-fab.html:29
|
||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:36
|
||||
msgid "Cash Receipt"
|
||||
msgstr "現金收入"
|
||||
|
||||
#: src/accounting/templates/accounting/journal-entry/include/order-journal-entry.html:30
|
||||
#: src/accounting/templates/accounting/journal-entry/include/order-journal-entry.html:29
|
||||
#: src/accounting/templates/accounting/report/include/add-journal-entry-material-fab.html:32
|
||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:41
|
||||
msgid "Transfer"
|
||||
@ -1046,6 +1112,68 @@ msgstr "新增現金收入傳票"
|
||||
msgid "Add a New Transfer Journal Entry"
|
||||
msgstr "新增轉帳傳票"
|
||||
|
||||
#: src/accounting/templates/accounting/option/detail.html:43
|
||||
#: src/accounting/templates/accounting/option/form.html:51
|
||||
msgid "Default Currency"
|
||||
msgstr "預設貨幣"
|
||||
|
||||
#: src/accounting/templates/accounting/option/detail.html:48
|
||||
#: src/accounting/templates/accounting/option/form.html:61
|
||||
msgid "Default Account for the Income and Expenses Log"
|
||||
msgstr "收支帳預設科目"
|
||||
|
||||
#: src/accounting/templates/accounting/option/detail.html:52
|
||||
#: src/accounting/templates/accounting/option/form.html:66
|
||||
#: src/accounting/templates/accounting/option/form.html:92
|
||||
msgid "Recurring Expense"
|
||||
msgstr "常用支出"
|
||||
|
||||
#: src/accounting/templates/accounting/option/detail.html:58
|
||||
#: src/accounting/templates/accounting/option/form.html:72
|
||||
#: src/accounting/templates/accounting/option/form.html:96
|
||||
msgid "Recurring Income"
|
||||
msgstr "常用收入"
|
||||
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:47
|
||||
msgid "Description Template"
|
||||
msgstr "摘要範本"
|
||||
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:52
|
||||
msgid "Available template variables:"
|
||||
msgstr "範本變數說明:"
|
||||
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:54
|
||||
msgid "This month, as a number."
|
||||
msgstr "這個月的數字。"
|
||||
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:55
|
||||
msgid "This month, in its name."
|
||||
msgstr "這個月的名稱。"
|
||||
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:56
|
||||
msgid "Last month, as a number."
|
||||
msgstr "上個月的數字。"
|
||||
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:57
|
||||
msgid "Last month, in its name."
|
||||
msgstr "上個月的名稱。"
|
||||
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:58
|
||||
msgid "The previous bimonthly period, as numbers."
|
||||
msgstr "前個雙月期的數字。"
|
||||
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:59
|
||||
msgid "The previous bimonthly period, as their names."
|
||||
msgstr "前個雙月期的名稱。"
|
||||
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:61
|
||||
msgid "Example:"
|
||||
msgstr "範例:"
|
||||
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:61
|
||||
msgid "Water bill for {last_bimonthly_name}"
|
||||
msgstr "水費{last_bimonthly_number}月"
|
||||
|
||||
#: src/accounting/templates/accounting/report/balance-sheet.html:29
|
||||
#: src/accounting/templates/accounting/report/balance-sheet.html:49
|
||||
#, python-format
|
||||
@ -1112,6 +1240,10 @@ msgstr "期間"
|
||||
msgid "Download"
|
||||
msgstr "下載"
|
||||
|
||||
#: src/accounting/utils/current_account.py:65
|
||||
msgid "current assets and liabilities"
|
||||
msgstr "流動資產與負債"
|
||||
|
||||
#: src/accounting/utils/pagination.py:206
|
||||
msgctxt "Pagination|"
|
||||
msgid "Previous"
|
||||
|
@ -1,5 +1,5 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
@ -14,28 +14,29 @@
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The pseudo account for the income and expenses log.
|
||||
"""The current assets and liabilities account.
|
||||
|
||||
"""
|
||||
import typing as t
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Account
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
class IncomeExpensesAccount:
|
||||
"""The pseudo account for the income and expenses log."""
|
||||
class CurrentAccount:
|
||||
"""A current assets and liabilities account."""
|
||||
CURRENT_AL_CODE: str = "0000-000"
|
||||
"""The account code for the current assets and liabilities."""
|
||||
"""The account code for all current assets and liabilities."""
|
||||
|
||||
def __init__(self, account: Account | None = None):
|
||||
"""Constructs the pseudo account for the income and expenses log.
|
||||
"""Constructs the current assets and liabilities account.
|
||||
|
||||
:param account: The actual account.
|
||||
"""
|
||||
self.account: Account | None = account
|
||||
"""The actual account."""
|
||||
self.id: int = -1 if account is None else account.id
|
||||
"""The ID."""
|
||||
self.code: str = "" if account is None else account.code
|
||||
@ -54,9 +55,9 @@ class IncomeExpensesAccount:
|
||||
|
||||
@classmethod
|
||||
def current_assets_and_liabilities(cls) -> t.Self:
|
||||
"""Returns the pseudo account for current assets and liabilities.
|
||||
"""Returns the pseudo account for all current assets and liabilities.
|
||||
|
||||
:return: The pseudo account for current assets and liabilities.
|
||||
:return: The pseudo account for all current assets and liabilities.
|
||||
"""
|
||||
account: cls = cls()
|
||||
account.id = 0
|
||||
@ -65,22 +66,28 @@ class IncomeExpensesAccount:
|
||||
account.str = account.title
|
||||
return account
|
||||
|
||||
@classmethod
|
||||
def accounts(cls) -> list[t.Self]:
|
||||
"""Returns the current assets and liabilities accounts.
|
||||
|
||||
def default_ie_account_code() -> str:
|
||||
"""Returns the default account code for the income and expenses log.
|
||||
:return: The current assets and liabilities accounts.
|
||||
"""
|
||||
accounts: list[cls] = [cls.current_assets_and_liabilities()]
|
||||
accounts.extend([CurrentAccount(x)
|
||||
for x in db.session.query(Account)
|
||||
.filter(cls.sql_condition())
|
||||
.order_by(Account.base_code, Account.no)])
|
||||
return accounts
|
||||
|
||||
:return: The default account code for the income and expenses log.
|
||||
"""
|
||||
return current_app.config.get("ACCOUNTING_DEFAULT_IE_ACCOUNT",
|
||||
Account.CASH_CODE)
|
||||
@classmethod
|
||||
def sql_condition(cls) -> sa.BinaryExpression:
|
||||
"""Returns the SQL condition for the current assets and liabilities
|
||||
accounts.
|
||||
|
||||
|
||||
def default_ie_account() -> IncomeExpensesAccount:
|
||||
"""Returns the default account for the income and expenses log.
|
||||
|
||||
:return: The default account for the income and expenses log.
|
||||
"""
|
||||
code: str = default_ie_account_code()
|
||||
if code == IncomeExpensesAccount.CURRENT_AL_CODE:
|
||||
return IncomeExpensesAccount.current_assets_and_liabilities()
|
||||
return IncomeExpensesAccount(Account.find_by_code(code))
|
||||
:return: The SQL condition for the current assets and liabilities
|
||||
accounts.
|
||||
"""
|
||||
return sa.or_(Account.base_code.startswith("11"),
|
||||
Account.base_code.startswith("12"),
|
||||
Account.base_code.startswith("21"),
|
||||
Account.base_code.startswith("22"))
|
225
src/accounting/utils/options.py
Normal file
225
src/accounting/utils/options.py
Normal file
@ -0,0 +1,225 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The getter and setter for the option management.
|
||||
|
||||
"""
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import Option, Account, Currency
|
||||
from accounting.utils.current_account import CurrentAccount
|
||||
from accounting.utils.user import get_current_user_pk
|
||||
|
||||
|
||||
class RecurringItem:
|
||||
"""A recurring item."""
|
||||
|
||||
def __init__(self, name: str, account_code: str,
|
||||
description_template: str):
|
||||
"""Constructs the recurring item.
|
||||
|
||||
:param name: The name.
|
||||
:param account_code: The account code.
|
||||
:param description_template: The description template.
|
||||
"""
|
||||
self.name: str = name
|
||||
self.account_code: str = account_code
|
||||
self.description_template: str = description_template
|
||||
|
||||
@property
|
||||
def account_text(self) -> str:
|
||||
"""Returns the account text.
|
||||
|
||||
:return: The account text.
|
||||
"""
|
||||
return str(Account.find_by_code(self.account_code))
|
||||
|
||||
|
||||
class Recurring:
|
||||
"""The recurring expenses or incomes."""
|
||||
|
||||
def __init__(self, data: dict[str, list[tuple[str, str, str]]]):
|
||||
"""Constructs the recurring item.
|
||||
|
||||
:param data: The data.
|
||||
"""
|
||||
self.expenses: list[RecurringItem] \
|
||||
= [RecurringItem(x[0], x[1], x[2]) for x in data["expense"]]
|
||||
self.incomes: list[RecurringItem] \
|
||||
= [RecurringItem(x[0], x[1], x[2]) for x in data["income"]]
|
||||
|
||||
@property
|
||||
def codes(self) -> set[str]:
|
||||
"""Returns all the account codes.
|
||||
|
||||
:return: All the account codes.
|
||||
"""
|
||||
return {x.account_code for x in self.expenses + self.incomes}
|
||||
|
||||
|
||||
class Options:
|
||||
"""The options."""
|
||||
|
||||
def __init__(self):
|
||||
"""Constructs the options."""
|
||||
self.is_modified: bool = False
|
||||
"""Whether the options were modified."""
|
||||
|
||||
@property
|
||||
def default_currency_code(self) -> str:
|
||||
"""Returns the default currency code.
|
||||
|
||||
:return: The default currency code.
|
||||
"""
|
||||
return self.__get_option("default_currency_code", "USD")
|
||||
|
||||
@default_currency_code.setter
|
||||
def default_currency_code(self, value: str) -> None:
|
||||
"""Sets the default currency code.
|
||||
|
||||
:param value: The default currency code.
|
||||
:return: None.
|
||||
"""
|
||||
self.__set_option("default_currency_code", value)
|
||||
|
||||
@property
|
||||
def default_currency_text(self) -> str:
|
||||
"""Returns the text of the default currency code.
|
||||
|
||||
:return: The text of the default currency code.
|
||||
"""
|
||||
return str(db.session.get(Currency, self.default_currency_code))
|
||||
|
||||
@property
|
||||
def default_ie_account_code(self) -> str:
|
||||
"""Returns the default account code for the income and expenses log.
|
||||
|
||||
:return: The default account code for the income and expenses log.
|
||||
"""
|
||||
return self.__get_option("default_ie_account", Account.CASH_CODE)
|
||||
|
||||
@default_ie_account_code.setter
|
||||
def default_ie_account_code(self, value: str) -> None:
|
||||
"""Sets the default account code for the income and expenses log.
|
||||
|
||||
:param value: The default account code for the income and expenses log.
|
||||
:return: None.
|
||||
"""
|
||||
self.__set_option("default_ie_account", value)
|
||||
|
||||
@property
|
||||
def default_ie_account_code_text(self) -> str:
|
||||
"""Returns the text of the default currency code.
|
||||
|
||||
:return: The text of the default currency code.
|
||||
"""
|
||||
code: str = self.default_ie_account_code
|
||||
if code == CurrentAccount.CURRENT_AL_CODE:
|
||||
return str(CurrentAccount.current_assets_and_liabilities())
|
||||
return str(CurrentAccount(Account.find_by_code(code)))
|
||||
|
||||
@property
|
||||
def default_ie_account(self) -> CurrentAccount:
|
||||
"""Returns the default account code for the income and expenses log.
|
||||
|
||||
:return: The default account code for the income and expenses log.
|
||||
"""
|
||||
if self.default_ie_account_code \
|
||||
== CurrentAccount.CURRENT_AL_CODE:
|
||||
return CurrentAccount.current_assets_and_liabilities()
|
||||
return CurrentAccount(
|
||||
Account.find_by_code(self.default_ie_account_code))
|
||||
|
||||
@property
|
||||
def recurring_data(self) -> dict[str, list[tuple[str, str, str]]]:
|
||||
"""Returns the data of the recurring expenses and incomes.
|
||||
|
||||
:return: The data of the recurring expenses and incomes.
|
||||
"""
|
||||
json_data: str | None = self.__get_option("recurring")
|
||||
if json_data is None:
|
||||
return {"expense": [], "income": []}
|
||||
return json.loads(json_data)
|
||||
|
||||
@recurring_data.setter
|
||||
def recurring_data(self,
|
||||
value: dict[str, list[tuple[str, str, str]]]) -> None:
|
||||
"""Sets the data of the recurring expenses and incomes.
|
||||
|
||||
:param value: The data of the recurring expenses and incomes.
|
||||
:return: None.
|
||||
"""
|
||||
self.__set_option("recurring", json.dumps(value, ensure_ascii=False,
|
||||
separators=(",", ":")))
|
||||
|
||||
@property
|
||||
def recurring(self) -> Recurring:
|
||||
"""Returns the recurring expenses and incomes.
|
||||
|
||||
:return: The recurring expenses and incomes.
|
||||
"""
|
||||
return Recurring(self.recurring_data)
|
||||
|
||||
@staticmethod
|
||||
def __get_option(name: str, default: str | None = None) -> str:
|
||||
"""Returns the value of an option.
|
||||
|
||||
:param name: The name.
|
||||
:param default: The default value when the value does not exist.
|
||||
:return: The value.
|
||||
"""
|
||||
option: Option | None = db.session.get(Option, name)
|
||||
if option is None:
|
||||
return default
|
||||
return option.value
|
||||
|
||||
def __set_option(self, name: str, value: str) -> None:
|
||||
"""Sets the value of an option.
|
||||
|
||||
:param name: The name.
|
||||
:param value: The value.
|
||||
:return: None.
|
||||
"""
|
||||
option: Option | None = db.session.get(Option, name)
|
||||
if option is None:
|
||||
current_user_pk: int = get_current_user_pk()
|
||||
db.session.add(Option(name=name,
|
||||
value=value,
|
||||
created_by_id=current_user_pk,
|
||||
updated_by_id=current_user_pk))
|
||||
self.is_modified = True
|
||||
return
|
||||
if option.value == value:
|
||||
return
|
||||
option.value = value
|
||||
option.updated_by_id = get_current_user_pk()
|
||||
option.updated_at = sa.func.now()
|
||||
self.is_modified = True
|
||||
|
||||
def commit(self) -> None:
|
||||
"""Commits the options to the database.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
db.session.commit()
|
||||
self.is_modified = False
|
||||
|
||||
|
||||
options: Options = Options()
|
||||
"""The options."""
|
@ -63,10 +63,13 @@ data."""
|
||||
__can_edit_func: t.Callable[[], bool] = lambda: True
|
||||
"""The callback that returns whether the current user can edit the accounting
|
||||
data."""
|
||||
__can_admin_func: t.Callable[[], bool] = lambda: True
|
||||
"""The callback that returns whether the current user can administrate the
|
||||
accounting settings."""
|
||||
|
||||
|
||||
def can_view() -> bool:
|
||||
"""Returns whether the current user can view the account data.
|
||||
"""Returns whether the current user can view the accounting data.
|
||||
|
||||
:return: True if the current user can view the accounting data, or False
|
||||
otherwise.
|
||||
@ -75,7 +78,7 @@ def can_view() -> bool:
|
||||
|
||||
|
||||
def can_edit() -> bool:
|
||||
"""Returns whether the current user can edit the account data.
|
||||
"""Returns whether the current user can edit the accounting data.
|
||||
|
||||
The user has to log in.
|
||||
|
||||
@ -87,6 +90,20 @@ def can_edit() -> bool:
|
||||
return __can_edit_func()
|
||||
|
||||
|
||||
def can_admin() -> bool:
|
||||
"""Returns whether the current user can administrate the accounting
|
||||
settings.
|
||||
|
||||
The user has to log in.
|
||||
|
||||
:return: True if the current user can administrate the accounting settings,
|
||||
or False otherwise.
|
||||
"""
|
||||
if get_current_user() is None:
|
||||
return False
|
||||
return __can_admin_func()
|
||||
|
||||
|
||||
def init_app(bp: Blueprint, user_utils: UserUtilityInterface) -> None:
|
||||
"""Initializes the application.
|
||||
|
||||
@ -94,8 +111,10 @@ def init_app(bp: Blueprint, user_utils: UserUtilityInterface) -> None:
|
||||
:param user_utils: The user utilities.
|
||||
:return: None.
|
||||
"""
|
||||
global __can_view_func, __can_edit_func
|
||||
global __can_view_func, __can_edit_func, __can_admin_func
|
||||
__can_view_func = user_utils.can_view
|
||||
__can_edit_func = user_utils.can_edit
|
||||
__can_admin_func = user_utils.can_admin
|
||||
bp.add_app_template_global(user_utils.can_view, "accounting_can_view")
|
||||
bp.add_app_template_global(user_utils.can_edit, "accounting_can_edit")
|
||||
bp.add_app_template_global(user_utils.can_admin, "accounting_can_admin")
|
||||
|
@ -50,6 +50,15 @@ class UserUtilityInterface(t.Generic[T], ABC):
|
||||
data, or False otherwise.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def can_admin(self) -> bool:
|
||||
"""Returns whether the currently logged-in user can administrate the
|
||||
accounting settings.
|
||||
|
||||
:return: True if the currently logged-in user can administrate the
|
||||
accounting settings, or False otherwise.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def cls(self) -> t.Type[T]:
|
||||
|
@ -27,12 +27,9 @@ from flask import Flask
|
||||
from flask.testing import FlaskCliRunner
|
||||
|
||||
from test_site import db
|
||||
from testlib import create_test_app, get_client, set_locale
|
||||
from testlib import NEXT_URI, create_test_app, get_client, set_locale
|
||||
from testlib_journal_entry import add_journal_entry
|
||||
|
||||
NEXT_URI: str = "/_next"
|
||||
"""The next URI."""
|
||||
|
||||
|
||||
class AccountData:
|
||||
"""The account data."""
|
||||
@ -550,8 +547,8 @@ class AccountTestCase(unittest.TestCase):
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import Account
|
||||
editor_username, editor2_username = "editor", "editor2"
|
||||
client, csrf_token = get_client(self.app, editor2_username)
|
||||
editor_username, admin_username = "editor", "admin"
|
||||
client, csrf_token = get_client(self.app, admin_username)
|
||||
detail_uri: str = f"{PREFIX}/{CASH.code}"
|
||||
update_uri: str = f"{PREFIX}/{CASH.code}/update"
|
||||
account: Account
|
||||
@ -574,7 +571,7 @@ class AccountTestCase(unittest.TestCase):
|
||||
self.assertEqual(account.created_by.username,
|
||||
editor_username)
|
||||
self.assertEqual(account.updated_by.username,
|
||||
editor2_username)
|
||||
admin_username)
|
||||
|
||||
def test_l10n(self) -> None:
|
||||
"""Tests the localization.
|
||||
|
@ -28,8 +28,8 @@ from flask import Flask
|
||||
from flask.testing import FlaskCliRunner
|
||||
|
||||
from test_site import db
|
||||
from testlib import create_test_app, get_client, set_locale
|
||||
from testlib_journal_entry import add_journal_entry, NEXT_URI
|
||||
from testlib import NEXT_URI, create_test_app, get_client, set_locale
|
||||
from testlib_journal_entry import add_journal_entry
|
||||
|
||||
|
||||
class CurrencyData:
|
||||
@ -471,8 +471,8 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import Currency
|
||||
editor_username, editor2_username = "editor", "editor2"
|
||||
client, csrf_token = get_client(self.app, editor2_username)
|
||||
editor_username, admin_username = "editor", "admin"
|
||||
client, csrf_token = get_client(self.app, admin_username)
|
||||
detail_uri: str = f"{PREFIX}/{USD.code}"
|
||||
update_uri: str = f"{PREFIX}/{USD.code}/update"
|
||||
currency: Currency
|
||||
@ -493,7 +493,7 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
with self.app.app_context():
|
||||
currency = db.session.get(Currency, USD.code)
|
||||
self.assertEqual(currency.created_by.username, editor_username)
|
||||
self.assertEqual(currency.updated_by.username, editor2_username)
|
||||
self.assertEqual(currency.updated_by.username, admin_username)
|
||||
|
||||
def test_api_exists(self) -> None:
|
||||
"""Tests the API to check if a code exists.
|
||||
|
@ -24,8 +24,8 @@ from click.testing import Result
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskCliRunner
|
||||
|
||||
from testlib import create_test_app, get_client
|
||||
from testlib_journal_entry import Accounts, NEXT_URI, add_journal_entry
|
||||
from testlib import NEXT_URI, Accounts, create_test_app, get_client
|
||||
from testlib_journal_entry import add_journal_entry
|
||||
|
||||
|
||||
class DescriptionEditorTestCase(unittest.TestCase):
|
||||
|
@ -27,9 +27,9 @@ from flask import Flask
|
||||
from flask.testing import FlaskCliRunner
|
||||
|
||||
from test_site import db
|
||||
from testlib import create_test_app, get_client
|
||||
from testlib_journal_entry import NEXT_URI, NON_EMPTY_NOTE, EMPTY_NOTE, \
|
||||
Accounts, get_add_form, get_unchanged_update_form, get_update_form, \
|
||||
from testlib import NEXT_URI, Accounts, create_test_app, get_client
|
||||
from testlib_journal_entry import NON_EMPTY_NOTE, EMPTY_NOTE, \
|
||||
get_add_form, get_unchanged_update_form, get_update_form, \
|
||||
match_journal_entry_detail, set_negative_amount, \
|
||||
remove_debit_in_a_currency, remove_credit_in_a_currency, add_journal_entry
|
||||
|
||||
@ -309,7 +309,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
|
||||
self.assertEqual(len(currencies[2].credit), 2)
|
||||
self.assertEqual(currencies[2].credit[0].no, 6)
|
||||
self.assertEqual(currencies[2].credit[0].account.code,
|
||||
Accounts.RENT)
|
||||
Accounts.RENT_INCOME)
|
||||
self.assertEqual(currencies[2].credit[1].no, 7)
|
||||
self.assertEqual(currencies[2].credit[1].account.code,
|
||||
Accounts.DONATION)
|
||||
@ -441,7 +441,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
|
||||
self.assertNotIn(currencies1[0].credit[1].id, old_id)
|
||||
self.assertEqual(currencies1[0].credit[1].no, 2)
|
||||
self.assertEqual(currencies1[0].credit[1].account.code,
|
||||
Accounts.RENT)
|
||||
Accounts.RENT_INCOME)
|
||||
|
||||
self.assertEqual(currencies1[1].code, "EUR")
|
||||
self.assertEqual(len(currencies1[1].debit), 1)
|
||||
@ -457,7 +457,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
|
||||
currencies0[2].credit[0].id)
|
||||
self.assertEqual(currencies1[1].credit[0].no, 3)
|
||||
self.assertEqual(currencies1[1].credit[0].account.code,
|
||||
Accounts.RENT)
|
||||
Accounts.RENT_INCOME)
|
||||
self.assertEqual(currencies1[1].credit[1].id,
|
||||
currencies0[2].credit[1].id)
|
||||
self.assertEqual(currencies1[1].credit[1].no, 4)
|
||||
@ -537,8 +537,8 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
|
||||
from accounting.models import JournalEntry
|
||||
journal_entry_id: int \
|
||||
= add_journal_entry(self.client, self.__get_add_form())
|
||||
editor_username, editor2_username = "editor", "editor2"
|
||||
client, csrf_token = get_client(self.app, editor2_username)
|
||||
editor_username, admin_username = "editor", "admin"
|
||||
client, csrf_token = get_client(self.app, admin_username)
|
||||
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
|
||||
update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
|
||||
journal_entry: JournalEntry
|
||||
@ -562,7 +562,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
|
||||
self.assertEqual(journal_entry.created_by.username,
|
||||
editor_username)
|
||||
self.assertEqual(journal_entry.updated_by.username,
|
||||
editor2_username)
|
||||
admin_username)
|
||||
|
||||
def test_delete(self) -> None:
|
||||
"""Tests to delete a journal entry.
|
||||
@ -1163,8 +1163,8 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
|
||||
from accounting.models import JournalEntry
|
||||
journal_entry_id: int \
|
||||
= add_journal_entry(self.client, self.__get_add_form())
|
||||
editor_username, editor2_username = "editor", "editor2"
|
||||
client, csrf_token = get_client(self.app, editor2_username)
|
||||
editor_username, admin_username = "editor", "admin"
|
||||
client, csrf_token = get_client(self.app, admin_username)
|
||||
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
|
||||
update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
|
||||
journal_entry: JournalEntry
|
||||
@ -1188,7 +1188,7 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
|
||||
self.assertEqual(journal_entry.created_by.username,
|
||||
editor_username)
|
||||
self.assertEqual(journal_entry.updated_by.username,
|
||||
editor2_username)
|
||||
admin_username)
|
||||
|
||||
def test_delete(self) -> None:
|
||||
"""Tests to delete a journal entry.
|
||||
@ -1562,7 +1562,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
|
||||
self.assertEqual(len(currencies[2].credit), 2)
|
||||
self.assertEqual(currencies[2].credit[0].no, 6)
|
||||
self.assertEqual(currencies[2].credit[0].account.code,
|
||||
Accounts.RENT)
|
||||
Accounts.RENT_INCOME)
|
||||
self.assertEqual(currencies[2].credit[1].no, 7)
|
||||
self.assertEqual(currencies[2].credit[1].account.code,
|
||||
Accounts.DONATION)
|
||||
@ -1728,7 +1728,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
|
||||
self.assertNotIn(currencies1[0].credit[1].id, old_id)
|
||||
self.assertEqual(currencies1[0].credit[1].no, 2)
|
||||
self.assertEqual(currencies1[0].credit[1].account.code,
|
||||
Accounts.RENT)
|
||||
Accounts.RENT_INCOME)
|
||||
|
||||
self.assertEqual(currencies1[1].code, "EUR")
|
||||
self.assertEqual(len(currencies1[1].debit), 2)
|
||||
@ -1748,7 +1748,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
|
||||
currencies0[2].credit[0].id)
|
||||
self.assertEqual(currencies1[1].credit[0].no, 3)
|
||||
self.assertEqual(currencies1[1].credit[0].account.code,
|
||||
Accounts.RENT)
|
||||
Accounts.RENT_INCOME)
|
||||
self.assertEqual(currencies1[1].credit[1].id,
|
||||
currencies0[2].credit[1].id)
|
||||
self.assertEqual(currencies1[1].credit[1].no, 4)
|
||||
@ -1837,8 +1837,8 @@ class TransferJournalEntryTestCase(unittest.TestCase):
|
||||
from accounting.models import JournalEntry
|
||||
journal_entry_id: int \
|
||||
= add_journal_entry(self.client, self.__get_add_form())
|
||||
editor_username, editor2_username = "editor", "editor2"
|
||||
client, csrf_token = get_client(self.app, editor2_username)
|
||||
editor_username, admin_username = "editor", "admin"
|
||||
client, csrf_token = get_client(self.app, admin_username)
|
||||
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
|
||||
update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
|
||||
journal_entry: JournalEntry
|
||||
@ -1862,7 +1862,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
|
||||
self.assertEqual(journal_entry.created_by.username,
|
||||
editor_username)
|
||||
self.assertEqual(journal_entry.updated_by.username,
|
||||
editor2_username)
|
||||
admin_username)
|
||||
|
||||
def test_save_as_receipt(self) -> None:
|
||||
"""Tests to save a transfer journal entry as a cash receipt journal
|
||||
@ -1914,7 +1914,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
|
||||
self.assertNotIn(currencies1[0].credit[1].id, old_id)
|
||||
self.assertEqual(currencies1[0].credit[1].no, 2)
|
||||
self.assertEqual(currencies1[0].credit[1].account.code,
|
||||
Accounts.RENT)
|
||||
Accounts.RENT_INCOME)
|
||||
|
||||
self.assertEqual(currencies1[1].code, "EUR")
|
||||
self.assertEqual(len(currencies1[1].debit), 1)
|
||||
@ -1930,7 +1930,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
|
||||
currencies0[2].credit[0].id)
|
||||
self.assertEqual(currencies1[1].credit[0].no, 3)
|
||||
self.assertEqual(currencies1[1].credit[0].account.code,
|
||||
Accounts.RENT)
|
||||
Accounts.RENT_INCOME)
|
||||
self.assertEqual(currencies1[1].credit[1].id,
|
||||
currencies0[2].credit[1].id)
|
||||
self.assertEqual(currencies1[1].credit[1].no, 4)
|
||||
|
@ -26,8 +26,8 @@ from flask import Flask
|
||||
from flask.testing import FlaskCliRunner
|
||||
|
||||
from test_site import db
|
||||
from testlib import create_test_app, get_client
|
||||
from testlib_journal_entry import Accounts, match_journal_entry_detail
|
||||
from testlib import Accounts, create_test_app, get_client
|
||||
from testlib_journal_entry import match_journal_entry_detail
|
||||
from testlib_offset import TestData, JournalEntryLineItemData, \
|
||||
JournalEntryData, CurrencyData
|
||||
|
||||
|
416
tests/test_option.py
Normal file
416
tests/test_option.py
Normal file
@ -0,0 +1,416 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The test for the options.
|
||||
|
||||
"""
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import httpx
|
||||
from click.testing import Result
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskCliRunner
|
||||
|
||||
from test_site import db
|
||||
from testlib import NEXT_URI, Accounts, create_test_app, get_client
|
||||
from testlib_offset import TestData
|
||||
|
||||
PREFIX: str = "/accounting/options"
|
||||
"""The URL prefix for the option management."""
|
||||
DETAIL_URI: str = f"{PREFIX}?next=%2F_next"
|
||||
"""THE URI for the option detail."""
|
||||
EDIT_URI: str = f"{PREFIX}/edit?next=%2F_next"
|
||||
"""THE URI for the form to edit the options."""
|
||||
UPDATE_URI: str = f"{PREFIX}/update"
|
||||
"""THE URI to update the options."""
|
||||
|
||||
|
||||
class OptionTestCase(unittest.TestCase):
|
||||
"""The option test case."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Sets up the test.
|
||||
This is run once per test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.app: Flask = create_test_app()
|
||||
|
||||
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||
with self.app.app_context():
|
||||
from accounting.models import BaseAccount, Option
|
||||
result: Result
|
||||
result = runner.invoke(args="init-db")
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
if BaseAccount.query.first() is None:
|
||||
result = runner.invoke(args="accounting-init-base")
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
result = runner.invoke(args=["accounting-init-currencies",
|
||||
"-u", "editor"])
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
result = runner.invoke(args=["accounting-init-accounts",
|
||||
"-u", "editor"])
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
Option.query.delete()
|
||||
|
||||
self.client, self.csrf_token = get_client(self.app, "admin")
|
||||
self.data: TestData = TestData(self.app, self.client, self.csrf_token)
|
||||
|
||||
def test_nobody(self) -> None:
|
||||
"""Test the permission as nobody.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
client, csrf_token = get_client(self.app, "nobody")
|
||||
response: httpx.Response
|
||||
|
||||
response = client.get(DETAIL_URI)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = client.get(EDIT_URI)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = client.post(UPDATE_URI, data=self.__get_form(csrf_token))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_viewer(self) -> None:
|
||||
"""Test the permission as viewer.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
client, csrf_token = get_client(self.app, "viewer")
|
||||
response: httpx.Response
|
||||
|
||||
response = client.get(DETAIL_URI)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = client.get(EDIT_URI)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = client.post(UPDATE_URI, data=self.__get_form(csrf_token))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_editor(self) -> None:
|
||||
"""Test the permission as editor.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
client, csrf_token = get_client(self.app, "editor")
|
||||
response: httpx.Response
|
||||
|
||||
response = client.get(DETAIL_URI)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = client.get(EDIT_URI)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = client.post(UPDATE_URI, data=self.__get_form(csrf_token))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_admin(self) -> None:
|
||||
"""Test the permission as administrator.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
response: httpx.Response
|
||||
|
||||
response = self.client.get(DETAIL_URI)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(EDIT_URI)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(UPDATE_URI, data=self.__get_form())
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], DETAIL_URI)
|
||||
|
||||
def test_set(self) -> None:
|
||||
"""Test to set the options.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.utils.options import options
|
||||
form: dict[str, str]
|
||||
response: httpx.Response
|
||||
|
||||
# Empty currency code
|
||||
form = self.__get_form()
|
||||
form["default_currency_code"] = " "
|
||||
response = self.client.post(UPDATE_URI, data=form)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], EDIT_URI)
|
||||
|
||||
# Non-existing currency code
|
||||
form = self.__get_form()
|
||||
form["default_currency_code"] = "ZZZ"
|
||||
response = self.client.post(UPDATE_URI, data=form)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], EDIT_URI)
|
||||
|
||||
# Empty current account
|
||||
form = self.__get_form()
|
||||
form["default_ie_account_code"] = " "
|
||||
response = self.client.post(UPDATE_URI, data=form)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], EDIT_URI)
|
||||
|
||||
# Non-existing current account
|
||||
form = self.__get_form()
|
||||
form["default_ie_account_code"] = "9999-999"
|
||||
response = self.client.post(UPDATE_URI, data=form)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], EDIT_URI)
|
||||
|
||||
# Not a current account
|
||||
form = self.__get_form()
|
||||
form["default_ie_account_code"] = Accounts.MEAL
|
||||
response = self.client.post(UPDATE_URI, data=form)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], EDIT_URI)
|
||||
|
||||
# Recurring item name empty
|
||||
form = self.__get_form()
|
||||
key = [x for x in form if x.endswith("-name")][0]
|
||||
form[key] = " "
|
||||
response = self.client.post(UPDATE_URI, data=form)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], EDIT_URI)
|
||||
|
||||
# Recurring item account empty
|
||||
form = self.__get_form()
|
||||
key = [x for x in form if x.endswith("-account_code")][0]
|
||||
form[key] = " "
|
||||
response = self.client.post(UPDATE_URI, data=form)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], EDIT_URI)
|
||||
|
||||
# Recurring item non-expense account
|
||||
form = self.__get_form()
|
||||
key = [x for x in form
|
||||
if x.startswith("recurring-expense-")
|
||||
and x.endswith("-account_code")][0]
|
||||
form[key] = Accounts.SERVICE
|
||||
response = self.client.post(UPDATE_URI, data=form)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], EDIT_URI)
|
||||
|
||||
# Recurring item non-income account
|
||||
form = self.__get_form()
|
||||
key = [x for x in form
|
||||
if x.startswith("recurring-income-")
|
||||
and x.endswith("-account_code")][0]
|
||||
form[key] = Accounts.UTILITIES
|
||||
response = self.client.post(UPDATE_URI, data=form)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], EDIT_URI)
|
||||
|
||||
# Recurring item payable expense
|
||||
form = self.__get_form()
|
||||
key = [x for x in form
|
||||
if x.startswith("recurring-expense-")
|
||||
and x.endswith("-account_code")][0]
|
||||
form[key] = Accounts.PAYABLE
|
||||
response = self.client.post(UPDATE_URI, data=form)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], EDIT_URI)
|
||||
|
||||
# Recurring item receivable income
|
||||
form = self.__get_form()
|
||||
key = [x for x in form
|
||||
if x.startswith("recurring-income-")
|
||||
and x.endswith("-account_code")][0]
|
||||
form[key] = Accounts.RECEIVABLE
|
||||
response = self.client.post(UPDATE_URI, data=form)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], EDIT_URI)
|
||||
|
||||
# Recurring item description template empty
|
||||
form = self.__get_form()
|
||||
key = [x for x in form if x.endswith("-description_template")][0]
|
||||
form[key] = " "
|
||||
response = self.client.post(UPDATE_URI, data=form)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], EDIT_URI)
|
||||
|
||||
# Success, with malformed order
|
||||
with self.app.app_context():
|
||||
self.assertEqual(options.default_currency_code, "USD")
|
||||
self.assertEqual(options.default_ie_account_code, "1111-001")
|
||||
self.assertEqual(len(options.recurring.expenses), 0)
|
||||
self.assertEqual(len(options.recurring.incomes), 0)
|
||||
|
||||
response = self.client.post(UPDATE_URI, data=self.__get_form())
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], DETAIL_URI)
|
||||
|
||||
with self.app.app_context():
|
||||
self.assertEqual(options.default_currency_code, "EUR")
|
||||
self.assertEqual(options.default_ie_account_code, "0000-000")
|
||||
self.assertEqual(len(options.recurring.expenses), 4)
|
||||
self.assertEqual(options.recurring.expenses[0].account_code,
|
||||
Accounts.INSURANCE)
|
||||
self.assertEqual(options.recurring.expenses[1].account_code,
|
||||
Accounts.POSTAGE)
|
||||
self.assertEqual(options.recurring.expenses[2].account_code,
|
||||
Accounts.UTILITIES)
|
||||
self.assertEqual(options.recurring.expenses[3].account_code,
|
||||
Accounts.RENT_EXPENSE)
|
||||
self.assertEqual(len(options.recurring.incomes), 2)
|
||||
self.assertEqual(options.recurring.incomes[0].account_code,
|
||||
Accounts.SERVICE)
|
||||
self.assertEqual(options.recurring.incomes[1].account_code,
|
||||
Accounts.DONATION)
|
||||
|
||||
# Success, with no recurring data
|
||||
form = self.__get_form()
|
||||
form = {x: form[x] for x in form if not x.startswith("recurring-")}
|
||||
response = self.client.post(UPDATE_URI, data=form)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], DETAIL_URI)
|
||||
|
||||
with self.app.app_context():
|
||||
self.assertEqual(len(options.recurring.expenses), 0)
|
||||
self.assertEqual(len(options.recurring.incomes), 0)
|
||||
|
||||
def test_update_not_modified(self) -> None:
|
||||
"""Tests that the data is not modified.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import Option
|
||||
form: dict[str, str]
|
||||
option: Option | None
|
||||
resource: httpx.Response
|
||||
|
||||
response = self.client.post(UPDATE_URI, data=self.__get_form())
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], DETAIL_URI)
|
||||
|
||||
with self.app.app_context():
|
||||
option = db.session.get(Option, "recurring")
|
||||
self.assertIsNotNone(option)
|
||||
timestamp: datetime = option.created_at - timedelta(seconds=5)
|
||||
option.created_at = timestamp
|
||||
option.updated_at = timestamp
|
||||
db.session.commit()
|
||||
|
||||
# The recurring setting was not modified
|
||||
form = self.__get_form()
|
||||
form["default_currency_code"] = "JPY"
|
||||
response = self.client.post(UPDATE_URI, data=form)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], DETAIL_URI)
|
||||
|
||||
with self.app.app_context():
|
||||
option = db.session.get(Option, "recurring")
|
||||
self.assertIsNotNone(option)
|
||||
self.assertEqual(option.created_at, timestamp)
|
||||
self.assertEqual(option.updated_at, timestamp)
|
||||
|
||||
# The recurring setting was modified
|
||||
form = self.__get_form()
|
||||
key: str = [x for x in form
|
||||
if x.startswith("recurring-expense-")
|
||||
and x.endswith("-account_code")][0]
|
||||
form[key] = Accounts.MEAL
|
||||
response = self.client.post(UPDATE_URI, data=form)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], DETAIL_URI)
|
||||
|
||||
with self.app.app_context():
|
||||
option = db.session.get(Option, "recurring")
|
||||
self.assertIsNotNone(option)
|
||||
self.assertLess(option.created_at, option.updated_at)
|
||||
|
||||
def test_created_updated_by(self) -> None:
|
||||
"""Tests the created-by and updated-by record.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import Option
|
||||
from accounting.utils.user import get_user_pk
|
||||
admin_username, editor_username = "admin", "editor"
|
||||
option: Option | None
|
||||
response: httpx.Response
|
||||
|
||||
response = self.client.post(UPDATE_URI, data=self.__get_form())
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], DETAIL_URI)
|
||||
|
||||
with self.app.app_context():
|
||||
editor_pk: int = get_user_pk(editor_username)
|
||||
option = db.session.get(Option, "recurring")
|
||||
self.assertIsNotNone(option)
|
||||
option.created_by_id = editor_pk
|
||||
option.updated_by_id = editor_pk
|
||||
db.session.commit()
|
||||
|
||||
form: dict[str, str] = self.__get_form()
|
||||
key: str = [x for x in form
|
||||
if x.startswith("recurring-expense-")
|
||||
and x.endswith("-account_code")][0]
|
||||
form[key] = Accounts.MEAL
|
||||
response = self.client.post(UPDATE_URI, data=form)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], DETAIL_URI)
|
||||
|
||||
with self.app.app_context():
|
||||
option = db.session.get(Option, "recurring")
|
||||
self.assertIsNotNone(option)
|
||||
self.assertEqual(option.created_by.username, editor_username)
|
||||
self.assertEqual(option.updated_by.username, admin_username)
|
||||
|
||||
def __get_form(self, csrf_token: str | None = None) -> dict[str, str]:
|
||||
"""Returns the option form.
|
||||
|
||||
:param csrf_token: The CSRF token.
|
||||
:return: The option form.
|
||||
"""
|
||||
if csrf_token is None:
|
||||
csrf_token = self.csrf_token
|
||||
return {"csrf_token": csrf_token,
|
||||
"next": NEXT_URI,
|
||||
"default_currency_code": "EUR",
|
||||
"default_ie_account_code": "0000-000",
|
||||
"recurring-expense-1-name": "Water bill",
|
||||
"recurring-expense-1-account_code": Accounts.UTILITIES,
|
||||
"recurring-expense-1-description_template":
|
||||
"Water bill for {last_bimonthly_name}",
|
||||
"recurring-expense-3-no": "16",
|
||||
"recurring-expense-3-name": "Phone bill",
|
||||
"recurring-expense-3-account_code": Accounts.POSTAGE,
|
||||
"recurring-expense-3-description_template":
|
||||
"Phone bill for {last_month_name}",
|
||||
"recurring-expense-12-name": "Rent",
|
||||
"recurring-expense-12-account_code": Accounts.RENT_EXPENSE,
|
||||
"recurring-expense-12-description_template":
|
||||
"Rent for {this_month_name}",
|
||||
"recurring-expense-26-no": "7",
|
||||
"recurring-expense-26-name": "Insurance",
|
||||
"recurring-expense-26-account_code": Accounts.INSURANCE,
|
||||
"recurring-expense-26-description_template":
|
||||
"Insurance for {last_month_name}",
|
||||
"recurring-income-2-no": "12",
|
||||
"recurring-income-2-name": "Donation",
|
||||
"recurring-income-2-account_code": Accounts.DONATION,
|
||||
"recurring-income-2-description_template":
|
||||
"Donation for {this_month_name}",
|
||||
"recurring-income-15-no": "4",
|
||||
"recurring-income-15-name": "Payroll",
|
||||
"recurring-income-15-account_code": Accounts.SERVICE,
|
||||
"recurring-income-15-description_template":
|
||||
"Payroll for {last_month_name}"}
|
@ -50,18 +50,6 @@ def create_app(is_testing: bool = False) -> Flask:
|
||||
"SQLALCHEMY_DATABASE_URI": db_uri,
|
||||
"BABEL_DEFAULT_LOCALE": "en",
|
||||
"ALL_LINGUAS": "zh_Hant|正體中文,en|English,zh_Hans|简体中文",
|
||||
"ACCOUNTING_DEFAULT_CURRENCY": "USD",
|
||||
"ACCOUNTING_DEFAULT_IE_ACCOUNT": "1111-001",
|
||||
"ACCOUNTING_RECURRING": (
|
||||
"debit|1314-001|Pension|Pension for {last_month_name},"
|
||||
"debit|6262-001|Health insurance"
|
||||
"|Health insurance for {last_month_name},"
|
||||
"debit|6261-001|Electricity bill"
|
||||
"|Electricity bill for {last_bimonthly_name},"
|
||||
"debit|6261-001|Water bill|Water bill for {last_bimonthly_name},"
|
||||
"debit|6261-001|Gas bill|Gas bill for {last_bimonthly_name},"
|
||||
"debit|6261-001|Phone bill|Phone bill for {last_month_name},"
|
||||
"credit|4611-001|Payroll|Payroll for {last_month_name}"),
|
||||
})
|
||||
if is_testing:
|
||||
app.config["TESTING"] = True
|
||||
@ -84,11 +72,15 @@ def create_app(is_testing: bool = False) -> Flask:
|
||||
def can_view(self) -> bool:
|
||||
return auth.current_user() is not None \
|
||||
and auth.current_user().username in ["viewer", "editor",
|
||||
"editor2"]
|
||||
"admin"]
|
||||
|
||||
def can_edit(self) -> bool:
|
||||
return auth.current_user() is not None \
|
||||
and auth.current_user().username in ["editor", "editor2"]
|
||||
and auth.current_user().username in ["editor", "admin"]
|
||||
|
||||
def can_admin(self) -> bool:
|
||||
return auth.current_user() is not None \
|
||||
and auth.current_user().username == "admin"
|
||||
|
||||
@property
|
||||
def cls(self) -> t.Type[auth.User]:
|
||||
@ -120,7 +112,7 @@ def init_db_command() -> None:
|
||||
"""Initializes the database."""
|
||||
db.create_all()
|
||||
from .auth import User
|
||||
for username in ["viewer", "editor", "editor2", "nobody"]:
|
||||
for username in ["viewer", "editor", "admin", "nobody"]:
|
||||
if User.query.filter(User.username == username).first() is None:
|
||||
db.session.add(User(username=username))
|
||||
db.session.commit()
|
||||
|
@ -58,8 +58,8 @@ def login() -> redirect:
|
||||
|
||||
:return: The redirection to the home page.
|
||||
"""
|
||||
if request.form.get("username") not in ["viewer", "editor", "editor2",
|
||||
"nobody"]:
|
||||
if request.form.get("username") not in {"viewer", "editor", "admin",
|
||||
"nobody"}:
|
||||
return redirect(url_for("auth.login"))
|
||||
session["user"] = request.form.get("username")
|
||||
return redirect(url_for("home.home"))
|
||||
|
@ -29,7 +29,7 @@ First written: 2023/1/27
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-primary" type="submit" name="username" value="viewer">{{ _("Viewer") }}</button>
|
||||
<button class="btn btn-primary" type="submit" name="username" value="editor">{{ _("Editor") }}</button>
|
||||
<button class="btn btn-primary" type="submit" name="username" value="editor2">{{ _("Editor2") }}</button>
|
||||
<button class="btn btn-primary" type="submit" name="username" value="admin">{{ _("Administrator") }}</button>
|
||||
<button class="btn btn-primary" type="submit" name="username" value="nobody">{{ _("Nobody") }}</button>
|
||||
</form>
|
||||
|
||||
|
@ -9,8 +9,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n"
|
||||
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
|
||||
"POT-Creation-Date: 2023-02-27 10:07+0800\n"
|
||||
"PO-Revision-Date: 2023-02-27 10:08+0800\n"
|
||||
"POT-Creation-Date: 2023-03-24 08:32+0800\n"
|
||||
"PO-Revision-Date: 2023-03-24 08:33+0800\n"
|
||||
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
|
||||
"Language: zh_Hant\n"
|
||||
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
|
||||
@ -18,27 +18,27 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.11.0\n"
|
||||
"Generated-By: Babel 2.12.1\n"
|
||||
|
||||
#: tests/test_site/templates/base.html:23
|
||||
msgid "en"
|
||||
msgstr "zh-Hant"
|
||||
|
||||
#: tests/test_site/templates/base.html:43
|
||||
#: tests/test_site/templates/base.html:46
|
||||
#: tests/test_site/templates/home.html:24
|
||||
msgid "Home"
|
||||
msgstr "首頁"
|
||||
|
||||
#: tests/test_site/templates/base.html:68
|
||||
#: tests/test_site/templates/base.html:71
|
||||
msgid "Log Out"
|
||||
msgstr "登出"
|
||||
|
||||
#: tests/test_site/templates/base.html:78
|
||||
#: tests/test_site/templates/base.html:81
|
||||
#: tests/test_site/templates/login.html:24
|
||||
msgid "Log In"
|
||||
msgstr "登入"
|
||||
|
||||
#: tests/test_site/templates/base.html:119
|
||||
#: tests/test_site/templates/base.html:122
|
||||
msgid "Error:"
|
||||
msgstr "錯誤:"
|
||||
|
||||
@ -51,8 +51,8 @@ msgid "Editor"
|
||||
msgstr "記帳者"
|
||||
|
||||
#: tests/test_site/templates/login.html:32
|
||||
msgid "Editor2"
|
||||
msgstr "記帳者2"
|
||||
msgid "Administrator"
|
||||
msgstr "管理者"
|
||||
|
||||
#: tests/test_site/templates/login.html:33
|
||||
msgid "Nobody"
|
||||
|
@ -26,6 +26,33 @@ from test_site import create_app
|
||||
|
||||
TEST_SERVER: str = "https://testserver"
|
||||
"""The test server URI."""
|
||||
NEXT_URI: str = "/_next"
|
||||
"""The next URI."""
|
||||
|
||||
|
||||
class Accounts:
|
||||
"""The shortcuts to the common accounts."""
|
||||
CASH: str = "1111-001"
|
||||
PETTY_CASH: str = "1112-001"
|
||||
BANK: str = "1113-001"
|
||||
NOTES_RECEIVABLE: str = "1131-001"
|
||||
RECEIVABLE: str = "1141-001"
|
||||
PREPAID: str = "1258-001"
|
||||
NOTES_PAYABLE: str = "2131-001"
|
||||
PAYABLE: str = "2141-001"
|
||||
SALES: str = "4111-001"
|
||||
SERVICE: str = "4611-001"
|
||||
AGENCY: str = "4711-001"
|
||||
RENT_EXPENSE: str = "6252-001"
|
||||
OFFICE: str = "6253-001"
|
||||
TRAVEL: str = "6254-001"
|
||||
POSTAGE: str = "6256-001"
|
||||
UTILITIES: str = "6261-001"
|
||||
INSURANCE: str = "6262-001"
|
||||
MEAL: str = "6272-001"
|
||||
INTEREST: str = "7111-001"
|
||||
DONATION: str = "7481-001"
|
||||
RENT_INCOME: str = "7482-001"
|
||||
|
||||
|
||||
def create_test_app() -> Flask:
|
||||
@ -57,7 +84,6 @@ def get_csrf_token(client: httpx.Client) -> str:
|
||||
return client.get("/.csrf-token").text
|
||||
|
||||
|
||||
|
||||
def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]:
|
||||
"""Returns a user client.
|
||||
|
||||
|
@ -26,36 +26,14 @@ import httpx
|
||||
from flask import Flask
|
||||
|
||||
from test_site import db
|
||||
from testlib import NEXT_URI, Accounts
|
||||
|
||||
NEXT_URI: str = "/_next"
|
||||
"""The next URI."""
|
||||
NON_EMPTY_NOTE: str = " This is \n\na test."
|
||||
"""The stripped content of an non-empty note."""
|
||||
EMPTY_NOTE: str = " \n\n "
|
||||
"""The empty note content."""
|
||||
|
||||
|
||||
class Accounts:
|
||||
"""The shortcuts to the common accounts."""
|
||||
CASH: str = "1111-001"
|
||||
PETTY_CASH: str = "1112-001"
|
||||
BANK: str = "1113-001"
|
||||
NOTES_RECEIVABLE: str = "1131-001"
|
||||
RECEIVABLE: str = "1141-001"
|
||||
PREPAID: str = "1258-001"
|
||||
NOTES_PAYABLE: str = "2131-001"
|
||||
PAYABLE: str = "2141-001"
|
||||
SALES: str = "4111-001"
|
||||
SERVICE: str = "4611-001"
|
||||
AGENCY: str = "4711-001"
|
||||
OFFICE: str = "6153-001"
|
||||
TRAVEL: str = "6154-001"
|
||||
MEAL: str = "6172-001"
|
||||
INTEREST: str = "7111-001"
|
||||
DONATION: str = "7481-001"
|
||||
RENT: str = "7482-001"
|
||||
|
||||
|
||||
def get_add_form(csrf_token: str) -> dict[str, str]:
|
||||
"""Returns the form data to add a new journal entry.
|
||||
|
||||
@ -115,7 +93,7 @@ def get_add_form(csrf_token: str) -> dict[str, str]:
|
||||
"currency-16-debit-9-description": " Gas ",
|
||||
"currency-16-debit-9-amount": "30000",
|
||||
"currency-16-credit-6-no": "6",
|
||||
"currency-16-credit-6-account_code": Accounts.RENT,
|
||||
"currency-16-credit-6-account_code": Accounts.RENT_INCOME,
|
||||
"currency-16-credit-6-description": " Rent ",
|
||||
"currency-16-credit-6-amount": "35000",
|
||||
"currency-16-credit-9-account_code": Accounts.DONATION,
|
||||
@ -349,7 +327,7 @@ def __mess_up_currencies(form: dict[str, str]) -> dict[str, str]:
|
||||
f"{prefix}debit-14-description": " ",
|
||||
f"{prefix}debit-14-amount": "14.55",
|
||||
f"{prefix}credit-16-no": "7",
|
||||
f"{prefix}credit-16-account_code": Accounts.RENT,
|
||||
f"{prefix}credit-16-account_code": Accounts.RENT_INCOME,
|
||||
f"{prefix}credit-16-description": " Bike ",
|
||||
f"{prefix}credit-16-amount": "19.5",
|
||||
f"{prefix}credit-22-no": "5",
|
||||
|
@ -26,8 +26,8 @@ import httpx
|
||||
from flask import Flask
|
||||
|
||||
from test_site import db
|
||||
from testlib_journal_entry import Accounts, match_journal_entry_detail, \
|
||||
NEXT_URI
|
||||
from testlib import NEXT_URI, Accounts
|
||||
from testlib_journal_entry import match_journal_entry_detail
|
||||
|
||||
|
||||
class JournalEntryLineItemData:
|
||||
|
Reference in New Issue
Block a user