Compare commits
	
		
			118 Commits
		
	
	
		
			v0.7.0
			...
			bf2c7bb785
		
	
	| 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 | |||
| d8afadda02 | |||
| c8e1270d8f | |||
| 2a78799404 | |||
| 863d7a9368 | |||
| 6fd37b21d9 | |||
| bbf3ee3320 | |||
| b60cc7902d | |||
| 623313b58a | |||
| d0d2d77a2e | |||
| 494faeffea | |||
| 871a5fd1d8 | |||
| e615ad2690 | |||
| da92a0b42c | |||
| 678d0aa773 | |||
| 9248ba7e3b | |||
| 446087b212 | |||
| a42e7d13a2 | |||
| a82f5091f1 | |||
| 3455827c09 | |||
| 5dccf99a55 | |||
| 8818b46e01 | |||
| 2f3ad99467 | |||
| 592910187b | |||
| cb7a0d377f | |||
| 79175285f8 | |||
| fef474977c | |||
| fa1a55cd3d | 
							
								
								
									
										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.7.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.7.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) | ||||
|   | ||||
| @@ -157,6 +157,9 @@ def delete_account(account: Account) -> redirect: | ||||
|     :return: The redirection to the account list on success, or the account | ||||
|         detail on error. | ||||
|     """ | ||||
|     if not account.can_delete: | ||||
|         flash(s(lazy_gettext("The account cannot be deleted.")), "error") | ||||
|         return redirect(inherit_next(__get_detail_uri(account))) | ||||
|     account.delete() | ||||
|     sort_accounts_in(account.base_code, account.id) | ||||
|     db.session.commit() | ||||
|   | ||||
| @@ -160,6 +160,9 @@ def delete_currency(currency: Currency) -> redirect: | ||||
|     :return: The redirection to the currency list on success, or the currency | ||||
|         detail on error. | ||||
|     """ | ||||
|     if not currency.can_delete: | ||||
|         flash(s(lazy_gettext("The currency cannot be deleted.")), "error") | ||||
|         return redirect(inherit_next(__get_detail_uri(currency))) | ||||
|     currency.delete() | ||||
|     db.session.commit() | ||||
|     flash(s(lazy_gettext("The currency is deleted successfully.")), "success") | ||||
|   | ||||
							
								
								
									
										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,9 +410,11 @@ class DebitLineItemForm(LineItemForm): | ||||
|     """The ID of the original line item.""" | ||||
|     account_code = StringField( | ||||
|         filters=[strip_text], | ||||
|         validators=[ACCOUNT_REQUIRED, | ||||
|         validators=[ | ||||
|             ACCOUNT_REQUIRED, | ||||
|             AccountExists(), | ||||
|                     IsDebitAccount(), | ||||
|             IsDebitAccount(lazy_gettext( | ||||
|                 "This account is not for debit line items.")), | ||||
|             SameAccountAsOriginalLineItem(), | ||||
|             KeepAccountWhenHavingOffset(), | ||||
|             NotStartPayableFromDebit()]) | ||||
| @@ -502,9 +462,11 @@ class CreditLineItemForm(LineItemForm): | ||||
|     """The ID of the original line item.""" | ||||
|     account_code = StringField( | ||||
|         filters=[strip_text], | ||||
|         validators=[ACCOUNT_REQUIRED, | ||||
|         validators=[ | ||||
|             ACCOUNT_REQUIRED, | ||||
|             AccountExists(), | ||||
|                     IsCreditAccount(), | ||||
|             IsCreditAccount(lazy_gettext( | ||||
|                 "This account is not for credit line items.")), | ||||
|             SameAccountAsOriginalLineItem(), | ||||
|             KeepAccountWhenHavingOffset(), | ||||
|             NotStartReceivableFromCredit()]) | ||||
|   | ||||
| @@ -17,12 +17,14 @@ | ||||
| """The description editor. | ||||
|  | ||||
| """ | ||||
| import re | ||||
| import typing as t | ||||
|  | ||||
| import sqlalchemy as sa | ||||
|  | ||||
| from accounting import db | ||||
| from accounting.models import Account, JournalEntryLineItem | ||||
| from accounting.utils.options import options, Recurring | ||||
|  | ||||
|  | ||||
| class DescriptionAccount: | ||||
| @@ -143,6 +145,29 @@ class DescriptionType: | ||||
|         return sorted(self.__tag_dict.values(), key=lambda x: -x.freq) | ||||
|  | ||||
|  | ||||
| class DescriptionRecurring: | ||||
|     """A recurring transaction.""" | ||||
|  | ||||
|     def __init__(self, name: str, account: Account, description_template: str): | ||||
|         """Constructs a recurring transaction. | ||||
|  | ||||
|         :param name: The name. | ||||
|         :param description_template: The description template. | ||||
|         :param account: The account. | ||||
|         """ | ||||
|         self.name: str = name | ||||
|         self.account: DescriptionAccount = DescriptionAccount(account, 0) | ||||
|         self.description_template: str = description_template | ||||
|  | ||||
|     @property | ||||
|     def account_codes(self) -> list[str]: | ||||
|         """Returns the account codes by the order of their frequencies. | ||||
|  | ||||
|         :return: The account codes by the order of their frequencies. | ||||
|         """ | ||||
|         return [self.account.code] | ||||
|  | ||||
|  | ||||
| class DescriptionDebitCredit: | ||||
|     """The description on debit or credit.""" | ||||
|  | ||||
| @@ -163,6 +188,8 @@ class DescriptionDebitCredit: | ||||
|                                DescriptionType] \ | ||||
|             = {x.id: x for x in {self.general, self.travel, self.bus}} | ||||
|         """A dictionary from the type ID to the corresponding tags.""" | ||||
|         self.recurring: list[DescriptionRecurring] = [] | ||||
|         """The recurring transactions.""" | ||||
|  | ||||
|     def add_tag(self, tag_type: t.Literal["general", "travel", "bus"], | ||||
|                 name: str, account: Account, freq: int) -> None: | ||||
| @@ -193,6 +220,10 @@ class DescriptionDebitCredit: | ||||
|                         freq[account.id] = 0 | ||||
|                     freq[account.id] \ | ||||
|                         = freq[account.id] + account.freq | ||||
|         for recurring in self.recurring: | ||||
|             accounts[recurring.account.id] = recurring.account | ||||
|             if recurring.account.id not in freq: | ||||
|                 freq[recurring.account.id] = 0 | ||||
|         return [accounts[y] for y in sorted(freq.keys(), | ||||
|                                             key=lambda x: -freq[x])] | ||||
|  | ||||
| @@ -206,6 +237,14 @@ class DescriptionEditor: | ||||
|         """The debit tags.""" | ||||
|         self.credit: DescriptionDebitCredit = DescriptionDebitCredit("credit") | ||||
|         """The credit tags.""" | ||||
|         self.__init_tags() | ||||
|         self.__init_recurring() | ||||
|  | ||||
|     def __init_tags(self): | ||||
|         """Initializes the tags. | ||||
|  | ||||
|         :return: None. | ||||
|         """ | ||||
|         debit_credit: sa.Label = sa.case( | ||||
|             (JournalEntryLineItem.is_debit, "debit"), | ||||
|             else_="credit").label("debit_credit") | ||||
| @@ -236,6 +275,50 @@ class DescriptionEditor: | ||||
|             debit_credit_dict[row.debit_credit].add_tag( | ||||
|                 row.tag_type, row.tag, accounts[row.account_id], row.freq) | ||||
|  | ||||
|     def __init_recurring(self) -> None: | ||||
|         """Initializes the recurring transactions. | ||||
|  | ||||
|         :return: None. | ||||
|         """ | ||||
|         recurring: Recurring = options.recurring | ||||
|         accounts: dict[str, Account] \ | ||||
|             = 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]: | ||||
|         """Finds and returns the accounts by codes. | ||||
|  | ||||
|         :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,\ | ||||
|                 f"Malformed account code \"{code0}\" for regular transactions." | ||||
|             return sa.and_(Account.base_code == m.group(1), | ||||
|                            Account.no == int(m.group(2))) | ||||
|  | ||||
|         conditions: list[sa.BinaryExpression] \ | ||||
|             = [get_condition(x) for x in codes] | ||||
|         accounts: dict[str, Account] \ | ||||
|             = {x.code: x for x in | ||||
|                Account.query.filter(sa.or_(*conditions)).all()} | ||||
|         for code in codes: | ||||
|             assert code in accounts,\ | ||||
|                 f"Unknown account \"{code}\" for regular transactions." | ||||
|         return accounts | ||||
|  | ||||
|  | ||||
| def get_prefix(string: str | sa.Column, separator: str | sa.Column) \ | ||||
|         -> sa.Function: | ||||
|   | ||||
| @@ -175,6 +175,9 @@ def delete_journal_entry(journal_entry: JournalEntry) -> redirect: | ||||
|     :return: The redirection to the journal entry list on success, or the | ||||
|         journal entry detail on error. | ||||
|     """ | ||||
|     if not journal_entry.can_delete: | ||||
|         flash(s(lazy_gettext("The journal entry cannot be deleted.")), "error") | ||||
|         return redirect(inherit_next(__get_detail_uri(journal_entry))) | ||||
|     journal_entry.delete() | ||||
|     sort_journal_entries_in(journal_entry.date, journal_entry.id) | ||||
|     db.session.commit() | ||||
|   | ||||
| @@ -25,8 +25,8 @@ from datetime import date | ||||
| from decimal import Decimal | ||||
|  | ||||
| import sqlalchemy as sa | ||||
| from flask import current_app | ||||
| from flask_babel import get_locale | ||||
| from babel import Locale | ||||
| from flask_babel import get_locale, get_babel | ||||
| from sqlalchemy import text | ||||
|  | ||||
| from accounting import db | ||||
| @@ -61,11 +61,11 @@ class BaseAccount(db.Model): | ||||
|  | ||||
|         :return: The title in the current locale. | ||||
|         """ | ||||
|         current_locale = str(get_locale()) | ||||
|         if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]: | ||||
|         current_locale: Locale = get_locale() | ||||
|         if current_locale == get_babel().instance.default_locale: | ||||
|             return self.title_l10n | ||||
|         for l10n in self.l10n: | ||||
|             if l10n.locale == current_locale: | ||||
|             if l10n.locale == str(current_locale): | ||||
|                 return l10n.title | ||||
|         return self.title_l10n | ||||
|  | ||||
| @@ -171,11 +171,11 @@ class Account(db.Model): | ||||
|  | ||||
|         :return: The title in the current locale. | ||||
|         """ | ||||
|         current_locale = str(get_locale()) | ||||
|         if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]: | ||||
|         current_locale: Locale = get_locale() | ||||
|         if current_locale == get_babel().instance.default_locale: | ||||
|             return self.title_l10n | ||||
|         for l10n in self.l10n: | ||||
|             if l10n.locale == current_locale: | ||||
|             if l10n.locale == str(current_locale): | ||||
|                 return l10n.title | ||||
|         return self.title_l10n | ||||
|  | ||||
| @@ -189,15 +189,15 @@ class Account(db.Model): | ||||
|         if self.title_l10n is None: | ||||
|             self.title_l10n = value | ||||
|             return | ||||
|         current_locale = str(get_locale()) | ||||
|         if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]: | ||||
|         current_locale: Locale = get_locale() | ||||
|         if current_locale == get_babel().instance.default_locale: | ||||
|             self.title_l10n = value | ||||
|             return | ||||
|         for l10n in self.l10n: | ||||
|             if l10n.locale == current_locale: | ||||
|             if l10n.locale == str(current_locale): | ||||
|                 l10n.title = value | ||||
|                 return | ||||
|         self.l10n.append(AccountL10n(locale=current_locale, title=value)) | ||||
|         self.l10n.append(AccountL10n(locale=str(current_locale), title=value)) | ||||
|  | ||||
|     @property | ||||
|     def is_real(self) -> bool: | ||||
| @@ -236,6 +236,16 @@ class Account(db.Model): | ||||
|                 return True | ||||
|         return False | ||||
|  | ||||
|     @property | ||||
|     def can_delete(self) -> bool: | ||||
|         """Returns whether the account can be deleted. | ||||
|  | ||||
|         :return: True if the account can be deleted, or False otherwise. | ||||
|         """ | ||||
|         if self.code in {"1111-001", "3351-001", "3353-001"}: | ||||
|             return False | ||||
|         return len(self.line_items) == 0 | ||||
|  | ||||
|     def delete(self) -> None: | ||||
|         """Deletes this account. | ||||
|  | ||||
| @@ -259,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"), | ||||
| @@ -280,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"), | ||||
| @@ -381,11 +395,11 @@ class Currency(db.Model): | ||||
|  | ||||
|         :return: The name in the current locale. | ||||
|         """ | ||||
|         current_locale = str(get_locale()) | ||||
|         if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]: | ||||
|         current_locale: Locale = get_locale() | ||||
|         if current_locale == get_babel().instance.default_locale: | ||||
|             return self.name_l10n | ||||
|         for l10n in self.l10n: | ||||
|             if l10n.locale == current_locale: | ||||
|             if l10n.locale == str(current_locale): | ||||
|                 return l10n.name | ||||
|         return self.name_l10n | ||||
|  | ||||
| @@ -399,15 +413,15 @@ class Currency(db.Model): | ||||
|         if self.name_l10n is None: | ||||
|             self.name_l10n = value | ||||
|             return | ||||
|         current_locale = str(get_locale()) | ||||
|         if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]: | ||||
|         current_locale: Locale = get_locale() | ||||
|         if current_locale == get_babel().instance.default_locale: | ||||
|             self.name_l10n = value | ||||
|             return | ||||
|         for l10n in self.l10n: | ||||
|             if l10n.locale == current_locale: | ||||
|             if l10n.locale == str(current_locale): | ||||
|                 l10n.name = value | ||||
|                 return | ||||
|         self.l10n.append(CurrencyL10n(locale=current_locale, name=value)) | ||||
|         self.l10n.append(CurrencyL10n(locale=str(current_locale), name=value)) | ||||
|  | ||||
|     @property | ||||
|     def is_modified(self) -> bool: | ||||
| @@ -422,6 +436,17 @@ class Currency(db.Model): | ||||
|                 return True | ||||
|         return False | ||||
|  | ||||
|     @property | ||||
|     def can_delete(self) -> bool: | ||||
|         """Returns whether the currency can be deleted. | ||||
|  | ||||
|         :return: True if the currency can be deleted, or False otherwise. | ||||
|         """ | ||||
|         from accounting.template_globals import default_currency_code | ||||
|         if self.code == default_currency_code(): | ||||
|             return False | ||||
|         return len(self.line_items) == 0 | ||||
|  | ||||
|     def delete(self) -> None: | ||||
|         """Deletes the currency. | ||||
|  | ||||
| @@ -597,14 +622,10 @@ class JournalEntry(db.Model): | ||||
|  | ||||
|         :return: True if the journal entry can be deleted, or False otherwise. | ||||
|         """ | ||||
|         if not hasattr(self, "__can_delete"): | ||||
|             def has_offset() -> bool: | ||||
|         for line_item in self.line_items: | ||||
|             if len(line_item.offsets) > 0: | ||||
|                         return True | ||||
|                 return False | ||||
|             setattr(self, "__can_delete", not has_offset()) | ||||
|         return getattr(self, "__can_delete") | ||||
|         return True | ||||
|  | ||||
|     def delete(self) -> None: | ||||
|         """Deletes the journal entry. | ||||
| @@ -739,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. | ||||
| @@ -749,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. | ||||
|  | ||||
|   | ||||
| @@ -76,7 +76,7 @@ | ||||
|         height: 3.2rem; | ||||
|         width: 3.2rem; | ||||
|         border-radius: 50%; | ||||
|         margin-left: 1rem; | ||||
|         margin-right: 0.5rem; | ||||
|     } | ||||
|     .accounting-toolbar > a.btn, .accounting-toolbar > .btn-group > a.btn { | ||||
|         padding-top: 0.7rem; | ||||
| @@ -315,6 +315,30 @@ a.accounting-report-table-row { | ||||
|     padding-left: 1rem; | ||||
| } | ||||
|  | ||||
| /* The description editor */ | ||||
| .accounting-description-editor-buttons .btn { | ||||
|     margin-bottom: 0.3rem; | ||||
| } | ||||
|  | ||||
| /* The order of the journal entries in a same day */ | ||||
| .accounting-journal-entry-order-item, .accounting-journal-entry-order-item:hover { | ||||
|     color: inherit; | ||||
|     text-decoration: none; | ||||
| } | ||||
| .accounting-journal-entry-order-item-currency { | ||||
|     margin-left: 0.5rem; | ||||
|     border-top: thin solid lightgray; | ||||
|     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; | ||||
| @@ -339,7 +363,7 @@ a.accounting-report-table-row { | ||||
| .accounting-material-fab { | ||||
|     position: fixed; | ||||
|     right: 2rem; | ||||
|     bottom: 1rem; | ||||
|     bottom: 2rem; | ||||
|     z-index: 10; | ||||
|     flex-direction: column-reverse; | ||||
| } | ||||
|   | ||||
| @@ -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 === "") { | ||||
|     #filterOptions() { | ||||
|         let isAnyMatched = false; | ||||
|         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; | ||||
|             if (option.isMatched(this.#query.value)) { | ||||
|                 option.setShown(true); | ||||
|                 isAnyMatched = true; | ||||
|             } else { | ||||
|                     option.classList.add("d-none"); | ||||
|                 option.setShown(false); | ||||
|             } | ||||
|         } | ||||
|             if (!hasAnyMatched) { | ||||
|         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; | ||||
|   | ||||
| @@ -32,7 +32,7 @@ class DescriptionEditor { | ||||
|      * The line item editor | ||||
|      * @type {JournalEntryLineItemEditor} | ||||
|      */ | ||||
|     #lineItemEditor; | ||||
|     lineItemEditor; | ||||
|  | ||||
|     /** | ||||
|      * The description editor form | ||||
| @@ -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; | ||||
| @@ -102,7 +102,7 @@ class DescriptionEditor { | ||||
|  | ||||
|     /** | ||||
|      * The tab planes | ||||
|      * @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, regular: RegularPaymentTab, annotation: AnnotationTab}} | ||||
|      * @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, recurring: RecurringTransactionTab, annotation: AnnotationTab}} | ||||
|      */ | ||||
|     tabPlanes = {}; | ||||
|  | ||||
| @@ -113,7 +113,7 @@ class DescriptionEditor { | ||||
|      * @param debitCredit {string} either "debit" or "credit" | ||||
|      */ | ||||
|     constructor(lineItemEditor, debitCredit) { | ||||
|         this.#lineItemEditor = lineItemEditor; | ||||
|         this.lineItemEditor = lineItemEditor; | ||||
|         this.debitCredit = debitCredit; | ||||
|         this.prefix = "accounting-description-editor-" + debitCredit; | ||||
|         this.#form = document.getElementById(this.prefix); | ||||
| @@ -125,14 +125,14 @@ class DescriptionEditor { | ||||
|         // noinspection JSValidateTypes | ||||
|         this.#accountButtons = Array.from(document.getElementsByClassName(this.prefix + "-account")); | ||||
|  | ||||
|         for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RegularPaymentTab, AnnotationTab]) { | ||||
|         for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RecurringTransactionTab, AnnotationTab]) { | ||||
|             const tab = new cls(this); | ||||
|             this.tabPlanes[tab.tabId()] = tab; | ||||
|         } | ||||
|         this.currentTab = this.tabPlanes.general; | ||||
|         this.#initializeSuggestedAccounts(); | ||||
|         this.description.onchange = () => this.#onDescriptionChange(); | ||||
|         this.#offsetButton.onclick = () => this.#lineItemEditor.originalLineItemSelector.onOpen(); | ||||
|         this.#offsetButton.onclick = () => this.lineItemEditor.originalLineItemSelector.onOpen(); | ||||
|         this.#form.onsubmit = () => { | ||||
|             if (this.currentTab.validate()) { | ||||
|                 this.#submit(); | ||||
| @@ -147,7 +147,7 @@ class DescriptionEditor { | ||||
|      */ | ||||
|     #onDescriptionChange() { | ||||
|         this.description.value = this.description.value.trim(); | ||||
|         for (const tabPlane of [this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) { | ||||
|         for (const tabPlane of [this.tabPlanes.recurring, this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) { | ||||
|             if (tabPlane.populate()) { | ||||
|                 break; | ||||
|             } | ||||
| @@ -158,27 +158,31 @@ class DescriptionEditor { | ||||
|     /** | ||||
|      * Filters the suggested accounts. | ||||
|      * | ||||
|      * @param tagButton {HTMLButtonElement|null} the tag button | ||||
|      * @param tagButton {HTMLButtonElement} the tag button | ||||
|      */ | ||||
|     filterSuggestedAccounts(tagButton) { | ||||
|         for (const accountButton of this.#accountButtons) { | ||||
|             accountButton.classList.add("d-none"); | ||||
|         } | ||||
|         if (tagButton === null) { | ||||
|             this.#selectAccount(null); | ||||
|             return; | ||||
|         } | ||||
|         this.clearSuggestedAccounts(); | ||||
|         const suggested = JSON.parse(tagButton.dataset.accounts); | ||||
|         let selectedAccountButton = null; | ||||
|         for (const accountButton of this.#accountButtons) { | ||||
|             if (suggested.includes(accountButton.dataset.code)) { | ||||
|                 accountButton.classList.remove("d-none"); | ||||
|                 if (accountButton.dataset.code === suggested[0]) { | ||||
|                     selectedAccountButton = accountButton; | ||||
|                     this.#selectAccount(accountButton); | ||||
|                     return; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         this.#selectAccount(selectedAccountButton); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Clears the suggested accounts. | ||||
|      * | ||||
|      */ | ||||
|     clearSuggestedAccounts() { | ||||
|         for (const accountButton of this.#accountButtons) { | ||||
|             accountButton.classList.add("d-none"); | ||||
|         } | ||||
|         this.#selectAccount(null); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -215,9 +219,9 @@ class DescriptionEditor { | ||||
|     #submit() { | ||||
|         bootstrap.Modal.getOrCreateInstance(this.#modal).hide(); | ||||
|         if (this.#selectedAccount !== null) { | ||||
|             this.#lineItemEditor.saveDescriptionWithAccount(this.description.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text, this.#selectedAccount.classList.contains("accounting-account-is-need-offset")); | ||||
|             this.lineItemEditor.saveDescriptionWithAccount(this.description.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text, this.#selectedAccount.classList.contains("accounting-account-is-need-offset")); | ||||
|         } else { | ||||
|             this.#lineItemEditor.saveDescription(this.description.value); | ||||
|             this.lineItemEditor.saveDescription(this.description.value); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -227,7 +231,7 @@ class DescriptionEditor { | ||||
|      */ | ||||
|     onOpen() { | ||||
|         this.#reset(); | ||||
|         this.description.value = this.#lineItemEditor.description === null? "": this.#lineItemEditor.description; | ||||
|         this.description.value = this.lineItemEditor.description === null? "": this.lineItemEditor.description; | ||||
|         this.#onDescriptionChange(); | ||||
|     } | ||||
|  | ||||
| @@ -274,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; | ||||
| @@ -418,7 +422,7 @@ class TagTabPlane extends TabPlane { | ||||
|             } | ||||
|         } | ||||
|         if (!isMatched) { | ||||
|             this.editor.filterSuggestedAccounts(null); | ||||
|             this.editor.clearSuggestedAccounts(); | ||||
|         } | ||||
|         this.validateTag(); | ||||
|     } | ||||
| @@ -436,14 +440,13 @@ class TagTabPlane extends TabPlane { | ||||
|      */ | ||||
|     switchToMe() { | ||||
|         super.switchToMe(); | ||||
|         let selectedTagButton = null; | ||||
|         for (const tagButton of this.tagButtons) { | ||||
|             if (tagButton.classList.contains("btn-primary")) { | ||||
|                 selectedTagButton = tagButton; | ||||
|                 break; | ||||
|                 this.editor.filterSuggestedAccounts(tagButton); | ||||
|                 return; | ||||
|             } | ||||
|         } | ||||
|         this.editor.filterSuggestedAccounts(selectedTagButton); | ||||
|         this.editor.clearSuggestedAccounts(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -561,13 +564,6 @@ class GeneralTagTab extends TagTabPlane { | ||||
|             this.tag.value = found[1]; | ||||
|             this.onTagChange(); | ||||
|         } | ||||
|         for (const tagButton of this.tagButtons) { | ||||
|             if (tagButton.dataset.value === this.tag.value) { | ||||
|                 tagButton.classList.remove("btn-outline-primary"); | ||||
|                 tagButton.classList.add("btn-primary"); | ||||
|                 this.editor.filterSuggestedAccounts(tagButton); | ||||
|             } | ||||
|         } | ||||
|         this.switchToMe(); | ||||
|         return true; | ||||
|     } | ||||
| @@ -732,13 +728,6 @@ class GeneralTripTab extends TagTabPlane { | ||||
|             } | ||||
|         } | ||||
|         this.#to.value = found[4]; | ||||
|         for (const tagButton of this.tagButtons) { | ||||
|             if (tagButton.dataset.value === this.tag.value) { | ||||
|                 tagButton.classList.remove("btn-outline-primary"); | ||||
|                 tagButton.classList.add("btn-primary"); | ||||
|                 this.editor.filterSuggestedAccounts(tagButton); | ||||
|             } | ||||
|         } | ||||
|         this.switchToMe(); | ||||
|         return true; | ||||
|     } | ||||
| @@ -917,14 +906,6 @@ class BusTripTab extends TagTabPlane { | ||||
|         this.#route.value = found[2]; | ||||
|         this.#from.value = found[3]; | ||||
|         this.#to.value = found[4]; | ||||
|         for (const tagButton of this.tagButtons) { | ||||
|             if (tagButton.dataset.value === this.tag.value) { | ||||
|                 tagButton.classList.remove("btn-outline-primary"); | ||||
|                 tagButton.classList.add("btn-primary"); | ||||
|                 this.editor.filterSuggestedAccounts(tagButton); | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
|         this.switchToMe(); | ||||
|         return true; | ||||
|     } | ||||
| @@ -985,19 +966,24 @@ class BusTripTab extends TagTabPlane { | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * The regular payment tab plane. | ||||
|  * The recurring transaction tab plane. | ||||
|  * | ||||
|  * @private | ||||
|  */ | ||||
| class RegularPaymentTab extends TabPlane { | ||||
| class RecurringTransactionTab extends TabPlane { | ||||
|  | ||||
|     /** | ||||
|      * The payment buttons | ||||
|      * The month names | ||||
|      * @type {string[]} | ||||
|      */ | ||||
|     #monthNames; | ||||
|  | ||||
|     /** | ||||
|      * The buttons of the recurring items | ||||
|      * @type {HTMLButtonElement[]} | ||||
|      */ | ||||
|     #payments; | ||||
|     #itemButtons; | ||||
|  | ||||
|     // noinspection JSValidateTypes | ||||
|     /** | ||||
|      * Constructs a tab plane. | ||||
|      * | ||||
| @@ -1006,8 +992,44 @@ class RegularPaymentTab extends TabPlane { | ||||
|      */ | ||||
|     constructor(editor) { | ||||
|         super(editor); | ||||
|         this.#monthNames = [ | ||||
|             "", | ||||
|             A_("January"), A_("February"), A_("March"), A_("April"), | ||||
|             A_("May"), A_("June"), A_("July"), A_("August"), | ||||
|             A_("September"), A_("October"), A_("November"), A_("December"), | ||||
|         ]; | ||||
|         // noinspection JSValidateTypes | ||||
|         this.#payments = Array.from(document.getElementsByClassName(this.prefix + "-payment")); | ||||
|         this.#itemButtons = Array.from(document.getElementsByClassName(this.prefix + "-item")); | ||||
|         for (const itemButton of this.#itemButtons) { | ||||
|             itemButton.onclick = () => { | ||||
|                 this.reset(); | ||||
|                 itemButton.classList.add("btn-primary"); | ||||
|                 itemButton.classList.remove("btn-outline-primary"); | ||||
|                 this.editor.description.value = this.#getDescription(itemButton); | ||||
|                 this.editor.filterSuggestedAccounts(itemButton); | ||||
|             }; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the description for a recurring item. | ||||
|      * | ||||
|      * @param itemButton {HTMLButtonElement} the recurring item | ||||
|      * @return {string} the description of the recurring item | ||||
|      */ | ||||
|     #getDescription(itemButton) { | ||||
|         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); | ||||
|         const lastBimonthlyTo = ((thisMonth + thisMonth % 2 + 9) % 12 + 1); | ||||
|         return itemButton.dataset.template | ||||
|             .replaceAll("{this_month_number}", String(thisMonth)) | ||||
|             .replaceAll("{this_month_name}", this.#monthNames[thisMonth]) | ||||
|             .replaceAll("{last_month_number}", String(lastMonth)) | ||||
|             .replaceAll("{last_month_name}", this.#monthNames[lastMonth]) | ||||
|             .replaceAll("{last_bimonthly_number}", String(lastBimonthlyFrom) + "–" + String(lastBimonthlyTo)) | ||||
|             .replaceAll("{last_bimonthly_name}", this.#monthNames[lastBimonthlyFrom] + "–" + this.#monthNames[lastBimonthlyTo]); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -1017,7 +1039,7 @@ class RegularPaymentTab extends TabPlane { | ||||
|      * @abstract | ||||
|      */ | ||||
|     tabId() { | ||||
|         return "regular"; | ||||
|         return "recurring"; | ||||
|     }; | ||||
|  | ||||
|     /** | ||||
| @@ -1026,9 +1048,9 @@ class RegularPaymentTab extends TabPlane { | ||||
|      * @override | ||||
|      */ | ||||
|     reset() { | ||||
|         for (const payment of this.#payments) { | ||||
|             payment.classList.remove("btn-primary"); | ||||
|             payment.classList.add("btn-outline-primary"); | ||||
|         for (const itemButton of this.#itemButtons) { | ||||
|             itemButton.classList.remove("btn-primary"); | ||||
|             itemButton.classList.add("btn-outline-primary"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -1039,9 +1061,32 @@ class RegularPaymentTab extends TabPlane { | ||||
|      * @override | ||||
|      */ | ||||
|     populate() { | ||||
|         for (const itemButton of this.#itemButtons) { | ||||
|             if (this.#getDescription(itemButton) === this.editor.description.value) { | ||||
|                 itemButton.classList.add("btn-primary"); | ||||
|                 itemButton.classList.remove("btn-outline-primary"); | ||||
|                 this.switchToMe(); | ||||
|                 return true; | ||||
|             } | ||||
|         } | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Switches to the tab plane. | ||||
|      * | ||||
|      */ | ||||
|     switchToMe() { | ||||
|         super.switchToMe(); | ||||
|         for (const itemButton of this.#itemButtons) { | ||||
|             if (itemButton.classList.contains("btn-primary")) { | ||||
|                 this.editor.filterSuggestedAccounts(itemButton); | ||||
|                 return; | ||||
|             } | ||||
|         } | ||||
|         this.editor.clearSuggestedAccounts(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Validates the input in the tab plane. | ||||
|      * | ||||
|   | ||||
							
								
								
									
										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,15 +683,36 @@ 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"); | ||||
|                 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 { | ||||
|                     lineItem.deleteButton.classList.remove("d-none"); | ||||
|                 } | ||||
|             } | ||||
|             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"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -670,10 +721,10 @@ class DebitCreditSubForm { | ||||
|      * | ||||
|      * @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,5 +34,4 @@ def default_currency_code() -> str: | ||||
|  | ||||
|     :return: The default currency code. | ||||
|     """ | ||||
|     with current_app.app_context(): | ||||
|         return current_app.config.get("DEFAULT_CURRENCY", "USD") | ||||
|     return options.default_currency_code | ||||
|   | ||||
| @@ -25,38 +25,45 @@ First written: 2023/1/31 | ||||
|  | ||||
| {% block content %} | ||||
|  | ||||
| <div class="btn-group mb-3"> | ||||
|   <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> | ||||
|   {% 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 }}"> | ||||
|       <i class="fa-solid fa-gear"></i> | ||||
|       {{ A_("Settings") }} | ||||
|     <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> | ||||
|     {{ A_("Order") }} | ||||
|     <span class="d-none d-md-inline">{{ A_("Order") }}</span> | ||||
|   </a> | ||||
|   {% if accounting_can_edit() %} | ||||
|     {% if obj.can_delete %} | ||||
|       <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal"> | ||||
|         <i class="fa-solid fa-trash"></i> | ||||
|       {{ A_("Delete") }} | ||||
|         <span class="d-none d-md-inline">{{ A_("Delete") }}</span> | ||||
|       </button> | ||||
|     {% else %} | ||||
|       <button class="btn btn-secondary" type="button" disabled="disabled"> | ||||
|         <i class="fa-solid fa-trash"></i> | ||||
|         <span class="d-none d-md-inline">{{ A_("Delete") }}</span> | ||||
|       </button> | ||||
|     {% endif %} | ||||
|   {% endif %} | ||||
| </div> | ||||
|  | ||||
| {% 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> | ||||
| {% endif %} | ||||
|  | ||||
| {% if accounting_can_edit() %} | ||||
| {% if accounting_can_edit() and obj.can_delete %} | ||||
|   <form action="{{ url_for("accounting.account.delete", account=obj) }}" method="post"> | ||||
|     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||
|     {% if request.args.next %} | ||||
|   | ||||
| @@ -27,10 +27,10 @@ First written: 2023/2/1 | ||||
|  | ||||
| {% block content %} | ||||
|  | ||||
| <div class="btn-group mb-3"> | ||||
| <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="btn-group mb-3"> | ||||
|   <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="btn-group mb-3"> | ||||
|   <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 %} | ||||
|   | ||||
| @@ -25,34 +25,41 @@ First written: 2023/2/6 | ||||
|  | ||||
| {% block content %} | ||||
|  | ||||
| <div class="btn-group mb-3"> | ||||
|   <a class="btn btn-primary" href="{{ url_for("accounting.currency.list")|accounting_or_next }}"> | ||||
| <div class="mb-3 accounting-toolbar"> | ||||
|   <a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.list")|accounting_or_next }}"> | ||||
|     <i class="fa-solid fa-circle-chevron-left"></i> | ||||
|     {{ 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 }}"> | ||||
|       <i class="fa-solid fa-gear"></i> | ||||
|       {{ A_("Settings") }} | ||||
|     <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> | ||||
|   {% endif %} | ||||
|   {% if accounting_can_edit() %} | ||||
|     {% if obj.can_delete %} | ||||
|       <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal"> | ||||
|         <i class="fa-solid fa-trash"></i> | ||||
|       {{ A_("Delete") }} | ||||
|         <span class="d-none d-md-inline">{{ A_("Delete") }}</span> | ||||
|       </button> | ||||
|     {% else %} | ||||
|       <button class="btn btn-secondary" type="button" disabled="disabled"> | ||||
|         <i class="fa-solid fa-trash"></i> | ||||
|         <span class="d-none d-md-inline">{{ A_("Delete") }}</span> | ||||
|       </button> | ||||
|     {% endif %} | ||||
|   {% endif %} | ||||
| </div> | ||||
|  | ||||
| {% 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> | ||||
| {% endif %} | ||||
|  | ||||
| {% if accounting_can_edit() %} | ||||
| {% if accounting_can_edit() and obj.can_delete %} | ||||
|   <form action="{{ url_for("accounting.currency.delete", currency=obj) }}" method="post"> | ||||
|     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||
|     {% if request.args.next %} | ||||
|   | ||||
| @@ -27,10 +27,10 @@ First written: 2023/2/6 | ||||
|  | ||||
| {% block content %} | ||||
|  | ||||
| <div class="btn-group mb-3"> | ||||
| <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 %} | ||||
|   | ||||
| @@ -21,10 +21,10 @@ First written: 2023/2/26 | ||||
| #} | ||||
| {% extends "accounting/journal-entry/include/detail.html" %} | ||||
|  | ||||
| {% block to_transfer %} | ||||
|   <a class="btn btn-primary" 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> | ||||
|     {{ A_("To Transfer") }} | ||||
| {% block as_trasfer %} | ||||
|   <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> | ||||
| {% endblock %} | ||||
|  | ||||
|   | ||||
| @@ -19,57 +19,15 @@ form-currency.html: The currency sub-form in the cash disbursement journal entry | ||||
| Author: imacat@mail.imacat.idv.tw (imacat) | ||||
| First written: 2023/2/25 | ||||
| #} | ||||
| <div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}"> | ||||
|   <input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}"> | ||||
|   <input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}"> | ||||
|   <div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}"> | ||||
|     <div class="d-flex justify-content-between mt-2 mb-3"> | ||||
|       <div class="form-floating accounting-currency-content"> | ||||
|         <select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code" {% if currency_code_is_locked %} disabled="disabled" {% endif %}> | ||||
|           {% for currency in accounting_currency_options() %} | ||||
|             <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option> | ||||
|           {% endfor %} | ||||
|         </select> | ||||
|         <label class="form-label" for="accounting-currency-{{ currency_index }}-code-select">{{ A_("Currency") }}</label> | ||||
|         <div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div> | ||||
|       </div> | ||||
| {% extends "accounting/journal-entry/include/form-currency.html" %} | ||||
|  | ||||
|       <div> | ||||
|         <button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}"> | ||||
|           <i class="fas fa-minus"></i> | ||||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class="mb-3"> | ||||
|       <div id="accounting-currency-{{ currency_index }}-debit" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}"> | ||||
|         <label class="form-label" for="accounting-currency-{{ currency_index }}-debit">{{ A_("Content") }}</label> | ||||
|         <ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-line-item-list"> | ||||
|           {% for line_item_form in debit_forms %} | ||||
| {% block line_items %} | ||||
|   {% with currency_index = currency_index, | ||||
|           debit_credit = "debit", | ||||
|                     line_item_index = loop.index, | ||||
|                     only_one_line_item_form = debit_forms|length == 1, | ||||
|                     form = line_item_form.form %} | ||||
|               {% include "accounting/journal-entry/include/form-line-item.html" %} | ||||
|           line_item_forms = debit_forms, | ||||
|           header = A_("Content"), | ||||
|           debit_credit_total = debit_total, | ||||
|           debit_credit_errors = debit_errors %} | ||||
|     {% include "accounting/journal-entry/include/form-debit-credit.html" %} | ||||
|   {% endwith %} | ||||
|           {% endfor %} | ||||
|         </ul> | ||||
|  | ||||
|         <div class="d-flex justify-content-between mb-2"> | ||||
|           <div>{{ A_("Total") }}</div> | ||||
|           <div><span id="accounting-currency-{{ currency_index }}-debit-total" class="badge rounded-pill bg-primary">{{ debit_total }}</span></div> | ||||
|         </div> | ||||
|  | ||||
|         <div> | ||||
|           <button id="accounting-currency-{{ currency_index }}-debit-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="debit" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal"> | ||||
|             <i class="fas fa-plus"></i> | ||||
|             {{ A_("New") }} | ||||
|           </button> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div id="accounting-currency-{{ currency_index }}-debit-error" class="invalid-feedback">{% if debit_errors %}{{ debit_errors[0] }}{% endif %}</div> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div> | ||||
| </div> | ||||
| {% endblock %} | ||||
|   | ||||
| @@ -32,7 +32,7 @@ First written: 2023/2/25 | ||||
|               currency_code_is_locked = currency_form.is_code_locked, | ||||
|               debit_forms = currency_form.debit, | ||||
|               debit_errors = currency_form.debit_errors, | ||||
|               debit_total = currency_form.form.debit_total|accounting_format_amount %} | ||||
|               debit_total = currency_form.debit_total|accounting_format_amount %} | ||||
|         {% include "accounting/journal-entry/disbursement/include/form-currency.html" %} | ||||
|       {% endwith %} | ||||
|     {% endfor %} | ||||
|   | ||||
| @@ -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 %} | ||||
|   | ||||
| @@ -55,8 +55,8 @@ First written: 2023/2/28 | ||||
|               </span> | ||||
|             </li> | ||||
|             <li class="nav-item"> | ||||
|               <span id="accounting-description-editor-{{ description_editor.debit_credit }}-regular-tab" class="nav-link accounting-clickable" aria-current="false"> | ||||
|                 {{ A_("Regular") }} | ||||
|               <span id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab" class="nav-link accounting-clickable" aria-current="false"> | ||||
|                 {{ A_("Recurring") }} | ||||
|               </span> | ||||
|             </li> | ||||
|             <li class="nav-item"> | ||||
| @@ -74,7 +74,7 @@ First written: 2023/2/28 | ||||
|               <div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag-error" class="invalid-feedback"></div> | ||||
|             </div> | ||||
|  | ||||
|             <div> | ||||
|             <div class="accounting-description-editor-buttons"> | ||||
|               {% for tag in description_editor.general.tags %} | ||||
|                 <button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-general-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}"> | ||||
|                   {{ tag }} | ||||
| @@ -91,7 +91,7 @@ First written: 2023/2/28 | ||||
|               <div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag-error" class="invalid-feedback"></div> | ||||
|             </div> | ||||
|  | ||||
|             <div> | ||||
|             <div class="accounting-description-editor-buttons"> | ||||
|               {% for tag in description_editor.travel.tags %} | ||||
|                 <button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-travel-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}"> | ||||
|                   {{ tag }} | ||||
| @@ -99,7 +99,7 @@ First written: 2023/2/28 | ||||
|               {% endfor %} | ||||
|             </div> | ||||
|  | ||||
|             <div class="d-flex justify-content-between mt-2"> | ||||
|             <div class="d-flex justify-content-between"> | ||||
|               <div class="form-floating"> | ||||
|                 <input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from" class="form-control" type="text" value="" placeholder=" "> | ||||
|                 <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from">{{ A_("From") }}</label> | ||||
| @@ -132,7 +132,7 @@ First written: 2023/2/28 | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <div> | ||||
|             <div class="accounting-description-editor-buttons"> | ||||
|               {% for tag in description_editor.bus.tags %} | ||||
|                 <button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-bus-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}"> | ||||
|                   {{ tag }} | ||||
| @@ -140,7 +140,7 @@ First written: 2023/2/28 | ||||
|               {% endfor %} | ||||
|             </div> | ||||
|  | ||||
|             <div class="d-flex justify-content-between mt-2"> | ||||
|             <div class="d-flex justify-content-between"> | ||||
|               <div class="form-floating me-2"> | ||||
|                 <input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from" class="form-control" type="text" value="" placeholder=" "> | ||||
|                 <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from">{{ A_("From") }}</label> | ||||
| @@ -154,9 +154,15 @@ First written: 2023/2/28 | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|           {# A regular income or payment #} | ||||
|           <div id="accounting-description-editor-{{ description_editor.debit_credit }}-regular-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-regular-tab"> | ||||
|             {# TODO: To be done #} | ||||
|           {# A recurring transaction #} | ||||
|           <div id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab"> | ||||
|             <div class="accounting-description-editor-buttons"> | ||||
|               {% for recurring in description_editor.recurring %} | ||||
|                 <button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-recurring-item" type="button" tabindex="-1" data-template="{{ recurring.description_template }}" data-accounts="{{ recurring.account_codes|tojson|forceescape }}"> | ||||
|                   {{ recurring.name }} | ||||
|                 </button> | ||||
|               {% endfor %} | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|           {# The annotation #} | ||||
|   | ||||
| @@ -25,32 +25,32 @@ First written: 2023/2/26 | ||||
|  | ||||
| {% block content %} | ||||
|  | ||||
| <div class="btn-group mb-3"> | ||||
|   <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> | ||||
|   {% 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 }}"> | ||||
|       <i class="fa-solid fa-gear"></i> | ||||
|       {{ A_("Settings") }} | ||||
|     <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> | ||||
|     {{ A_("Order") }} | ||||
|     <span class="d-none d-md-inline">{{ A_("Order") }}</span> | ||||
|   </a> | ||||
|   {% if accounting_can_edit() %} | ||||
|     {% block to_transfer %}{% endblock %} | ||||
|     {% block as_trasfer %}{% endblock %} | ||||
|     {% if obj.can_delete %} | ||||
|       <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal"> | ||||
|         <i class="fa-solid fa-trash"></i> | ||||
|         {{ A_("Delete") }} | ||||
|         <span class="d-none d-md-inline">{{ A_("Delete") }}</span> | ||||
|       </button> | ||||
|     {% else %} | ||||
|       <button class="btn btn-secondary" type="button" disabled="disabled"> | ||||
|         <i class="fa-solid fa-trash"></i> | ||||
|         {{ A_("Delete") }} | ||||
|         <span class="d-none d-md-inline">{{ A_("Delete") }}</span> | ||||
|       </button> | ||||
|     {% endif %} | ||||
|   {% endif %} | ||||
| @@ -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> | ||||
|   | ||||
| @@ -0,0 +1,47 @@ | ||||
| {# | ||||
| The Mia! Accounting Flask Project | ||||
| form-currency.html: The currency sub-form in 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/3/21 | ||||
| #} | ||||
| <div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}"> | ||||
|   <input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}"> | ||||
|   <input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}"> | ||||
|   <div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}"> | ||||
|     <div class="d-flex justify-content-between mt-2 mb-3"> | ||||
|       <div class="form-floating accounting-currency-content"> | ||||
|         <select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" {% if currency_code_is_locked %} disabled="disabled" {% endif %}> | ||||
|           {% for currency in accounting_currency_options() %} | ||||
|             <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option> | ||||
|           {% endfor %} | ||||
|         </select> | ||||
|         <label class="form-label" for="accounting-currency-{{ currency_index }}-code-select">{{ A_("Currency") }}</label> | ||||
|         <div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div> | ||||
|       </div> | ||||
|  | ||||
|       <div> | ||||
|         <button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}"> | ||||
|           <i class="fas fa-minus"></i> | ||||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     {% block line_items %}{% endblock %} | ||||
|   </div> | ||||
|   <div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div> | ||||
| </div> | ||||
| @@ -0,0 +1,51 @@ | ||||
| {# | ||||
| The Mia! Accounting Flask Project | ||||
| form-debit-credit.html: The debit or credit line items in 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/3/21 | ||||
| #} | ||||
| <div class="mb-2"> | ||||
|   <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}" class="form-control accounting-material-text-field {% if line_item_forms %} accounting-not-empty {% else %} accounting-clickable {% endif %} {% if debit_errors %} is-invalid {% endif %}"> | ||||
|     <label class="form-label" for="accounting-currency-{{ currency_index }}-{{ debit_credit }}">{{ header }}</label> | ||||
|     <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-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 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> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-error" class="invalid-feedback">{% if debit_credit_errors %}{{ debit_credit_errors[0] }}{% endif %}</div> | ||||
| </div> | ||||
| @@ -25,17 +25,17 @@ 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="btn-group mb-3"> | ||||
| <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> | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,56 @@ | ||||
| {# | ||||
| The Mia! Accounting Flask Project | ||||
| order-journal-entry.html: The journal entry in the journal entry order page | ||||
|  | ||||
|  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/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> | ||||
|     {% if journal_entry.is_cash_disbursement %} | ||||
|       {{ A_("Cash Disbursement") }} | ||||
|     {% elif journal_entry.is_cash_receipt %} | ||||
|       {{ A_("Cash Receipt") }} | ||||
|     {% else %} | ||||
|       {{ A_("Transfer") }} | ||||
|     {% endif %} | ||||
|   </div> | ||||
|   {% for currency in journal_entry.currencies %} | ||||
|     <div class="d-flex justify-content-between accounting-journal-entry-order-item-currency"> | ||||
|       <div> | ||||
|         {% if not journal_entry.is_cash_receipt %} | ||||
|           {% for line_item in currency.debit %} | ||||
|             <div>{{ line_item.description|accounting_default }}</div> | ||||
|           {% endfor %} | ||||
|         {% endif %} | ||||
|         {% if not journal_entry.is_cash_disbursement %} | ||||
|           {% for line_item in currency.credit %} | ||||
|             <div class="accounting-mobile-journal-credit">{{ line_item.description|accounting_default }}</div> | ||||
|           {% endfor %} | ||||
|         {% endif %} | ||||
|       </div> | ||||
|       <div> | ||||
|         <span class="badge bg-info rounded-pill"> | ||||
|           {% if currency.code != accounting_default_currency_code() %} | ||||
|             {{ currency.code }} | ||||
|           {% endif %} | ||||
|           {{ currency.debit_total|accounting_format_amount }} | ||||
|         </span> | ||||
|       </div> | ||||
|     </div> | ||||
|   {% endfor %} | ||||
| </a> | ||||
| @@ -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="btn-group mb-3"> | ||||
|   <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> | ||||
|  | ||||
| @@ -47,9 +47,9 @@ First written: 2023/2/26 | ||||
|       {% for item in list %} | ||||
|         <li class="list-group-item d-flex justify-content-between" data-id="{{ item.id }}"> | ||||
|           <input id="accounting-order-{{ item.id }}-no" type="hidden" name="{{ item.id }}-no" value="{{ loop.index }}"> | ||||
|           <div> | ||||
|             {{ item }} | ||||
|           </div> | ||||
|           {% with journal_entry = item %} | ||||
|             {% include "accounting/journal-entry/include/order-journal-entry.html" %} | ||||
|           {% endwith %} | ||||
|           <i class="fa-solid fa-bars"></i> | ||||
|         </li> | ||||
|       {% endfor %} | ||||
| @@ -72,7 +72,9 @@ First written: 2023/2/26 | ||||
|   <ul class="list-group mb-3"> | ||||
|     {% for item in list %} | ||||
|       <li class="list-group-item"> | ||||
|         {{ item }} | ||||
|         {% with journal_entry = item %} | ||||
|           {% include "accounting/journal-entry/include/order-journal-entry.html" %} | ||||
|         {% endwith %} | ||||
|       </li> | ||||
|     {% endfor %} | ||||
|   </ul> | ||||
|   | ||||
| @@ -21,10 +21,10 @@ First written: 2023/2/26 | ||||
| #} | ||||
| {% extends "accounting/journal-entry/include/detail.html" %} | ||||
|  | ||||
| {% block to_transfer %} | ||||
|   <a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}"> | ||||
| {% block as_trasfer %} | ||||
|   <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> | ||||
|     {{ A_("To Transfer") }} | ||||
|     <span class="d-none d-md-inline">{{ A_("As Transfer") }}</span> | ||||
|   </a> | ||||
| {% endblock %} | ||||
|  | ||||
|   | ||||
| @@ -19,57 +19,15 @@ form-currency.html: The currency sub-form in the cash receipt journal entry form | ||||
| Author: imacat@mail.imacat.idv.tw (imacat) | ||||
| First written: 2023/2/25 | ||||
| #} | ||||
| <div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}"> | ||||
|   <input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}"> | ||||
|   <input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}"> | ||||
|   <div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}"> | ||||
|     <div class="d-flex justify-content-between mt-2 mb-3"> | ||||
|       <div class="form-floating accounting-currency-content"> | ||||
|         <select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code" {% if currency_code_is_locked %} disabled="disabled" {% endif %}> | ||||
|           {% for currency in accounting_currency_options() %} | ||||
|             <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option> | ||||
|           {% endfor %} | ||||
|         </select> | ||||
|         <label class="form-label" for="accounting-currency-{{ currency_index }}-code-select">{{ A_("Currency") }}</label> | ||||
|         <div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div> | ||||
|       </div> | ||||
| {% extends "accounting/journal-entry/include/form-currency.html" %} | ||||
|  | ||||
|       <div> | ||||
|         <button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}"> | ||||
|           <i class="fas fa-minus"></i> | ||||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class="mb-3"> | ||||
|       <div id="accounting-currency-{{ currency_index }}-credit" class="form-control accounting-material-text-field accounting-not-empty {% if credit_errors %} is-invalid {% endif %}"> | ||||
|         <label class="form-label" for="accounting-currency-{{ currency_index }}-credit">{{ A_("Content") }}</label> | ||||
|         <ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-line-item-list"> | ||||
|           {% for line_item_form in credit_forms %} | ||||
| {% block line_items %} | ||||
|   {% with currency_index = currency_index, | ||||
|           debit_credit = "credit", | ||||
|                     line_item_index = loop.index, | ||||
|                     only_one_line_item_form = credit_forms|length == 1, | ||||
|                     form = line_item_form.form %} | ||||
|               {% include "accounting/journal-entry/include/form-line-item.html" %} | ||||
|           line_item_forms = credit_forms, | ||||
|           header = A_("Content"), | ||||
|           debit_credit_total = credit_total, | ||||
|           debit_credit_errors = credit_errors %} | ||||
|     {% include "accounting/journal-entry/include/form-debit-credit.html" %} | ||||
|   {% endwith %} | ||||
|           {% endfor %} | ||||
|         </ul> | ||||
|  | ||||
|         <div class="d-flex justify-content-between mb-2"> | ||||
|           <div>{{ A_("Total") }}</div> | ||||
|           <div><span id="accounting-currency-{{ currency_index }}-credit-total" class="badge rounded-pill bg-primary">{{ credit_total }}</span></div> | ||||
|         </div> | ||||
|  | ||||
|         <div> | ||||
|           <button id="accounting-currency-{{ currency_index }}-credit-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="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 id="accounting-currency-{{ currency_index }}-credit-error" class="invalid-feedback">{% if credit_errors %}{{ credit_errors[0] }}{% endif %}</div> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div> | ||||
| </div> | ||||
| {% endblock %} | ||||
|   | ||||
| @@ -32,7 +32,7 @@ First written: 2023/2/25 | ||||
|               currency_code_is_locked = currency_form.is_code_locked, | ||||
|               credit_forms = currency_form.credit, | ||||
|               credit_errors = currency_form.credit_errors, | ||||
|               credit_total = currency_form.form.credit_total|accounting_format_amount %} | ||||
|               credit_total = currency_form.credit_total|accounting_format_amount %} | ||||
|         {% include "accounting/journal-entry/receipt/include/form-currency.html" %} | ||||
|       {% endwith %} | ||||
|     {% endfor %} | ||||
|   | ||||
| @@ -19,91 +19,30 @@ form-currency.html: The currency sub-form in the transfer journal entry form | ||||
| Author: imacat@mail.imacat.idv.tw (imacat) | ||||
| First written: 2023/2/25 | ||||
| #} | ||||
| <div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}"> | ||||
|   <input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}"> | ||||
|   <input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}"> | ||||
|   <div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}"> | ||||
|     <div class="d-flex justify-content-between mt-2 mb-3"> | ||||
|       <div class="form-floating accounting-currency-content"> | ||||
|         <select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" {% if currency_code_is_locked %} disabled="disabled" {% endif %}> | ||||
|           {% for currency in accounting_currency_options() %} | ||||
|             <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option> | ||||
|           {% endfor %} | ||||
|         </select> | ||||
|         <label class="form-label" for="accounting-currency-{{ currency_index }}-code-select">{{ A_("Currency") }}</label> | ||||
|         <div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div> | ||||
|       </div> | ||||
| {% extends "accounting/journal-entry/include/form-currency.html" %} | ||||
|  | ||||
|       <div> | ||||
|         <button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}"> | ||||
|           <i class="fas fa-minus"></i> | ||||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class="row"> | ||||
|       {# The debit line items #} | ||||
|       <div class="col-sm-6 mb-3"> | ||||
|         <div id="accounting-currency-{{ currency_index }}-debit" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}"> | ||||
|           <label class="form-label" for="accounting-currency-{{ currency_index }}-debit">{{ A_("Debit") }}</label> | ||||
|           <ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-line-item-list"> | ||||
|             {% for line_item_form in debit_forms %} | ||||
| {% block line_items %} | ||||
| <div class="row"> | ||||
|   <div class="col-sm-6"> | ||||
|     {% with currency_index = currency_index, | ||||
|             debit_credit = "debit", | ||||
|                       line_item_index = loop.index, | ||||
|                       only_one_line_item_form = debit_forms|length == 1, | ||||
|                       form = line_item_form.form %} | ||||
|                 {% include "accounting/journal-entry/include/form-line-item.html" %} | ||||
|             line_item_forms = debit_forms, | ||||
|             header = A_("Debit"), | ||||
|             debit_credit_total = debit_total, | ||||
|             debit_credit_errors = debit_errors %} | ||||
|       {% include "accounting/journal-entry/include/form-debit-credit.html" %} | ||||
|     {% endwith %} | ||||
|             {% endfor %} | ||||
|           </ul> | ||||
|  | ||||
|           <div class="d-flex justify-content-between mb-2"> | ||||
|             <div>{{ A_("Total") }}</div> | ||||
|             <div><span id="accounting-currency-{{ currency_index }}-debit-total" class="badge rounded-pill bg-primary">{{ debit_total }}</span></div> | ||||
|   </div> | ||||
|  | ||||
|           <div> | ||||
|             <button id="accounting-currency-{{ currency_index }}-debit-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="debit" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal"> | ||||
|               <i class="fas fa-plus"></i> | ||||
|               {{ A_("New") }} | ||||
|             </button> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div id="accounting-currency-{{ currency_index }}-debit-error" class="invalid-feedback">{% if debit_errors %}{{ debit_errors[0] }}{% endif %}</div> | ||||
|       </div> | ||||
|  | ||||
|       {# The credit line items #} | ||||
|       <div class="col-sm-6 mb-3"> | ||||
|         <div id="accounting-currency-{{ currency_index }}-credit" class="form-control accounting-material-text-field accounting-not-empty {% if credit_errors %} is-invalid {% endif %}"> | ||||
|           <label class="form-label" for="accounting-currency-{{ currency_index }}-credit">{{ A_("Credit") }}</label> | ||||
|           <ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-line-item-list"> | ||||
|             {% for line_item_form in credit_forms %} | ||||
|   <div class="col-sm-6"> | ||||
|     {% with currency_index = currency_index, | ||||
|             debit_credit = "credit", | ||||
|                       line_item_index = loop.index, | ||||
|                       only_one_line_item_form = credit_forms|length == 1, | ||||
|                       form = line_item_form.form %} | ||||
|                 {% include "accounting/journal-entry/include/form-line-item.html" %} | ||||
|             line_item_forms = credit_forms, | ||||
|             header = A_("Credit"), | ||||
|             debit_credit_total = credit_total, | ||||
|             debit_credit_errors = credit_errors %} | ||||
|       {% include "accounting/journal-entry/include/form-debit-credit.html" %} | ||||
|     {% endwith %} | ||||
|             {% endfor %} | ||||
|           </ul> | ||||
|  | ||||
|           <div class="d-flex justify-content-between mb-2"> | ||||
|             <div>{{ A_("Total") }}</div> | ||||
|             <div><span id="accounting-currency-{{ currency_index }}-credit-total" class="badge rounded-pill bg-primary">{{ credit_total }}</span></div> | ||||
|   </div> | ||||
|  | ||||
|           <div> | ||||
|             <button id="accounting-currency-{{ currency_index }}-credit-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="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 id="accounting-currency-{{ currency_index }}-credit-error" class="invalid-feedback">{% if credit_errors %}{{ credit_errors[0] }}{% endif %}</div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div> | ||||
| </div> | ||||
| {% endblock %} | ||||
|   | ||||
| @@ -32,10 +32,10 @@ First written: 2023/2/25 | ||||
|               currency_code_is_locked = currency_form.is_code_locked, | ||||
|               debit_forms = currency_form.debit, | ||||
|               debit_errors = currency_form.debit_errors, | ||||
|               debit_total = currency_form.form.debit_total|accounting_format_amount, | ||||
|               debit_total = currency_form.debit_total|accounting_format_amount, | ||||
|               credit_forms = currency_form.credit, | ||||
|               credit_errors = currency_form.credit_errors, | ||||
|               credit_total = currency_form.form.credit_total|accounting_format_amount %} | ||||
|               credit_total = currency_form.credit_total|accounting_format_amount %} | ||||
|         {% include "accounting/journal-entry/transfer/include/form-currency.html" %} | ||||
|       {% endwith %} | ||||
|     {% endfor %} | ||||
|   | ||||
							
								
								
									
										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> | ||||
|   | ||||
| @@ -45,11 +45,11 @@ First written: 2023/3/8 | ||||
|   </div> | ||||
| {% endif %} | ||||
| <div class="btn-group" role="group"> | ||||
|   <button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"> | ||||
|   <button id="accounting-choose-report" class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"> | ||||
|     <i class="fa-solid fa-book"></i> | ||||
|     <span class="d-none d-md-inline">{{ report.report_chooser.current_report }}</span> | ||||
|     <span class="d-none d-md-inline">{{ A_("Report") }}</span> | ||||
|   </button> | ||||
|   <ul class="dropdown-menu" aria-label="{{ A_("Report") }}"> | ||||
|   <ul class="dropdown-menu" aria-labelledby="accounting-choose-report"> | ||||
|     {% for report in report.report_chooser %} | ||||
|       <li> | ||||
|         <a class="dropdown-item {% if report.is_active %} active {% endif %}" href="{{ report.url }}"> | ||||
| @@ -68,11 +68,11 @@ First written: 2023/3/8 | ||||
| </div> | ||||
| {% if use_currency_chooser %} | ||||
|   <div class="btn-group" role="group"> | ||||
|     <button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"> | ||||
|     <button id="accounting-choose-currency" class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"> | ||||
|       <i class="fa-solid fa-money-bill-wave"></i> | ||||
|       <span class="d-none d-md-inline">{{ report.currency.name|title }}</span> | ||||
|       <span class="d-none d-md-inline">{{ A_("Currency") }}</span> | ||||
|     </button> | ||||
|     <ul class="dropdown-menu" aria-label="{{ A_("Currency") }}"> | ||||
|     <ul class="dropdown-menu" aria-labelledby="accounting-choose-currency"> | ||||
|       {% for currency in report.currency_options %} | ||||
|         <li> | ||||
|           <a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}"> | ||||
| @@ -85,11 +85,11 @@ First written: 2023/3/8 | ||||
| {% endif %} | ||||
| {% if use_account_chooser %} | ||||
|   <div class="btn-group" role="group"> | ||||
|     <button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"> | ||||
|     <button id="accounting-choose-account" class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"> | ||||
|       <i class="fa-solid fa-clipboard"></i> | ||||
|       <span class="d-none d-md-inline">{{ report.account.title|title }}</span> | ||||
|       <span class="d-none d-md-inline">{{ A_("Account") }}</span> | ||||
|     </button> | ||||
|     <ul class="dropdown-menu" aria-label="{{ A_("Account") }}"> | ||||
|     <ul class="dropdown-menu" aria-labelledby="accounting-choose-account"> | ||||
|       {% for account in report.account_options %} | ||||
|         <li> | ||||
|           <a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}"> | ||||
| @@ -103,7 +103,7 @@ First written: 2023/3/8 | ||||
| {% if use_period_chooser %} | ||||
|   <button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-period-chooser-modal"> | ||||
|     <i class="fa-solid fa-calendar-day"></i> | ||||
|     <span class="d-none d-md-inline">{{ report.period.desc|title }}</span> | ||||
|     <span class="d-none d-md-inline">{{ A_("Period") }}</span> | ||||
|   </button> | ||||
| {% endif %} | ||||
| {% if report.has_data %} | ||||
|   | ||||
| @@ -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-20 23:51+0800\n" | ||||
| "PO-Revision-Date: 2023-03-20 23:51+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:538 | ||||
| #: 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:541 | ||||
| #: src/accounting/models.py:566 | ||||
| #, python-format | ||||
| msgid "Cash Receipt Journal Entry#%(id)s" | ||||
| msgstr "現金收入傳票#%(id)s" | ||||
|  | ||||
| #: src/accounting/models.py:542 | ||||
| #: src/accounting/models.py:567 | ||||
| #, python-format | ||||
| msgid "Transfer Journal Entry#%(id)s" | ||||
| msgstr "轉帳傳票#%(id)s" | ||||
|  | ||||
| #: src/accounting/models.py:679 | ||||
| #: src/accounting/models.py:700 | ||||
| #, python-format | ||||
| msgid "%(date)s %(description)s %(amount)s" | ||||
| msgstr "%(date)s %(description)s %(amount)s" | ||||
| @@ -86,7 +102,7 @@ msgstr "請填上標題。" | ||||
|  | ||||
| #: src/accounting/account/queries.py:50 | ||||
| #: src/accounting/report/reports/search.py:101 | ||||
| #: src/accounting/templates/accounting/account/detail.html:90 | ||||
| #: src/accounting/templates/accounting/account/detail.html:97 | ||||
| #: src/accounting/templates/accounting/account/list.html:62 | ||||
| msgid "Needs Offset" | ||||
| msgstr "需抵銷" | ||||
| @@ -103,17 +119,21 @@ msgstr "科目未異動。" | ||||
| msgid "The account is updated successfully." | ||||
| msgstr "科目存好了。" | ||||
|  | ||||
| #: src/accounting/account/views.py:163 | ||||
| #: src/accounting/account/views.py:161 | ||||
| msgid "The account cannot be deleted." | ||||
| msgstr "科目不可刪除。" | ||||
|  | ||||
| #: src/accounting/account/views.py:166 | ||||
| msgid "The account is deleted successfully." | ||||
| msgstr "科目刪掉了" | ||||
|  | ||||
| #: src/accounting/account/views.py:190 | ||||
| #: src/accounting/journal_entry/views.py:213 | ||||
| #: src/accounting/account/views.py:193 | ||||
| #: src/accounting/journal_entry/views.py:216 | ||||
| msgid "The order was not modified." | ||||
| msgstr "順序未異動。" | ||||
|  | ||||
| #: src/accounting/account/views.py:193 | ||||
| #: src/accounting/journal_entry/views.py:216 | ||||
| #: src/accounting/account/views.py:196 | ||||
| #: src/accounting/journal_entry/views.py:219 | ||||
| msgid "The order is updated successfully." | ||||
| msgstr "順序存好了。" | ||||
|  | ||||
| @@ -137,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 "請填上名稱。" | ||||
|  | ||||
| @@ -154,7 +177,11 @@ msgstr "貨幣未異動。" | ||||
| msgid "The currency is updated successfully." | ||||
| msgstr "貨幣存好了。" | ||||
|  | ||||
| #: src/accounting/currency/views.py:165 | ||||
| #: src/accounting/currency/views.py:164 | ||||
| msgid "The currency cannot be deleted." | ||||
| msgstr "貨幣不可刪除。" | ||||
|  | ||||
| #: src/accounting/currency/views.py:168 | ||||
| msgid "The currency is deleted successfully." | ||||
| msgstr "貨幣刪掉了" | ||||
|  | ||||
| @@ -170,32 +197,32 @@ msgstr "傳票未異動。" | ||||
| msgid "The journal entry is updated successfully." | ||||
| msgstr "傳票存好了。" | ||||
|  | ||||
| #: src/accounting/journal_entry/views.py:181 | ||||
| #: src/accounting/journal_entry/views.py:179 | ||||
| msgid "The journal entry cannot be deleted." | ||||
| msgstr "傳票不可刪除。" | ||||
|  | ||||
| #: src/accounting/journal_entry/views.py:184 | ||||
| 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 "借方貸方合計不符。 " | ||||
| @@ -224,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 "" | ||||
| @@ -287,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 "全部" | ||||
| @@ -351,20 +412,17 @@ 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 | ||||
| #: src/accounting/report/reports/trial_balance.py:228 | ||||
| #: src/accounting/templates/accounting/journal-entry/disbursement/detail.html:43 | ||||
| #: src/accounting/templates/accounting/journal-entry/disbursement/include/form-currency.html:60 | ||||
| #: src/accounting/templates/accounting/journal-entry/include/form-debit-credit.html:37 | ||||
| #: src/accounting/templates/accounting/journal-entry/receipt/detail.html:43 | ||||
| #: src/accounting/templates/accounting/journal-entry/receipt/include/form-currency.html:60 | ||||
| #: src/accounting/templates/accounting/journal-entry/transfer/detail.html:39 | ||||
| #: src/accounting/templates/accounting/journal-entry/transfer/detail.html:55 | ||||
| #: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:62 | ||||
| #: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:93 | ||||
| #: src/accounting/templates/accounting/report/balance-sheet.html:59 | ||||
| #: src/accounting/templates/accounting/report/balance-sheet.html:71 | ||||
| #: src/accounting/templates/accounting/report/balance-sheet.html:81 | ||||
| @@ -383,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 | ||||
| @@ -395,11 +453,12 @@ 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/report/include/toolbar-buttons.html:92 | ||||
| #: 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 | ||||
| #: src/accounting/templates/accounting/report/search.html:52 | ||||
| @@ -407,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 | ||||
| @@ -419,27 +478,27 @@ 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:172 | ||||
| #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:178 | ||||
| #: src/accounting/templates/accounting/journal-entry/include/form.html:73 | ||||
| msgid "Note" | ||||
| msgstr "備註" | ||||
| @@ -475,10 +534,8 @@ msgid "Amount" | ||||
| msgstr "金額" | ||||
|  | ||||
| #: src/accounting/report/reports/journal.py:155 | ||||
| #: src/accounting/templates/accounting/journal-entry/disbursement/include/form-currency.html:33 | ||||
| #: src/accounting/templates/accounting/journal-entry/receipt/include/form-currency.html:33 | ||||
| #: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:33 | ||||
| #: src/accounting/templates/accounting/report/include/toolbar-buttons.html:75 | ||||
| #: src/accounting/templates/accounting/journal-entry/include/form-currency.html:33 | ||||
| #: src/accounting/templates/accounting/report/include/toolbar-buttons.html:73 | ||||
| #: src/accounting/templates/accounting/report/journal.html:54 | ||||
| #: src/accounting/templates/accounting/report/search.html:51 | ||||
| msgid "Currency" | ||||
| @@ -488,7 +545,7 @@ msgstr "貨幣" | ||||
| #: src/accounting/report/reports/ledger.py:367 | ||||
| #: src/accounting/report/reports/trial_balance.py:224 | ||||
| #: src/accounting/templates/accounting/journal-entry/transfer/detail.html:33 | ||||
| #: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:48 | ||||
| #: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:30 | ||||
| #: src/accounting/templates/accounting/report/journal.html:57 | ||||
| #: src/accounting/templates/accounting/report/ledger.html:57 | ||||
| #: src/accounting/templates/accounting/report/search.html:54 | ||||
| @@ -500,7 +557,7 @@ msgstr "借方" | ||||
| #: src/accounting/report/reports/ledger.py:367 | ||||
| #: src/accounting/report/reports/trial_balance.py:225 | ||||
| #: src/accounting/templates/accounting/journal-entry/transfer/detail.html:49 | ||||
| #: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:79 | ||||
| #: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:41 | ||||
| #: src/accounting/templates/accounting/report/journal.html:58 | ||||
| #: src/accounting/templates/accounting/report/ledger.html:58 | ||||
| #: src/accounting/templates/accounting/report/search.html:55 | ||||
| @@ -508,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 | ||||
| @@ -519,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 | ||||
| @@ -555,25 +609,73 @@ msgstr "資產負債表" | ||||
| msgid "Please fill in the title." | ||||
| msgstr "請填上標題。" | ||||
|  | ||||
| #: src/accounting/static/js/description-editor.js:767 | ||||
| #: src/accounting/static/js/description-editor.js:953 | ||||
| #: src/accounting/static/js/description-editor.js:756 | ||||
| #: src/accounting/static/js/description-editor.js:934 | ||||
| msgid "Please fill in the tag." | ||||
| msgstr "請填上標籤。" | ||||
|  | ||||
| #: src/accounting/static/js/description-editor.js:777 | ||||
| #: src/accounting/static/js/description-editor.js:973 | ||||
| #: src/accounting/static/js/description-editor.js:766 | ||||
| #: src/accounting/static/js/description-editor.js:954 | ||||
| msgid "Please fill in the origin." | ||||
| msgstr "請填上起點。" | ||||
|  | ||||
| #: src/accounting/static/js/description-editor.js:787 | ||||
| #: src/accounting/static/js/description-editor.js:983 | ||||
| #: src/accounting/static/js/description-editor.js:776 | ||||
| #: src/accounting/static/js/description-editor.js:964 | ||||
| msgid "Please fill in the destination." | ||||
| msgstr "請填上終點。" | ||||
|  | ||||
| #: src/accounting/static/js/description-editor.js:963 | ||||
| #: src/accounting/static/js/description-editor.js:944 | ||||
| msgid "Please fill in the route." | ||||
| msgstr "請填上路線名稱。" | ||||
|  | ||||
| #: src/accounting/static/js/description-editor.js:998 | ||||
| msgid "January" | ||||
| msgstr "一月" | ||||
|  | ||||
| #: src/accounting/static/js/description-editor.js:998 | ||||
| msgid "February" | ||||
| msgstr "二月" | ||||
|  | ||||
| #: src/accounting/static/js/description-editor.js:998 | ||||
| msgid "March" | ||||
| msgstr "三月" | ||||
|  | ||||
| #: src/accounting/static/js/description-editor.js:998 | ||||
| msgid "April" | ||||
| msgstr "四月" | ||||
|  | ||||
| #: src/accounting/static/js/description-editor.js:999 | ||||
| msgid "May" | ||||
| msgstr "五月" | ||||
|  | ||||
| #: src/accounting/static/js/description-editor.js:999 | ||||
| msgid "June" | ||||
| msgstr "六月" | ||||
|  | ||||
| #: src/accounting/static/js/description-editor.js:999 | ||||
| msgid "July" | ||||
| msgstr "七月" | ||||
|  | ||||
| #: src/accounting/static/js/description-editor.js:999 | ||||
| msgid "August" | ||||
| msgstr "八月" | ||||
|  | ||||
| #: src/accounting/static/js/description-editor.js:1000 | ||||
| msgid "September" | ||||
| msgstr "九月" | ||||
|  | ||||
| #: src/accounting/static/js/description-editor.js:1000 | ||||
| msgid "October" | ||||
| msgstr "十月" | ||||
|  | ||||
| #: src/accounting/static/js/description-editor.js:1000 | ||||
| msgid "November" | ||||
| msgstr "十一月" | ||||
|  | ||||
| #: src/accounting/static/js/description-editor.js:1000 | ||||
| msgid "December" | ||||
| msgstr "十二月" | ||||
|  | ||||
| #: src/accounting/static/js/journal-entry-form.js:985 | ||||
| #: src/accounting/static/js/journal-entry-line-item-editor.js:449 | ||||
| msgid "Please fill in the amount." | ||||
| @@ -623,74 +725,82 @@ 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 | ||||
| msgid "Settings" | ||||
| msgstr "設定" | ||||
| #: src/accounting/templates/accounting/option/detail.html:31 | ||||
| msgid "Edit" | ||||
| msgstr "編輯" | ||||
|  | ||||
| #: src/accounting/templates/accounting/account/detail.html:41 | ||||
| #: src/accounting/templates/accounting/journal-entry/include/detail.html:41 | ||||
| msgid "Order" | ||||
| msgstr "次序" | ||||
|  | ||||
| #: src/accounting/templates/accounting/account/detail.html:46 | ||||
| #: src/accounting/templates/accounting/currency/detail.html:42 | ||||
| #: src/accounting/templates/accounting/account/detail.html:47 | ||||
| #: src/accounting/templates/accounting/account/detail.html:52 | ||||
| #: src/accounting/templates/accounting/currency/detail.html:43 | ||||
| #: src/accounting/templates/accounting/currency/detail.html:48 | ||||
| #: src/accounting/templates/accounting/journal-entry/include/detail.html:48 | ||||
| #: src/accounting/templates/accounting/journal-entry/include/detail.html:53 | ||||
| msgid "Delete" | ||||
| msgstr "刪除" | ||||
|  | ||||
| #: src/accounting/templates/accounting/account/detail.html:69 | ||||
| #: src/accounting/templates/accounting/account/detail.html:76 | ||||
| msgid "Confirm Delete Account" | ||||
| msgstr "刪除科目確認" | ||||
| msgstr "確認刪除科目" | ||||
|  | ||||
| #: src/accounting/templates/accounting/account/detail.html:70 | ||||
| #: src/accounting/templates/accounting/account/detail.html:77 | ||||
| #: src/accounting/templates/accounting/account/include/form.html:91 | ||||
| #: src/accounting/templates/accounting/currency/detail.html:66 | ||||
| #: src/accounting/templates/accounting/currency/detail.html:73 | ||||
| #: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:27 | ||||
| #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:30 | ||||
| #: 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" | ||||
| msgstr "關閉" | ||||
|  | ||||
| #: src/accounting/templates/accounting/account/detail.html:73 | ||||
| #: src/accounting/templates/accounting/account/detail.html:80 | ||||
| msgid "Do you really want to delete this account?" | ||||
| msgstr "你確定要刪掉這個科目嗎?" | ||||
|  | ||||
| #: src/accounting/templates/accounting/account/detail.html:76 | ||||
| #: src/accounting/templates/accounting/account/detail.html:83 | ||||
| #: src/accounting/templates/accounting/account/include/form.html:112 | ||||
| #: src/accounting/templates/accounting/currency/detail.html:72 | ||||
| #: src/accounting/templates/accounting/currency/detail.html:79 | ||||
| #: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:49 | ||||
| #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:187 | ||||
| #: 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 "取消" | ||||
|  | ||||
| #: src/accounting/templates/accounting/account/detail.html:77 | ||||
| #: src/accounting/templates/accounting/currency/detail.html:73 | ||||
| #: src/accounting/templates/accounting/account/detail.html:84 | ||||
| #: src/accounting/templates/accounting/currency/detail.html:80 | ||||
| #: src/accounting/templates/accounting/journal-entry/include/detail.html:85 | ||||
| #: src/accounting/templates/accounting/report/include/period-chooser.html:141 | ||||
| msgid "Confirm" | ||||
| msgstr "確定" | ||||
|  | ||||
| #: src/accounting/templates/accounting/account/detail.html:94 | ||||
| #: src/accounting/templates/accounting/currency/detail.html:85 | ||||
| #: src/accounting/templates/accounting/account/detail.html:101 | ||||
| #: src/accounting/templates/accounting/currency/detail.html:92 | ||||
| #: src/accounting/templates/accounting/journal-entry/include/detail.html:114 | ||||
| msgid "Created" | ||||
| msgstr "建檔" | ||||
|  | ||||
| #: src/accounting/templates/accounting/account/detail.html:95 | ||||
| #: src/accounting/templates/accounting/currency/detail.html:86 | ||||
| #: src/accounting/templates/accounting/account/detail.html:102 | ||||
| #: src/accounting/templates/accounting/currency/detail.html:93 | ||||
| #: src/accounting/templates/accounting/journal-entry/include/detail.html:115 | ||||
| msgid "Updated" | ||||
| msgstr "更新" | ||||
| @@ -714,11 +824,9 @@ msgstr "科目管理" | ||||
|  | ||||
| #: src/accounting/templates/accounting/account/list.html:32 | ||||
| #: src/accounting/templates/accounting/currency/list.html:32 | ||||
| #: src/accounting/templates/accounting/journal-entry/disbursement/include/form-currency.html:67 | ||||
| #: 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/journal-entry/receipt/include/form-currency.html:67 | ||||
| #: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:69 | ||||
| #: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:100 | ||||
| #: 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 "新增" | ||||
| @@ -730,7 +838,8 @@ msgstr "新增" | ||||
| #: src/accounting/templates/accounting/currency/list.html:65 | ||||
| #: 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:80 | ||||
| #: 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 | ||||
| @@ -749,10 +858,12 @@ msgstr "%(base)s下的科目" | ||||
| #: src/accounting/templates/accounting/account/include/form.html:75 | ||||
| #: src/accounting/templates/accounting/account/order.html:62 | ||||
| #: src/accounting/templates/accounting/currency/include/form.html:57 | ||||
| #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:188 | ||||
| #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:194 | ||||
| #: 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 "儲存" | ||||
|  | ||||
| @@ -779,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 "清除" | ||||
|  | ||||
| @@ -790,11 +902,11 @@ msgstr "基本科目管理" | ||||
| msgid "Add a New Currency" | ||||
| msgstr "新增貨幣" | ||||
|  | ||||
| #: src/accounting/templates/accounting/currency/detail.html:65 | ||||
| #: src/accounting/templates/accounting/currency/detail.html:72 | ||||
| msgid "Confirm Delete Currency" | ||||
| msgstr "刪除貨幣確認" | ||||
| msgstr "確認刪除貨幣" | ||||
|  | ||||
| #: src/accounting/templates/accounting/currency/detail.html:69 | ||||
| #: src/accounting/templates/accounting/currency/detail.html:76 | ||||
| msgid "Do you really want to delete this currency?" | ||||
| msgstr "你確定要刪掉這個貨幣嗎?" | ||||
|  | ||||
| @@ -812,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 "名稱" | ||||
|  | ||||
| @@ -835,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 "分頁瀏覽" | ||||
| @@ -850,14 +969,14 @@ msgstr "新增現金支出傳票" | ||||
|  | ||||
| #: src/accounting/templates/accounting/journal-entry/disbursement/detail.html:27 | ||||
| #: src/accounting/templates/accounting/journal-entry/receipt/detail.html:27 | ||||
| msgid "To Transfer" | ||||
| msgid "As Transfer" | ||||
| msgstr "改轉帳" | ||||
|  | ||||
| #: src/accounting/templates/accounting/journal-entry/disbursement/detail.html:37 | ||||
| #: src/accounting/templates/accounting/journal-entry/disbursement/include/form-currency.html:46 | ||||
| #: src/accounting/templates/accounting/journal-entry/disbursement/include/form-currency.html:28 | ||||
| #: src/accounting/templates/accounting/journal-entry/include/form.html:56 | ||||
| #: src/accounting/templates/accounting/journal-entry/receipt/detail.html:37 | ||||
| #: src/accounting/templates/accounting/journal-entry/receipt/include/form-currency.html:46 | ||||
| #: src/accounting/templates/accounting/journal-entry/receipt/include/form-currency.html:28 | ||||
| msgid "Content" | ||||
| msgstr "內容" | ||||
|  | ||||
| @@ -869,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 "選擇科目" | ||||
|  | ||||
| @@ -893,8 +1013,8 @@ msgid "Bus" | ||||
| msgstr "公車" | ||||
|  | ||||
| #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:59 | ||||
| msgid "Regular" | ||||
| msgstr "帳單" | ||||
| msgid "Recurring" | ||||
| msgstr "常用" | ||||
|  | ||||
| #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:64 | ||||
| msgid "Annotation" | ||||
| @@ -922,7 +1042,7 @@ msgstr "至" | ||||
| msgid "Route" | ||||
| msgstr "路線" | ||||
|  | ||||
| #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:166 | ||||
| #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:172 | ||||
| msgid "The Number of Items" | ||||
| msgstr "數量" | ||||
|  | ||||
| @@ -948,7 +1068,7 @@ msgstr "未抵銷" | ||||
|  | ||||
| #: src/accounting/templates/accounting/journal-entry/include/detail.html:77 | ||||
| msgid "Confirm Delete Journal Entry" | ||||
| msgstr "刪除傳票確認" | ||||
| msgstr "確認刪除傳票" | ||||
|  | ||||
| #: src/accounting/templates/accounting/journal-entry/include/detail.html:81 | ||||
| msgid "Do you really want to delete this journal entry?" | ||||
| @@ -962,6 +1082,24 @@ msgstr "分錄內容" | ||||
| msgid "Original Line Item" | ||||
| msgstr "原始分錄" | ||||
|  | ||||
| #: 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: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: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" | ||||
| msgstr "轉帳" | ||||
|  | ||||
| #: src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html:26 | ||||
| msgid "Select Original Line Item" | ||||
| msgstr "選擇原始分錄" | ||||
| @@ -974,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 | ||||
| @@ -1007,21 +1207,6 @@ msgstr "%(period)s%(currency)s%(account)s分類帳" | ||||
| msgid "Trial Balance of %(currency)s %(period)s" | ||||
| msgstr "%(period)s%(currency)s試算表" | ||||
|  | ||||
| #: 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/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/report/include/add-journal-entry-material-fab.html:32 | ||||
| #: src/accounting/templates/accounting/report/include/toolbar-buttons.html:41 | ||||
| msgid "Transfer" | ||||
| msgstr "轉帳" | ||||
|  | ||||
| #: src/accounting/templates/accounting/report/include/period-chooser.html:26 | ||||
| msgid "Period Chooser" | ||||
| msgstr "選擇日期範圍" | ||||
| @@ -1042,15 +1227,23 @@ msgstr "日" | ||||
| msgid "Custom" | ||||
| msgstr "自訂" | ||||
|  | ||||
| #: src/accounting/templates/accounting/report/include/toolbar-buttons.html:52 | ||||
| #: src/accounting/templates/accounting/report/include/toolbar-buttons.html:50 | ||||
| msgid "Report" | ||||
| msgstr "報表" | ||||
|  | ||||
| #: src/accounting/templates/accounting/report/include/toolbar-buttons.html:106 | ||||
| msgid "Period" | ||||
| msgstr "期間" | ||||
|  | ||||
| #: src/accounting/templates/accounting/report/include/toolbar-buttons.html:112 | ||||
| #: src/accounting/templates/accounting/report/include/toolbar-buttons.html:117 | ||||
| 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 default account code for the income and expenses log. | ||||
|         :return: The current assets and liabilities accounts. | ||||
|         """ | ||||
|     with current_app.app_context(): | ||||
|         return current_app.config.get("DEFAULT_IE_ACCOUNT", Account.CASH_CODE) | ||||
|         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 | ||||
| 
 | ||||
|     @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. | ||||
|         :return: The SQL condition for the current assets and liabilities | ||||
|             accounts. | ||||
|         """ | ||||
|     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 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]: | ||||
|   | ||||
| @@ -18,7 +18,7 @@ | ||||
|  | ||||
| """ | ||||
| import unittest | ||||
| from datetime import timedelta | ||||
| from datetime import timedelta, date | ||||
|  | ||||
| import httpx | ||||
| import sqlalchemy as sa | ||||
| @@ -27,10 +27,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 | ||||
|  | ||||
| NEXT_URI: str = "/_next" | ||||
| """The next URI.""" | ||||
| from testlib import NEXT_URI, create_test_app, get_client, set_locale | ||||
| from testlib_journal_entry import add_journal_entry | ||||
|  | ||||
|  | ||||
| class AccountData: | ||||
| @@ -53,13 +51,15 @@ class AccountData: | ||||
|         """The code.""" | ||||
|  | ||||
|  | ||||
| cash: AccountData = AccountData("1111", 1, "Cash") | ||||
| CASH: AccountData = AccountData("1111", 1, "Cash") | ||||
| """The cash account.""" | ||||
| bank: AccountData = AccountData("1113", 1, "Bank") | ||||
| PETTY: AccountData = AccountData("1112", 1, "Bank") | ||||
| """The petty cash account.""" | ||||
| BANK: AccountData = AccountData("1113", 1, "Bank") | ||||
| """The bank account.""" | ||||
| stock: AccountData = AccountData("1121", 1, "Stock") | ||||
| STOCK: AccountData = AccountData("1121", 1, "Stock") | ||||
| """The stock account.""" | ||||
| loan: AccountData = AccountData("2112", 1, "Loan") | ||||
| LOAN: AccountData = AccountData("2112", 1, "Loan") | ||||
| """The loan account.""" | ||||
| PREFIX: str = "/accounting/accounts" | ||||
| """The URL prefix for the account management.""" | ||||
| @@ -147,19 +147,19 @@ class AccountTestCase(unittest.TestCase): | ||||
|  | ||||
|         response = self.client.post(f"{PREFIX}/store", | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": cash.base_code, | ||||
|                                           "title": cash.title}) | ||||
|                                           "base_code": CASH.base_code, | ||||
|                                           "title": CASH.title}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], | ||||
|                          f"{PREFIX}/{cash.code}") | ||||
|                          f"{PREFIX}/{CASH.code}") | ||||
|  | ||||
|         response = self.client.post(f"{PREFIX}/store", | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": bank.base_code, | ||||
|                                           "title": bank.title}) | ||||
|                                           "base_code": BANK.base_code, | ||||
|                                           "title": BANK.title}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], | ||||
|                          f"{PREFIX}/{bank.code}") | ||||
|                          f"{PREFIX}/{BANK.code}") | ||||
|  | ||||
|     def test_nobody(self) -> None: | ||||
|         """Test the permission as nobody. | ||||
| @@ -173,7 +173,7 @@ class AccountTestCase(unittest.TestCase): | ||||
|         response = client.get(PREFIX) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|         response = client.get(f"{PREFIX}/{cash.code}") | ||||
|         response = client.get(f"{PREFIX}/{CASH.code}") | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|         response = client.get(f"{PREFIX}/create") | ||||
| @@ -181,33 +181,33 @@ class AccountTestCase(unittest.TestCase): | ||||
|  | ||||
|         response = client.post(f"{PREFIX}/store", | ||||
|                                data={"csrf_token": csrf_token, | ||||
|                                      "base_code": stock.base_code, | ||||
|                                      "title": stock.title}) | ||||
|                                      "base_code": STOCK.base_code, | ||||
|                                      "title": STOCK.title}) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|         response = client.get(f"{PREFIX}/{cash.code}/edit") | ||||
|         response = client.get(f"{PREFIX}/{CASH.code}/edit") | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|         response = client.post(f"{PREFIX}/{cash.code}/update", | ||||
|         response = client.post(f"{PREFIX}/{CASH.code}/update", | ||||
|                                data={"csrf_token": csrf_token, | ||||
|                                      "base_code": cash.base_code, | ||||
|                                      "title": f"{cash.title}-2"}) | ||||
|                                      "base_code": CASH.base_code, | ||||
|                                      "title": f"{CASH.title}-2"}) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|         response = client.post(f"{PREFIX}/{cash.code}/delete", | ||||
|         response = client.post(f"{PREFIX}/{BANK.code}/delete", | ||||
|                                data={"csrf_token": csrf_token}) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|         response = client.get(f"{PREFIX}/bases/{cash.base_code}") | ||||
|         response = client.get(f"{PREFIX}/bases/{CASH.base_code}") | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             bank_id: int = Account.find_by_code(bank.code).id | ||||
|             cash_id: int = Account.find_by_code(CASH.code).id | ||||
|  | ||||
|         response = client.post(f"{PREFIX}/bases/{bank.base_code}", | ||||
|         response = client.post(f"{PREFIX}/bases/{CASH.base_code}", | ||||
|                                data={"csrf_token": csrf_token, | ||||
|                                      "next": NEXT_URI, | ||||
|                                      f"{bank_id}-no": "5"}) | ||||
|                                      f"{cash_id}-no": "5"}) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|     def test_viewer(self) -> None: | ||||
| @@ -222,7 +222,7 @@ class AccountTestCase(unittest.TestCase): | ||||
|         response = client.get(PREFIX) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         response = client.get(f"{PREFIX}/{cash.code}") | ||||
|         response = client.get(f"{PREFIX}/{CASH.code}") | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         response = client.get(f"{PREFIX}/create") | ||||
| @@ -230,33 +230,33 @@ class AccountTestCase(unittest.TestCase): | ||||
|  | ||||
|         response = client.post(f"{PREFIX}/store", | ||||
|                                data={"csrf_token": csrf_token, | ||||
|                                      "base_code": stock.base_code, | ||||
|                                      "title": stock.title}) | ||||
|                                      "base_code": STOCK.base_code, | ||||
|                                      "title": STOCK.title}) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|         response = client.get(f"{PREFIX}/{cash.code}/edit") | ||||
|         response = client.get(f"{PREFIX}/{CASH.code}/edit") | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|         response = client.post(f"{PREFIX}/{cash.code}/update", | ||||
|         response = client.post(f"{PREFIX}/{CASH.code}/update", | ||||
|                                data={"csrf_token": csrf_token, | ||||
|                                      "base_code": cash.base_code, | ||||
|                                      "title": f"{cash.title}-2"}) | ||||
|                                      "base_code": CASH.base_code, | ||||
|                                      "title": f"{CASH.title}-2"}) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|         response = client.post(f"{PREFIX}/{cash.code}/delete", | ||||
|         response = client.post(f"{PREFIX}/{BANK.code}/delete", | ||||
|                                data={"csrf_token": csrf_token}) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|         response = client.get(f"{PREFIX}/bases/{cash.base_code}") | ||||
|         response = client.get(f"{PREFIX}/bases/{CASH.base_code}") | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             bank_id: int = Account.find_by_code(bank.code).id | ||||
|             cash_id: int = Account.find_by_code(CASH.code).id | ||||
|  | ||||
|         response = client.post(f"{PREFIX}/bases/{bank.base_code}", | ||||
|         response = client.post(f"{PREFIX}/bases/{CASH.base_code}", | ||||
|                                data={"csrf_token": csrf_token, | ||||
|                                      "next": NEXT_URI, | ||||
|                                      f"{bank_id}-no": "5"}) | ||||
|                                      f"{cash_id}-no": "5"}) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|     def test_editor(self) -> None: | ||||
| @@ -270,7 +270,7 @@ class AccountTestCase(unittest.TestCase): | ||||
|         response = self.client.get(PREFIX) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         response = self.client.get(f"{PREFIX}/{cash.code}") | ||||
|         response = self.client.get(f"{PREFIX}/{CASH.code}") | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         response = self.client.get(f"{PREFIX}/create") | ||||
| @@ -278,37 +278,37 @@ class AccountTestCase(unittest.TestCase): | ||||
|  | ||||
|         response = self.client.post(f"{PREFIX}/store", | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": stock.base_code, | ||||
|                                           "title": stock.title}) | ||||
|                                           "base_code": STOCK.base_code, | ||||
|                                           "title": STOCK.title}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], | ||||
|                          f"{PREFIX}/{stock.code}") | ||||
|                          f"{PREFIX}/{STOCK.code}") | ||||
|  | ||||
|         response = self.client.get(f"{PREFIX}/{cash.code}/edit") | ||||
|         response = self.client.get(f"{PREFIX}/{CASH.code}/edit") | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         response = self.client.post(f"{PREFIX}/{cash.code}/update", | ||||
|         response = self.client.post(f"{PREFIX}/{CASH.code}/update", | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": cash.base_code, | ||||
|                                           "title": f"{cash.title}-2"}) | ||||
|                                           "base_code": CASH.base_code, | ||||
|                                           "title": f"{CASH.title}-2"}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], f"{PREFIX}/{cash.code}") | ||||
|         self.assertEqual(response.headers["Location"], f"{PREFIX}/{CASH.code}") | ||||
|  | ||||
|         response = self.client.post(f"{PREFIX}/{cash.code}/delete", | ||||
|         response = self.client.post(f"{PREFIX}/{BANK.code}/delete", | ||||
|                                     data={"csrf_token": self.csrf_token}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], PREFIX) | ||||
|  | ||||
|         response = self.client.get(f"{PREFIX}/bases/{cash.base_code}") | ||||
|         response = self.client.get(f"{PREFIX}/bases/{CASH.base_code}") | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             bank_id: int = Account.find_by_code(bank.code).id | ||||
|             cash_id: int = Account.find_by_code(CASH.code).id | ||||
|  | ||||
|         response = self.client.post(f"{PREFIX}/bases/{bank.base_code}", | ||||
|         response = self.client.post(f"{PREFIX}/bases/{CASH.base_code}", | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "next": NEXT_URI, | ||||
|                                           f"{bank_id}-no": "5"}) | ||||
|                                           f"{cash_id}-no": "5"}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], NEXT_URI) | ||||
|  | ||||
| @@ -320,31 +320,31 @@ class AccountTestCase(unittest.TestCase): | ||||
|         from accounting.models import Account | ||||
|         create_uri: str = f"{PREFIX}/create" | ||||
|         store_uri: str = f"{PREFIX}/store" | ||||
|         detail_uri: str = f"{PREFIX}/{stock.code}" | ||||
|         detail_uri: str = f"{PREFIX}/{STOCK.code}" | ||||
|         response: httpx.Response | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             self.assertEqual({x.code for x in Account.query.all()}, | ||||
|                              {cash.code, bank.code}) | ||||
|                              {CASH.code, BANK.code}) | ||||
|  | ||||
|         # Missing CSRF token | ||||
|         response = self.client.post(store_uri, | ||||
|                                     data={"base_code": stock.base_code, | ||||
|                                           "title": stock.title}) | ||||
|                                     data={"base_code": STOCK.base_code, | ||||
|                                           "title": STOCK.title}) | ||||
|         self.assertEqual(response.status_code, 400) | ||||
|  | ||||
|         # CSRF token mismatch | ||||
|         response = self.client.post(store_uri, | ||||
|                                     data={"csrf_token": f"{self.csrf_token}-2", | ||||
|                                           "base_code": stock.base_code, | ||||
|                                           "title": stock.title}) | ||||
|                                           "base_code": STOCK.base_code, | ||||
|                                           "title": STOCK.title}) | ||||
|         self.assertEqual(response.status_code, 400) | ||||
|  | ||||
|         # Empty base account code | ||||
|         response = self.client.post(store_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": " ", | ||||
|                                           "title": stock.title}) | ||||
|                                           "title": STOCK.title}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], create_uri) | ||||
|  | ||||
| @@ -352,7 +352,7 @@ class AccountTestCase(unittest.TestCase): | ||||
|         response = self.client.post(store_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": "9999", | ||||
|                                           "title": stock.title}) | ||||
|                                           "title": STOCK.title}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], create_uri) | ||||
|  | ||||
| @@ -360,14 +360,14 @@ class AccountTestCase(unittest.TestCase): | ||||
|         response = self.client.post(store_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": "1", | ||||
|                                           "title": stock.title}) | ||||
|                                           "title": STOCK.title}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], create_uri) | ||||
|  | ||||
|         # Empty name | ||||
|         response = self.client.post(store_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": stock.base_code, | ||||
|                                           "base_code": STOCK.base_code, | ||||
|                                           "title": " "}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], create_uri) | ||||
| @@ -376,7 +376,7 @@ class AccountTestCase(unittest.TestCase): | ||||
|         response = self.client.post(store_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": "6172", | ||||
|                                           "title": stock.title, | ||||
|                                           "title": STOCK.title, | ||||
|                                           "is_need_offset": "yes"}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], create_uri) | ||||
| @@ -384,43 +384,43 @@ class AccountTestCase(unittest.TestCase): | ||||
|         # Success, with spaces to be stripped | ||||
|         response = self.client.post(store_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": f" {stock.base_code} ", | ||||
|                                           "title": f" {stock.title} "}) | ||||
|                                           "base_code": f" {STOCK.base_code} ", | ||||
|                                           "title": f" {STOCK.title} "}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], detail_uri) | ||||
|  | ||||
|         # Success under the same base | ||||
|         response = self.client.post(store_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": stock.base_code, | ||||
|                                           "title": stock.title}) | ||||
|                                           "base_code": STOCK.base_code, | ||||
|                                           "title": STOCK.title}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], | ||||
|                          f"{PREFIX}/{stock.base_code}-002") | ||||
|                          f"{PREFIX}/{STOCK.base_code}-002") | ||||
|  | ||||
|         # Success under the same base, with order in a mess. | ||||
|         with self.app.app_context(): | ||||
|             stock_2: Account = Account.find_by_code(f"{stock.base_code}-002") | ||||
|             stock_2: Account = Account.find_by_code(f"{STOCK.base_code}-002") | ||||
|             stock_2.no = 66 | ||||
|             db.session.commit() | ||||
|  | ||||
|         response = self.client.post(store_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": stock.base_code, | ||||
|                                           "title": stock.title}) | ||||
|                                           "base_code": STOCK.base_code, | ||||
|                                           "title": STOCK.title}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], | ||||
|                          f"{PREFIX}/{stock.base_code}-003") | ||||
|                          f"{PREFIX}/{STOCK.base_code}-003") | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             self.assertEqual({x.code for x in Account.query.all()}, | ||||
|                              {cash.code, bank.code, stock.code, | ||||
|                               f"{stock.base_code}-002", | ||||
|                               f"{stock.base_code}-003"}) | ||||
|                              {CASH.code, BANK.code, STOCK.code, | ||||
|                               f"{STOCK.base_code}-002", | ||||
|                               f"{STOCK.base_code}-003"}) | ||||
|  | ||||
|             account: Account = Account.find_by_code(stock.code) | ||||
|             self.assertEqual(account.base_code, stock.base_code) | ||||
|             self.assertEqual(account.title_l10n, stock.title) | ||||
|             account: Account = Account.find_by_code(STOCK.code) | ||||
|             self.assertEqual(account.base_code, STOCK.base_code) | ||||
|             self.assertEqual(account.title_l10n, STOCK.title) | ||||
|  | ||||
|     def test_basic_update(self) -> None: | ||||
|         """Tests the basic rules to update a user. | ||||
| @@ -428,30 +428,30 @@ class AccountTestCase(unittest.TestCase): | ||||
|         :return: None. | ||||
|         """ | ||||
|         from accounting.models import Account | ||||
|         detail_uri: str = f"{PREFIX}/{cash.code}" | ||||
|         edit_uri: str = f"{PREFIX}/{cash.code}/edit" | ||||
|         update_uri: str = f"{PREFIX}/{cash.code}/update" | ||||
|         detail_c_uri: str = f"{PREFIX}/{stock.code}" | ||||
|         detail_uri: str = f"{PREFIX}/{CASH.code}" | ||||
|         edit_uri: str = f"{PREFIX}/{CASH.code}/edit" | ||||
|         update_uri: str = f"{PREFIX}/{CASH.code}/update" | ||||
|         detail_c_uri: str = f"{PREFIX}/{STOCK.code}" | ||||
|         response: httpx.Response | ||||
|  | ||||
|         # Success, with spaces to be stripped | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": f" {cash.base_code} ", | ||||
|                                           "title": f" {cash.title}-1 "}) | ||||
|                                           "base_code": f" {CASH.base_code} ", | ||||
|                                           "title": f" {CASH.title}-1 "}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], detail_uri) | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             account: Account = Account.find_by_code(cash.code) | ||||
|             self.assertEqual(account.base_code, cash.base_code) | ||||
|             self.assertEqual(account.title_l10n, f"{cash.title}-1") | ||||
|             account: Account = Account.find_by_code(CASH.code) | ||||
|             self.assertEqual(account.base_code, CASH.base_code) | ||||
|             self.assertEqual(account.title_l10n, f"{CASH.title}-1") | ||||
|  | ||||
|         # Empty base account code | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": " ", | ||||
|                                           "title": stock.title}) | ||||
|                                           "title": STOCK.title}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], edit_uri) | ||||
|  | ||||
| @@ -459,7 +459,7 @@ class AccountTestCase(unittest.TestCase): | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": "9999", | ||||
|                                           "title": stock.title}) | ||||
|                                           "title": STOCK.title}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], edit_uri) | ||||
|  | ||||
| @@ -467,14 +467,14 @@ class AccountTestCase(unittest.TestCase): | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": "1", | ||||
|                                           "title": stock.title}) | ||||
|                                           "title": STOCK.title}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], edit_uri) | ||||
|  | ||||
|         # Empty name | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": stock.base_code, | ||||
|                                           "base_code": STOCK.base_code, | ||||
|                                           "title": " "}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], edit_uri) | ||||
| @@ -483,7 +483,7 @@ class AccountTestCase(unittest.TestCase): | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": "6172", | ||||
|                                           "title": stock.title, | ||||
|                                           "title": STOCK.title, | ||||
|                                           "is_need_offset": "yes"}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], edit_uri) | ||||
| @@ -491,8 +491,8 @@ class AccountTestCase(unittest.TestCase): | ||||
|         # Change the base account | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": stock.base_code, | ||||
|                                           "title": stock.title}) | ||||
|                                           "base_code": STOCK.base_code, | ||||
|                                           "title": STOCK.title}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], detail_c_uri) | ||||
|  | ||||
| @@ -508,20 +508,20 @@ class AccountTestCase(unittest.TestCase): | ||||
|         :return: None. | ||||
|         """ | ||||
|         from accounting.models import Account | ||||
|         detail_uri: str = f"{PREFIX}/{cash.code}" | ||||
|         update_uri: str = f"{PREFIX}/{cash.code}/update" | ||||
|         detail_uri: str = f"{PREFIX}/{CASH.code}" | ||||
|         update_uri: str = f"{PREFIX}/{CASH.code}/update" | ||||
|         account: Account | ||||
|         response: httpx.Response | ||||
|  | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": f" {cash.base_code} ", | ||||
|                                           "title": f" {cash.title} "}) | ||||
|                                           "base_code": f" {CASH.base_code} ", | ||||
|                                           "title": f" {CASH.title} "}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], detail_uri) | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             account = Account.find_by_code(cash.code) | ||||
|             account = Account.find_by_code(CASH.code) | ||||
|             self.assertIsNotNone(account) | ||||
|             account.created_at \ | ||||
|                 = account.created_at - timedelta(seconds=5) | ||||
| @@ -530,13 +530,13 @@ class AccountTestCase(unittest.TestCase): | ||||
|  | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": cash.base_code, | ||||
|                                           "title": stock.title}) | ||||
|                                           "base_code": CASH.base_code, | ||||
|                                           "title": STOCK.title}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], detail_uri) | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             account = Account.find_by_code(cash.code) | ||||
|             account = Account.find_by_code(CASH.code) | ||||
|             self.assertIsNotNone(account) | ||||
|             self.assertLess(account.created_at, | ||||
|                             account.updated_at) | ||||
| @@ -547,31 +547,31 @@ 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) | ||||
|         detail_uri: str = f"{PREFIX}/{cash.code}" | ||||
|         update_uri: str = f"{PREFIX}/{cash.code}/update" | ||||
|         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 | ||||
|         response: httpx.Response | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             account = Account.find_by_code(cash.code) | ||||
|             account = Account.find_by_code(CASH.code) | ||||
|             self.assertEqual(account.created_by.username, editor_username) | ||||
|             self.assertEqual(account.updated_by.username, editor_username) | ||||
|  | ||||
|         response = client.post(update_uri, | ||||
|                                data={"csrf_token": csrf_token, | ||||
|                                      "base_code": cash.base_code, | ||||
|                                      "title": f"{cash.title}-2"}) | ||||
|                                      "base_code": CASH.base_code, | ||||
|                                      "title": f"{CASH.title}-2"}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], detail_uri) | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             account = Account.find_by_code(cash.code) | ||||
|             account = Account.find_by_code(CASH.code) | ||||
|             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. | ||||
| @@ -579,60 +579,60 @@ class AccountTestCase(unittest.TestCase): | ||||
|         :return: None | ||||
|         """ | ||||
|         from accounting.models import Account | ||||
|         detail_uri: str = f"{PREFIX}/{cash.code}" | ||||
|         update_uri: str = f"{PREFIX}/{cash.code}/update" | ||||
|         detail_uri: str = f"{PREFIX}/{CASH.code}" | ||||
|         update_uri: str = f"{PREFIX}/{CASH.code}/update" | ||||
|         account: Account | ||||
|         response: httpx.Response | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             account = Account.find_by_code(cash.code) | ||||
|             self.assertEqual(account.title_l10n, cash.title) | ||||
|             account = Account.find_by_code(CASH.code) | ||||
|             self.assertEqual(account.title_l10n, CASH.title) | ||||
|             self.assertEqual(account.l10n, []) | ||||
|  | ||||
|         set_locale(self.client, self.csrf_token, "zh_Hant") | ||||
|  | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": cash.base_code, | ||||
|                                           "title": f"{cash.title}-zh_Hant"}) | ||||
|                                           "base_code": CASH.base_code, | ||||
|                                           "title": f"{CASH.title}-zh_Hant"}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], detail_uri) | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             account = Account.find_by_code(cash.code) | ||||
|             self.assertEqual(account.title_l10n, cash.title) | ||||
|             account = Account.find_by_code(CASH.code) | ||||
|             self.assertEqual(account.title_l10n, CASH.title) | ||||
|             self.assertEqual({(x.locale, x.title) for x in account.l10n}, | ||||
|                              {("zh_Hant", f"{cash.title}-zh_Hant")}) | ||||
|                              {("zh_Hant", f"{CASH.title}-zh_Hant")}) | ||||
|  | ||||
|         set_locale(self.client, self.csrf_token, "en") | ||||
|  | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": cash.base_code, | ||||
|                                           "title": f"{cash.title}-2"}) | ||||
|                                           "base_code": CASH.base_code, | ||||
|                                           "title": f"{CASH.title}-2"}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], detail_uri) | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             account = Account.find_by_code(cash.code) | ||||
|             self.assertEqual(account.title_l10n, f"{cash.title}-2") | ||||
|             account = Account.find_by_code(CASH.code) | ||||
|             self.assertEqual(account.title_l10n, f"{CASH.title}-2") | ||||
|             self.assertEqual({(x.locale, x.title) for x in account.l10n}, | ||||
|                              {("zh_Hant", f"{cash.title}-zh_Hant")}) | ||||
|                              {("zh_Hant", f"{CASH.title}-zh_Hant")}) | ||||
|  | ||||
|         set_locale(self.client, self.csrf_token, "zh_Hant") | ||||
|  | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": cash.base_code, | ||||
|                                           "title": f"{cash.title}-zh_Hant-2"}) | ||||
|                                           "base_code": CASH.base_code, | ||||
|                                           "title": f"{CASH.title}-zh_Hant-2"}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], detail_uri) | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             account = Account.find_by_code(cash.code) | ||||
|             self.assertEqual(account.title_l10n, f"{cash.title}-2") | ||||
|             account = Account.find_by_code(CASH.code) | ||||
|             self.assertEqual(account.title_l10n, f"{CASH.title}-2") | ||||
|             self.assertEqual({(x.locale, x.title) for x in account.l10n}, | ||||
|                              {("zh_Hant", f"{cash.title}-zh_Hant-2")}) | ||||
|                              {("zh_Hant", f"{CASH.title}-zh_Hant-2")}) | ||||
|  | ||||
|     def test_delete(self) -> None: | ||||
|         """Tests to delete a currency. | ||||
| @@ -640,15 +640,51 @@ class AccountTestCase(unittest.TestCase): | ||||
|         :return: None. | ||||
|         """ | ||||
|         from accounting.models import Account | ||||
|         detail_uri: str = f"{PREFIX}/{cash.code}" | ||||
|         delete_uri: str = f"{PREFIX}/{cash.code}/delete" | ||||
|         detail_uri: str = f"{PREFIX}/{PETTY.code}" | ||||
|         delete_uri: str = f"{PREFIX}/{PETTY.code}/delete" | ||||
|         list_uri: str = PREFIX | ||||
|         response: httpx.Response | ||||
|  | ||||
|         response = self.client.post(f"{PREFIX}/store", | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": PETTY.base_code, | ||||
|                                           "title": PETTY.title}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], detail_uri) | ||||
|  | ||||
|         response = self.client.post("/accounting/currencies/store", | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": "USD", | ||||
|                                           "name": "US Dollars"}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], | ||||
|                          "/accounting/currencies/USD") | ||||
|  | ||||
|         add_journal_entry(self.client, | ||||
|                           form={"csrf_token": self.csrf_token, | ||||
|                                 "next": NEXT_URI, | ||||
|                                 "date": date.today().isoformat(), | ||||
|                                 "currency-1-code": "USD", | ||||
|                                 "currency-1-credit-1-account_code": BANK.code, | ||||
|                                 "currency-1-credit-1-amount": "20"}) | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             self.assertEqual({x.code for x in Account.query.all()}, | ||||
|                              {cash.code, bank.code}) | ||||
|                              {CASH.code, PETTY.code, BANK.code}) | ||||
|  | ||||
|         # Cannot delete the cash account | ||||
|         response = self.client.post(f"{PREFIX}/{CASH.code}/delete", | ||||
|                                     data={"csrf_token": self.csrf_token}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], f"{PREFIX}/{CASH.code}") | ||||
|  | ||||
|         # Cannot delete the account that is in use | ||||
|         response = self.client.post(f"{PREFIX}/{BANK.code}/delete", | ||||
|                                     data={"csrf_token": self.csrf_token}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], f"{PREFIX}/{BANK.code}") | ||||
|  | ||||
|         # Success | ||||
|         response = self.client.get(detail_uri) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         response = self.client.post(delete_uri, | ||||
| @@ -658,7 +694,7 @@ class AccountTestCase(unittest.TestCase): | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             self.assertEqual({x.code for x in Account.query.all()}, | ||||
|                              {bank.code}) | ||||
|                              {CASH.code, BANK.code}) | ||||
|  | ||||
|         response = self.client.get(detail_uri) | ||||
|         self.assertEqual(response.status_code, 404) | ||||
|   | ||||
| @@ -20,7 +20,7 @@ | ||||
| import csv | ||||
| import typing as t | ||||
| import unittest | ||||
| from datetime import timedelta | ||||
| from datetime import timedelta, date | ||||
|  | ||||
| import httpx | ||||
| from click.testing import Result | ||||
| @@ -28,7 +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 import NEXT_URI, create_test_app, get_client, set_locale | ||||
| from testlib_journal_entry import add_journal_entry | ||||
|  | ||||
|  | ||||
| class CurrencyData: | ||||
| @@ -46,14 +47,14 @@ class CurrencyData: | ||||
|         """The name.""" | ||||
|  | ||||
|  | ||||
| zza: CurrencyData = CurrencyData("ZZA", "Testing Dollar #A") | ||||
| """The first test currency.""" | ||||
| zzb: CurrencyData = CurrencyData("ZZB", "Testing Dollar #B") | ||||
| """The second test currency.""" | ||||
| zzc: CurrencyData = CurrencyData("ZZC", "Testing Dollar #C") | ||||
| """The third test currency.""" | ||||
| zzd: CurrencyData = CurrencyData("ZZD", "Testing Dollar #D") | ||||
| """The fourth test currency.""" | ||||
| USD: CurrencyData = CurrencyData("USD", "US Dollar") | ||||
| """The US dollars.""" | ||||
| EUR: CurrencyData = CurrencyData("EUR", "Euro") | ||||
| """The European dollars.""" | ||||
| TWD: CurrencyData = CurrencyData("TWD", "Taiwan dollars") | ||||
| """The Taiwan dollars.""" | ||||
| JPY: CurrencyData = CurrencyData("JPY", "Japanese yen") | ||||
| """The Japanese yen.""" | ||||
| PREFIX: str = "/accounting/currencies" | ||||
| """The URL prefix for the currency management.""" | ||||
|  | ||||
| @@ -140,17 +141,17 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|  | ||||
|         response = self.client.post(f"{PREFIX}/store", | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": zza.code, | ||||
|                                           "name": zza.name}) | ||||
|                                           "code": USD.code, | ||||
|                                           "name": USD.name}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], f"{PREFIX}/{zza.code}") | ||||
|         self.assertEqual(response.headers["Location"], f"{PREFIX}/{USD.code}") | ||||
|  | ||||
|         response = self.client.post(f"{PREFIX}/store", | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": zzb.code, | ||||
|                                           "name": zzb.name}) | ||||
|                                           "code": EUR.code, | ||||
|                                           "name": EUR.name}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzb.code}") | ||||
|         self.assertEqual(response.headers["Location"], f"{PREFIX}/{EUR.code}") | ||||
|  | ||||
|     def test_nobody(self) -> None: | ||||
|         """Test the permission as nobody. | ||||
| @@ -163,7 +164,7 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|         response = client.get(PREFIX) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|         response = client.get(f"{PREFIX}/{zza.code}") | ||||
|         response = client.get(f"{PREFIX}/{USD.code}") | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|         response = client.get(f"{PREFIX}/create") | ||||
| @@ -171,20 +172,20 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|  | ||||
|         response = client.post(f"{PREFIX}/store", | ||||
|                                data={"csrf_token": csrf_token, | ||||
|                                      "code": zzc.code, | ||||
|                                      "name": zzc.name}) | ||||
|                                      "code": TWD.code, | ||||
|                                      "name": TWD.name}) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|         response = client.get(f"{PREFIX}/{zza.code}/edit") | ||||
|         response = client.get(f"{PREFIX}/{USD.code}/edit") | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|         response = client.post(f"{PREFIX}/{zza.code}/update", | ||||
|         response = client.post(f"{PREFIX}/{USD.code}/update", | ||||
|                                data={"csrf_token": csrf_token, | ||||
|                                      "code": zzd.code, | ||||
|                                      "name": zzd.name}) | ||||
|                                      "code": JPY.code, | ||||
|                                      "name": JPY.name}) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|         response = client.post(f"{PREFIX}/{zzb.code}/delete", | ||||
|         response = client.post(f"{PREFIX}/{EUR.code}/delete", | ||||
|                                data={"csrf_token": csrf_token}) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
| @@ -199,7 +200,7 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|         response = client.get(PREFIX) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         response = client.get(f"{PREFIX}/{zza.code}") | ||||
|         response = client.get(f"{PREFIX}/{USD.code}") | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         response = client.get(f"{PREFIX}/create") | ||||
| @@ -207,20 +208,20 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|  | ||||
|         response = client.post(f"{PREFIX}/store", | ||||
|                                data={"csrf_token": csrf_token, | ||||
|                                      "code": zzc.code, | ||||
|                                      "name": zzc.name}) | ||||
|                                      "code": TWD.code, | ||||
|                                      "name": TWD.name}) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|         response = client.get(f"{PREFIX}/{zza.code}/edit") | ||||
|         response = client.get(f"{PREFIX}/{USD.code}/edit") | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|         response = client.post(f"{PREFIX}/{zza.code}/update", | ||||
|         response = client.post(f"{PREFIX}/{USD.code}/update", | ||||
|                                data={"csrf_token": csrf_token, | ||||
|                                      "code": zzd.code, | ||||
|                                      "name": zzd.name}) | ||||
|                                      "code": JPY.code, | ||||
|                                      "name": JPY.name}) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|         response = client.post(f"{PREFIX}/{zzb.code}/delete", | ||||
|         response = client.post(f"{PREFIX}/{EUR.code}/delete", | ||||
|                                data={"csrf_token": csrf_token}) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
| @@ -234,7 +235,7 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|         response = self.client.get(PREFIX) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         response = self.client.get(f"{PREFIX}/{zza.code}") | ||||
|         response = self.client.get(f"{PREFIX}/{USD.code}") | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         response = self.client.get(f"{PREFIX}/create") | ||||
| @@ -242,22 +243,22 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|  | ||||
|         response = self.client.post(f"{PREFIX}/store", | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": zzc.code, | ||||
|                                           "name": zzc.name}) | ||||
|                                           "code": TWD.code, | ||||
|                                           "name": TWD.name}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzc.code}") | ||||
|         self.assertEqual(response.headers["Location"], f"{PREFIX}/{TWD.code}") | ||||
|  | ||||
|         response = self.client.get(f"{PREFIX}/{zza.code}/edit") | ||||
|         response = self.client.get(f"{PREFIX}/{USD.code}/edit") | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         response = self.client.post(f"{PREFIX}/{zza.code}/update", | ||||
|         response = self.client.post(f"{PREFIX}/{USD.code}/update", | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": zzd.code, | ||||
|                                           "name": zzd.name}) | ||||
|                                           "code": JPY.code, | ||||
|                                           "name": JPY.name}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzd.code}") | ||||
|         self.assertEqual(response.headers["Location"], f"{PREFIX}/{JPY.code}") | ||||
|  | ||||
|         response = self.client.post(f"{PREFIX}/{zzb.code}/delete", | ||||
|         response = self.client.post(f"{PREFIX}/{EUR.code}/delete", | ||||
|                                     data={"csrf_token": self.csrf_token}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], PREFIX) | ||||
| @@ -270,31 +271,31 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|         from accounting.models import Currency | ||||
|         create_uri: str = f"{PREFIX}/create" | ||||
|         store_uri: str = f"{PREFIX}/store" | ||||
|         detail_uri: str = f"{PREFIX}/{zzc.code}" | ||||
|         detail_uri: str = f"{PREFIX}/{TWD.code}" | ||||
|         response: httpx.Response | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             self.assertEqual({x.code for x in Currency.query.all()}, | ||||
|                              {zza.code, zzb.code}) | ||||
|                              {USD.code, EUR.code}) | ||||
|  | ||||
|         # Missing CSRF token | ||||
|         response = self.client.post(store_uri, | ||||
|                                     data={"code": zzc.code, | ||||
|                                           "name": zzc.name}) | ||||
|                                     data={"code": TWD.code, | ||||
|                                           "name": TWD.name}) | ||||
|         self.assertEqual(response.status_code, 400) | ||||
|  | ||||
|         # CSRF token mismatch | ||||
|         response = self.client.post(store_uri, | ||||
|                                     data={"csrf_token": f"{self.csrf_token}-2", | ||||
|                                           "code": zzc.code, | ||||
|                                           "name": zzc.name}) | ||||
|                                           "code": TWD.code, | ||||
|                                           "name": TWD.name}) | ||||
|         self.assertEqual(response.status_code, 400) | ||||
|  | ||||
|         # Empty code | ||||
|         response = self.client.post(store_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": " ", | ||||
|                                           "name": zzc.name}) | ||||
|                                           "name": TWD.name}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], create_uri) | ||||
|  | ||||
| @@ -302,7 +303,7 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|         response = self.client.post(store_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": " create ", | ||||
|                                           "name": zzc.name}) | ||||
|                                           "name": TWD.name}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], create_uri) | ||||
|  | ||||
| @@ -310,14 +311,14 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|         response = self.client.post(store_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": " zzc ", | ||||
|                                           "name": zzc.name}) | ||||
|                                           "name": TWD.name}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], create_uri) | ||||
|  | ||||
|         # Empty name | ||||
|         response = self.client.post(store_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": zzc.code, | ||||
|                                           "code": TWD.code, | ||||
|                                           "name": " "}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], create_uri) | ||||
| @@ -325,26 +326,26 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|         # Success, with spaces to be stripped | ||||
|         response = self.client.post(store_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": f" {zzc.code} ", | ||||
|                                           "name": f" {zzc.name} "}) | ||||
|                                           "code": f" {TWD.code} ", | ||||
|                                           "name": f" {TWD.name} "}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], detail_uri) | ||||
|  | ||||
|         # Duplicated code | ||||
|         response = self.client.post(store_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": zzc.code, | ||||
|                                           "name": zzc.name}) | ||||
|                                           "code": TWD.code, | ||||
|                                           "name": TWD.name}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], create_uri) | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             self.assertEqual({x.code for x in Currency.query.all()}, | ||||
|                              {zza.code, zzb.code, zzc.code}) | ||||
|                              {USD.code, EUR.code, TWD.code}) | ||||
|  | ||||
|             currency: Currency = db.session.get(Currency, zzc.code) | ||||
|             self.assertEqual(currency.code, zzc.code) | ||||
|             self.assertEqual(currency.name_l10n, zzc.name) | ||||
|             currency: Currency = db.session.get(Currency, TWD.code) | ||||
|             self.assertEqual(currency.code, TWD.code) | ||||
|             self.assertEqual(currency.name_l10n, TWD.name) | ||||
|  | ||||
|     def test_basic_update(self) -> None: | ||||
|         """Tests the basic rules to update a user. | ||||
| @@ -352,30 +353,30 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|         :return: None. | ||||
|         """ | ||||
|         from accounting.models import Currency | ||||
|         detail_uri: str = f"{PREFIX}/{zza.code}" | ||||
|         edit_uri: str = f"{PREFIX}/{zza.code}/edit" | ||||
|         update_uri: str = f"{PREFIX}/{zza.code}/update" | ||||
|         detail_c_uri: str = f"{PREFIX}/{zzc.code}" | ||||
|         detail_uri: str = f"{PREFIX}/{USD.code}" | ||||
|         edit_uri: str = f"{PREFIX}/{USD.code}/edit" | ||||
|         update_uri: str = f"{PREFIX}/{USD.code}/update" | ||||
|         detail_c_uri: str = f"{PREFIX}/{TWD.code}" | ||||
|         response: httpx.Response | ||||
|  | ||||
|         # Success, with spaces to be stripped | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": f" {zza.code} ", | ||||
|                                           "name": f" {zza.name}-1 "}) | ||||
|                                           "code": f" {USD.code} ", | ||||
|                                           "name": f" {USD.name}-1 "}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], detail_uri) | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             currency: Currency = db.session.get(Currency, zza.code) | ||||
|             self.assertEqual(currency.code, zza.code) | ||||
|             self.assertEqual(currency.name_l10n, f"{zza.name}-1") | ||||
|             currency: Currency = db.session.get(Currency, USD.code) | ||||
|             self.assertEqual(currency.code, USD.code) | ||||
|             self.assertEqual(currency.name_l10n, f"{USD.name}-1") | ||||
|  | ||||
|         # Empty code | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": " ", | ||||
|                                           "name": zzc.name}) | ||||
|                                           "name": TWD.name}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], edit_uri) | ||||
|  | ||||
| @@ -383,7 +384,7 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": " create ", | ||||
|                                           "name": zzc.name}) | ||||
|                                           "name": TWD.name}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], edit_uri) | ||||
|  | ||||
| @@ -391,14 +392,14 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": "abc/def", | ||||
|                                           "name": zzc.name}) | ||||
|                                           "name": TWD.name}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], edit_uri) | ||||
|  | ||||
|         # Empty name | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": zzc.code, | ||||
|                                           "code": TWD.code, | ||||
|                                           "name": " "}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], edit_uri) | ||||
| @@ -406,16 +407,16 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|         # Duplicated code | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": zzb.code, | ||||
|                                           "name": zzc.name}) | ||||
|                                           "code": EUR.code, | ||||
|                                           "name": TWD.name}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], edit_uri) | ||||
|  | ||||
|         # Change code | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": zzc.code, | ||||
|                                           "name": zzc.name}) | ||||
|                                           "code": TWD.code, | ||||
|                                           "name": TWD.name}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], detail_c_uri) | ||||
|  | ||||
| @@ -431,20 +432,20 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|         :return: None. | ||||
|         """ | ||||
|         from accounting.models import Currency | ||||
|         detail_uri: str = f"{PREFIX}/{zza.code}" | ||||
|         update_uri: str = f"{PREFIX}/{zza.code}/update" | ||||
|         detail_uri: str = f"{PREFIX}/{USD.code}" | ||||
|         update_uri: str = f"{PREFIX}/{USD.code}/update" | ||||
|         currency: Currency | None | ||||
|         response: httpx.Response | ||||
|  | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": f" {zza.code} ", | ||||
|                                           "name": f" {zza.name} "}) | ||||
|                                           "code": f" {USD.code} ", | ||||
|                                           "name": f" {USD.name} "}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], detail_uri) | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             currency = db.session.get(Currency, zza.code) | ||||
|             currency = db.session.get(Currency, USD.code) | ||||
|             self.assertIsNotNone(currency) | ||||
|             currency.created_at \ | ||||
|                 = currency.created_at - timedelta(seconds=5) | ||||
| @@ -453,13 +454,13 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|  | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": zza.code, | ||||
|                                           "name": zzc.name}) | ||||
|                                           "code": USD.code, | ||||
|                                           "name": TWD.name}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], detail_uri) | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             currency = db.session.get(Currency, zza.code) | ||||
|             currency = db.session.get(Currency, USD.code) | ||||
|             self.assertIsNotNone(currency) | ||||
|             self.assertLess(currency.created_at, | ||||
|                             currency.updated_at) | ||||
| @@ -470,29 +471,29 @@ 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) | ||||
|         detail_uri: str = f"{PREFIX}/{zza.code}" | ||||
|         update_uri: str = f"{PREFIX}/{zza.code}/update" | ||||
|         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 | ||||
|         response: httpx.Response | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             currency = db.session.get(Currency, zza.code) | ||||
|             currency = db.session.get(Currency, USD.code) | ||||
|             self.assertEqual(currency.created_by.username, editor_username) | ||||
|             self.assertEqual(currency.updated_by.username, editor_username) | ||||
|  | ||||
|         response = client.post(update_uri, | ||||
|                                data={"csrf_token": csrf_token, | ||||
|                                      "code": zza.code, | ||||
|                                      "name": f"{zza.name}-2"}) | ||||
|                                      "code": USD.code, | ||||
|                                      "name": f"{USD.name}-2"}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], detail_uri) | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             currency = db.session.get(Currency, zza.code) | ||||
|             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. | ||||
| @@ -502,14 +503,14 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|         response: httpx.Response | ||||
|  | ||||
|         response = self.client.get( | ||||
|             f"/accounting/api/currencies/exists-code?q={zza.code}") | ||||
|             f"/accounting/api/currencies/exists-code?q={USD.code}") | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         data = response.json() | ||||
|         self.assertEqual(set(data.keys()), {"exists"}) | ||||
|         self.assertTrue(data["exists"]) | ||||
|  | ||||
|         response = self.client.get( | ||||
|             f"/accounting/api/currencies/exists-code?q={zza.code}-1") | ||||
|             f"/accounting/api/currencies/exists-code?q={USD.code}-1") | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         data = response.json() | ||||
|         self.assertEqual(set(data.keys()), {"exists"}) | ||||
| @@ -521,60 +522,60 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|         :return: None | ||||
|         """ | ||||
|         from accounting.models import Currency | ||||
|         detail_uri: str = f"{PREFIX}/{zza.code}" | ||||
|         update_uri: str = f"{PREFIX}/{zza.code}/update" | ||||
|         detail_uri: str = f"{PREFIX}/{USD.code}" | ||||
|         update_uri: str = f"{PREFIX}/{USD.code}/update" | ||||
|         currency: Currency | ||||
|         response: httpx.Response | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             currency = db.session.get(Currency, zza.code) | ||||
|             self.assertEqual(currency.name_l10n, zza.name) | ||||
|             currency = db.session.get(Currency, USD.code) | ||||
|             self.assertEqual(currency.name_l10n, USD.name) | ||||
|             self.assertEqual(currency.l10n, []) | ||||
|  | ||||
|         set_locale(self.client, self.csrf_token, "zh_Hant") | ||||
|  | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": zza.code, | ||||
|                                           "name": f"{zza.name}-zh_Hant"}) | ||||
|                                           "code": USD.code, | ||||
|                                           "name": f"{USD.name}-zh_Hant"}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], detail_uri) | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             currency = db.session.get(Currency, zza.code) | ||||
|             self.assertEqual(currency.name_l10n, zza.name) | ||||
|             currency = db.session.get(Currency, USD.code) | ||||
|             self.assertEqual(currency.name_l10n, USD.name) | ||||
|             self.assertEqual({(x.locale, x.name) for x in currency.l10n}, | ||||
|                              {("zh_Hant", f"{zza.name}-zh_Hant")}) | ||||
|                              {("zh_Hant", f"{USD.name}-zh_Hant")}) | ||||
|  | ||||
|         set_locale(self.client, self.csrf_token, "en") | ||||
|  | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": zza.code, | ||||
|                                           "name": f"{zza.name}-2"}) | ||||
|                                           "code": USD.code, | ||||
|                                           "name": f"{USD.name}-2"}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], detail_uri) | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             currency = db.session.get(Currency, zza.code) | ||||
|             self.assertEqual(currency.name_l10n, f"{zza.name}-2") | ||||
|             currency = db.session.get(Currency, USD.code) | ||||
|             self.assertEqual(currency.name_l10n, f"{USD.name}-2") | ||||
|             self.assertEqual({(x.locale, x.name) for x in currency.l10n}, | ||||
|                              {("zh_Hant", f"{zza.name}-zh_Hant")}) | ||||
|                              {("zh_Hant", f"{USD.name}-zh_Hant")}) | ||||
|  | ||||
|         set_locale(self.client, self.csrf_token, "zh_Hant") | ||||
|  | ||||
|         response = self.client.post(update_uri, | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": zza.code, | ||||
|                                           "name": f"{zza.name}-zh_Hant-2"}) | ||||
|                                           "code": USD.code, | ||||
|                                           "name": f"{USD.name}-zh_Hant-2"}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], detail_uri) | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             currency = db.session.get(Currency, zza.code) | ||||
|             self.assertEqual(currency.name_l10n, f"{zza.name}-2") | ||||
|             currency = db.session.get(Currency, USD.code) | ||||
|             self.assertEqual(currency.name_l10n, f"{USD.name}-2") | ||||
|             self.assertEqual({(x.locale, x.name) for x in currency.l10n}, | ||||
|                              {("zh_Hant", f"{zza.name}-zh_Hant-2")}) | ||||
|                              {("zh_Hant", f"{USD.name}-zh_Hant-2")}) | ||||
|  | ||||
|     def test_delete(self) -> None: | ||||
|         """Tests to delete a currency. | ||||
| @@ -582,15 +583,58 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|         :return: None. | ||||
|         """ | ||||
|         from accounting.models import Currency | ||||
|         detail_uri: str = f"{PREFIX}/{zza.code}" | ||||
|         delete_uri: str = f"{PREFIX}/{zza.code}/delete" | ||||
|         detail_uri: str = f"{PREFIX}/{JPY.code}" | ||||
|         delete_uri: str = f"{PREFIX}/{JPY.code}/delete" | ||||
|         list_uri: str = PREFIX | ||||
|         response: httpx.Response | ||||
|  | ||||
|         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||
|         with self.app.app_context(): | ||||
|             from accounting.models import BaseAccount | ||||
|             if BaseAccount.query.first() is None: | ||||
|                 result = runner.invoke(args="accounting-init-base") | ||||
|                 self.assertEqual(result.exit_code, 0) | ||||
|  | ||||
|         response = self.client.post("/accounting/accounts/store", | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "base_code": "1111", | ||||
|                                           "title": "Cash"}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], | ||||
|                          "/accounting/accounts/1111-001") | ||||
|  | ||||
|         response = self.client.post(f"{PREFIX}/store", | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "code": JPY.code, | ||||
|                                           "name": JPY.name}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], detail_uri) | ||||
|  | ||||
|         add_journal_entry(self.client, | ||||
|                           form={"csrf_token": self.csrf_token, | ||||
|                                 "next": NEXT_URI, | ||||
|                                 "date": date.today().isoformat(), | ||||
|                                 "currency-1-code": EUR.code, | ||||
|                                 "currency-1-credit-1-account_code": "1111-001", | ||||
|                                 "currency-1-credit-1-amount": "20"}) | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             self.assertEqual({x.code for x in Currency.query.all()}, | ||||
|                              {zza.code, zzb.code}) | ||||
|                              {USD.code, EUR.code, JPY.code}) | ||||
|  | ||||
|         # Cannot delete the default currency | ||||
|         response = self.client.post(f"{PREFIX}/{USD.code}/delete", | ||||
|                                     data={"csrf_token": self.csrf_token}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], f"{PREFIX}/{USD.code}") | ||||
|  | ||||
|         # Cannot delete the account that is in use | ||||
|         response = self.client.post(f"{PREFIX}/{EUR.code}/delete", | ||||
|                                     data={"csrf_token": self.csrf_token}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], f"{PREFIX}/{EUR.code}") | ||||
|  | ||||
|         # Success | ||||
|         response = self.client.get(detail_uri) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         response = self.client.post(delete_uri, | ||||
| @@ -600,7 +644,7 @@ class CurrencyTestCase(unittest.TestCase): | ||||
|  | ||||
|         with self.app.app_context(): | ||||
|             self.assertEqual({x.code for x in Currency.query.all()}, | ||||
|                              {zzb.code}) | ||||
|                              {USD.code, EUR.code}) | ||||
|  | ||||
|         response = self.client.get(detail_uri) | ||||
|         self.assertEqual(response.status_code, 404) | ||||
|   | ||||
| @@ -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,19 +562,50 @@ 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. | ||||
|  | ||||
|         :return: None. | ||||
|         """ | ||||
|         journal_entry_id: int \ | ||||
|         from accounting.models import JournalEntry, JournalEntryLineItem | ||||
|         journal_entry_id_1: int \ | ||||
|             = add_journal_entry(self.client, self.__get_add_form()) | ||||
|         detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next" | ||||
|         delete_uri: str = f"{PREFIX}/{journal_entry_id}/delete" | ||||
|         detail_uri: str = f"{PREFIX}/{journal_entry_id_1}?next=%2F_next" | ||||
|         delete_uri: str = f"{PREFIX}/{journal_entry_id_1}/delete" | ||||
|         response: httpx.Response | ||||
|  | ||||
|         form: dict[str, str] = self.__get_add_form() | ||||
|         key: str = [x for x in form if x.endswith("-account_code")][0] | ||||
|         form[key] = Accounts.PAYABLE | ||||
|         journal_entry_id_2: int = add_journal_entry(self.client, form) | ||||
|         with self.app.app_context(): | ||||
|             journal_entry: JournalEntry | None \ | ||||
|                 = db.session.get(JournalEntry, journal_entry_id_2) | ||||
|             self.assertIsNotNone(journal_entry) | ||||
|             line_item: JournalEntryLineItem \ | ||||
|                 = [x for x in journal_entry.line_items | ||||
|                    if x.account_code == Accounts.PAYABLE][0] | ||||
|         add_journal_entry( | ||||
|             self.client, | ||||
|             form={"csrf_token": self.csrf_token, | ||||
|                   "next": NEXT_URI, | ||||
|                   "date": date.today().isoformat(), | ||||
|                   "currency-1-code": line_item.currency_code, | ||||
|                   "currency-1-debit-1-original_line_item_id": line_item.id, | ||||
|                   "currency-1-debit-1-account_code": line_item.account_code, | ||||
|                   "currency-1-debit-1-amount": "1"}) | ||||
|  | ||||
|         # Cannot delete the journal entry that is in use | ||||
|         response = self.client.post(f"{PREFIX}/{journal_entry_id_2}/delete", | ||||
|                                     data={"csrf_token": self.csrf_token, | ||||
|                                           "next": NEXT_URI}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.headers["Location"], | ||||
|                          f"{PREFIX}/{journal_entry_id_2}?next=%2F_next") | ||||
|  | ||||
|         # Success | ||||
|         response = self.client.get(detail_uri) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         response = self.client.post(delete_uri, | ||||
| @@ -1132,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 | ||||
| @@ -1157,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. | ||||
| @@ -1531,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) | ||||
| @@ -1697,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) | ||||
| @@ -1717,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) | ||||
| @@ -1806,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 | ||||
| @@ -1831,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 | ||||
| @@ -1883,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) | ||||
| @@ -1899,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}"} | ||||
| @@ -72,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]: | ||||
| @@ -108,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")) | ||||
|   | ||||
| @@ -27,7 +27,7 @@ First written: 2023/1/27 | ||||
|   <meta name="author" content="{{ "imacat" }}" /> | ||||
|   <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" crossorigin="anonymous"> | ||||
|   <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.2.1/css/all.min.css" crossorigin="anonymous"> | ||||
|   <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.2.10/dist/css/tempus-dominus.min.css" crossorigin="anonymous"> | ||||
|   <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.4.3/dist/css/tempus-dominus.min.css" crossorigin="anonymous"> | ||||
|   {% block styles %}{% endblock %} | ||||
|   <script src="{{ url_for("babel_catalog") }}"></script> | ||||
|   <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script> | ||||
|   | ||||
| @@ -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