271 Commits

Author SHA1 Message Date
884e37fe1b Advanced to version 0.6.0. 2023-03-18 23:38:41 +08:00
cc6a73211e Updated the Sphinx documentation. 2023-03-18 23:38:19 +08:00
2299b86d0f Updated the translation. 2023-03-18 23:37:08 +08:00
6d293a1aac Added the JavaScript getInstances method to the SummaryEditor and AccountSelector classes, so that it is easier to deal with the case when the debit and credit versions are not both exist. 2023-03-18 23:36:38 +08:00
a2311aee24 Revised to prevent word wrapping in the button to choose the original entry in the summary editor. 2023-03-18 23:20:49 +08:00
5571c0d01f Renamed all the is_XXX_needed properties to is_need_XXX. For example, especially the is_offset_needed property to is_need_offset, to be clear and understandable. 2023-03-18 22:52:29 +08:00
98e1bad413 Renamed the test_not_needed test to test_not_need in the PaginationTestCase test case. 2023-03-18 22:43:53 +08:00
7ff52d99e6 Removed the unused is_offset_chooser_needed property from the AccountOption data model. 2023-03-18 22:40:56 +08:00
cc440a4110 Renamed the "_is_payable_needed" and "_is_receivable_needed" properties in the TransactionForm form to "_is_need_payable" and "_is_need_receivable", respectively, for readability and understandability. 2023-03-18 22:39:26 +08:00
f5149a0c37 Replaced the long parameter list with the JournalEntrySubForm instance in the onEdit method of the JavaScript JournalEntryEditor, to simplify the code. 2023-03-18 22:36:50 +08:00
ca928636fd Replaces the datasets with object attributes to store the currency code and entry type in the JavaScript OriginalEntrySelector class. 2023-03-18 22:11:45 +08:00
4a8297d594 Revised the documentation of the JavaScript OriginalEntrySelector class. 2023-03-18 22:11:45 +08:00
915e4408e1 Revised the JavaScript to initialize the OriginalEntrySelector instance in JournalEntryEditor, so that the journal entry editor holds the OriginalEntrySelector instance. It can find the OriginalEntrySelector instance without needing to invoke its static methods. Removed the redundant static methods from the OriginalEntrySelector class. 2023-03-18 22:11:45 +08:00
fd9eac06f6 Merged the code in the #initializeSummaryEditors method into the constructor in the JavaScript JournalEntryEditor class. 2023-03-18 22:11:45 +08:00
403942dfc0 Added a missing semicolon in the saveOriginalEntry method of the JavaScript JournalEntryEditor class. 2023-03-18 22:11:45 +08:00
35dc513760 Revised the #initializeSummaryEditors method of the JavaScript JournalEntryEditor class to construct the SummaryEditor instances with the entry type instead of the form element. Replaced the form element with the entry type in the constructor of the SummaryEditor class. Removed the unused accounting-summary-editor class and data-entry-type attributes from the template of the summary editor. 2023-03-18 22:11:45 +08:00
01861f0b6a Merged the code in the #initializeAccountSelectors method into the constructor in the JavaScript JournalEntryEditor class. 2023-03-18 22:11:45 +08:00
8c10f1e96a Revised the ledger not to show the accumulated balance of the nominal accounts. The accumulated balance does not make sense for nominal accounts. 2023-03-18 22:11:45 +08:00
5f7fc0b8e8 Added the is_real pseudo property to the Account data model, and changed the is_nominal pseudo property to be the opposite of the is_real pseudo property. 2023-03-18 22:11:45 +08:00
700c179774 Applied the is_nominal pseudo property to the __get_brought_forward_entry method of the EntryCollector of the ledger. 2023-03-18 22:11:45 +08:00
cabe02f7d0 Added the is_nominal pseudo property to the Account data model. 2023-03-18 22:11:45 +08:00
5ceb9f2e83 Fixed and renamed the "__query_currency_period" method of the AccountCollector of the balance sheet to "__query_current_period". 2023-03-18 22:11:45 +08:00
fe1c7669b6 Added owner's equity accounts (base code starts with "3") when calculating the change of owner's equity in the specified period. 2023-03-18 22:11:45 +08:00
4eac10981f Added owner's equity (base code starts with "3") to the accounts that can take offset. 2023-03-18 22:11:44 +08:00
c869bccc04 Reordered the methods of the Account data model. 2023-03-18 22:11:44 +08:00
61c111db69 Revised the journal, the ledger, the income and expenses log, and the search result to show the last page first as the default upon pagination. 2023-03-18 22:11:44 +08:00
34f63c1cdf Renamed the "isOriginalEntry", "is-original-entry", "is_original_entry", and "isOriginalEntry()" methods and properties of journal entries to "isNeedOffset", "is-need-offset", "is_need_offset", and "isNeedOffset()", to be clear and understandable. 2023-03-18 22:11:44 +08:00
a643d9e811 Renamed the isAccountOffsetNeeded parameter to isAccountNeedOffset in the saveSummaryWithAccount method of the JournalEntryEditor class. 2023-03-18 22:11:44 +08:00
2239ddfad1 Revised the JavaScript to initialize the AccountSelector instances in JournalEntryEditor, so that the journal entry editor holds the AccountSelector instances. It can find the AccountSelector instance without needing to invoke its static methods. Removed the redundant static methods from the AccountSelector class. 2023-03-18 22:11:44 +08:00
12fbe36b9a Revised the JavaScript to initialize the SummaryEditor instances in JournalEntryEditor, so that the journal entry editor holds the SummaryEditor instances. It can find the SummaryEditor instance without needing to invoke its static methods. Removed the redundant static methods from the SummaryEditor class. 2023-03-18 22:11:44 +08:00
46e34bb89a Removed setting the redundant "entryType" dataset from the "onAddNew" and "onEdit" methods of the JournalEntryEditor class. It is not used anymore. 2023-03-18 22:11:44 +08:00
c9453d3023 Removed the redundant "entryType" parameter from the static "start" method of JavaScript AccountSelector. 2023-03-18 22:11:44 +08:00
fc766724c4 Removed the redundant "summary" parameter from the "#onOpen" and static "start" methods of JavaScript SummaryEditor. 2023-03-18 22:11:44 +08:00
38c394c0af Added the TransactionForm instance to the constructor of the JournalEntryEditor instance, so that the journal entry editor holds an instance of the transaction form, too. It does not need to find the transaction form all the way from the side property that may not be available. Retired the redundant getTransactionForm method from the JournalEntryEditor class. 2023-03-18 22:11:44 +08:00
67e2b06d37 Revised the JavaScript to initialize the JournalEntryEditor in TransactionForm, so that the transaction form holds the JournalEntryEditor instance. The DebitCreditSideSubForm and JournalEntrySubForm instances can find the JournalEntryEditor instance from the parent form, without needing to invoke its static methods. Removed the redundant static methods from the JournalEntryEditor class. 2023-03-18 22:11:44 +08:00
be10a8d99e Revised the coding style with the JavaScript arrow functions for the transaction form. 2023-03-18 22:11:44 +08:00
fbeec600b7 Replaced the long parameter list with the JournalEntryEditor instance in the save method of the JavaScript JournalEntrySubForm sub-form, to simplify the code. 2023-03-18 22:11:44 +08:00
1a54592d4c Added the amount attribute to the JavaScript JournalEntryEditor class to pass the amount to the JournalEntrySubForm without exposing the amount input element. 2023-03-18 22:11:44 +08:00
94a527caf2 Replaces the datasets with object attributes to store the column values in the JavaScript JournalEntryEditor class. 2023-03-18 22:10:28 +08:00
0a1bbbdd47 Replaced the isOriginalEntry dataset attribute with the isNeedOffset property in the JavaScript JournalEntryEditor. It does not make sense to store that information in the HTML. 2023-03-18 09:47:57 +08:00
82b63e4bd4 Revised the coding style in the JavaScript journal entry editor. 2023-03-18 04:02:48 +08:00
e1d1aff0c1 Simplified the code in the #resetDeleteCurrencyButtons method of the JavaScript TransactionForm form. 2023-03-18 03:55:55 +08:00
2e5f9ee01f Simplified the text data in the TestData clas in testlib_offset.py. 2023-03-18 03:41:51 +08:00
f901a0020f Revised the amount limitation tests in the OffsetTestCase test case, to be clear. 2023-03-18 03:38:07 +08:00
fc2be75c3b Changed the type of the amount property in the testing JournalEntryData data model from string to Decimal. 2023-03-18 03:21:47 +08:00
96c131940b Revised the date limitation tests in the OffsetTestCase test case, to be clear. 2023-03-18 03:08:08 +08:00
b9435a255b Added the "/.errors" route to the application in the "create_test_app" function in testlib.py, to make it easier to test. 2023-03-18 02:59:28 +08:00
56045f0faf Removed the unused __entry pseudo property from the JournalEntryForm form. 2023-03-17 22:52:38 +08:00
08d1e60238 Fixed the journal, the ledger, the income ane expenses log, and the search result to respect the transaction number before the debit/credit and the journal entry nuber. 2023-03-17 22:39:29 +08:00
d88b3ac770 Added to track the net balance and offset of the original entries. 2023-03-17 22:32:01 +08:00
40e329d37f Reordered the validators in the "accounting.transaction.forms.journal_entry" module. 2023-03-16 20:42:32 +08:00
23a0721d8d Added assert in the be function in the "accounting.utils.cast" module, to insure the correctness of the expression received. 2023-03-15 23:23:01 +08:00
2b2c665eb6 Replaced the if checks with assert in the IsBalanced validator of the currency sub-form of the transaction form, the NoOffsetNominalAccount validator of the account form, and the CodeUnique validator of the currency form. 2023-03-15 22:25:24 +08:00
954173a2c2 Removed the unused list-group-item-success class from style.css. 2023-03-15 01:43:49 +08:00
91e6dc6668 Removed an excess tailing blank line from the "accounting.currency.views" module. 2023-03-15 01:10:05 +08:00
e9d8a8fcd8 Added the "accounting.utils.cast" module to cast the things to the expected type in order to supress the warnings from PyCharm. 2023-03-15 01:09:59 +08:00
4c84686395 Removed an unused import from the "accounting" module. 2023-03-15 00:51:56 +08:00
61fd1849ed Removed the annotation future import from the "accounting.transaction.utils.account_option", "accounting.transaction.forms.journal_entry", and "accounting.transaction.forms.reorder" modules. 2023-03-14 21:51:19 +08:00
a67158f8f6 Moved the CodeUnique validator from an inner class of the CurrencyForm form to an independent class, and removed the annotation future import from the "accounting.currency.forms" module. 2023-03-14 21:48:11 +08:00
5c6bfd8b49 Revised the coding style of the NeedSomeCurrencies validator. 2023-03-14 21:42:02 +08:00
d9ecf51c6d Added the "create_test_app" function in testlib.py to replace "create_app" to prevent common mistakes. Added a get_csrf_token_view route to the application, and changed the get_csrf_token function to retrieve the CSRF token with the route without parsing the HTML for the CSRF token. 2023-03-14 21:28:35 +08:00
5d31eb9172 Removed the unnecessary future annotation import from the "accounting.transaction.forms.transaction" module. 2023-03-14 20:44:06 +08:00
fadce244c5 Revised the type hint and the coding style of the NeedSomeCurrencies validator. 2023-03-14 20:43:28 +08:00
cbe7c6ca6d Added dummy.js to .gitignore and MANIFEST.in for exclusion. 2023-03-14 17:03:22 +08:00
b03938fb2e Added test_temp.py to the exclusion in MANIFEST.in. 2023-03-14 17:03:21 +08:00
8061a23fdc Renamed the AbstractUserUtils class to UserUtilityInterface, and added the can_view and can_edit functions to the UserUtilityInterface interface. There is no need to separately supply two additional can_view and can_edit callbacks. 2023-03-14 17:03:18 +08:00
cd8a480cd0 Revised the documentation of the AbstractUserUtils class. 2023-03-14 17:03:16 +08:00
b8b87714eb Revised the documentation of the JavaScript summary editor. 2023-03-14 17:03:12 +08:00
bf2f96621d Revised so that when the account selector finds the codes from the form, the journal entry editor is used to find the form instead of messing-up with the TransactionForm class and its static method that was a shortcut to the private instance method of the same name. 2023-03-14 17:03:10 +08:00
2d771f04be Fixed so that saving the journal entry from the journal entry editor triggers updating the total of the debit or credit side, which in turn triggers validating the balance if it is on a transfer form. This fixed the problem that deleting a journal entry updates total but not re-validating the balance. 2023-03-14 17:03:06 +08:00
3a12472d4b Fixed the indent in the template of the account selector. 2023-03-14 17:03:01 +08:00
d5a686a5d8 Removed trailing blank spaces from the JavaScript summary editor. 2023-03-14 17:02:58 +08:00
690f89e29a Removed the unused accounting-debit-account-code and accounting-credit-account-code HTML classes. 2023-03-14 17:02:54 +08:00
82a6a53dc4 Revised the account selector to find the account codes from the form through the TransactionForm class, but not finding the codes by itself. 2023-03-14 17:02:52 +08:00
cdd31b1047 Added the missing documentation to the static initialize method of JavaScript TransactionForm class. 2023-03-14 17:02:50 +08:00
5bad949cfa Fixed the documentation of the entryType parameter in the constructor of the JavaScript DebitCreditSideSubForm sub-form. 2023-03-14 17:02:48 +08:00
3826646d06 Reordered the journal entry editor and put the summary first and the account later. 2023-03-14 17:02:46 +08:00
74071e8997 Removed the unused static validateAccount method from the JavaScript journal entry editor. 2023-03-14 17:02:43 +08:00
3ce34803f3 Moved the journal entry editor from the transaction-form.js to a new independent JavaScript file journal-entry-editor.js. 2023-03-14 17:02:41 +08:00
232f73172f Removed the prefix from the journal entry sub-form. 2023-03-14 17:02:39 +08:00
ff1bb7142b Removed the unused data-prefix attribute from the currency sub-forms of the transaction form. 2023-03-14 17:02:37 +08:00
7155bf635a Removed the data-entry-type attribute from the journal entry editor form. The entry type is passed by the object. There is no need to store this information in the HTML anymore. 2023-03-14 17:02:35 +08:00
c306ff8009 Revised the JavaScript journal entry editor and account selector so that the account selector work with the journal entry editor and does not get into the detail of the journal entry editor. 2023-03-14 17:02:32 +08:00
b344abce06 Added the #prefix property to the journal entry editor to simplify the consistency. 2023-03-14 17:02:29 +08:00
b3c666c872 Fixed the addJournalEntry method of the DebitCreditSideSubForm sub-form to re-validate the whole side after a new journal entry is added. 2023-03-14 17:02:14 +08:00
6a671cac84 Revised the JavaScript journal entry editor and summary editor so that the summary editor work with the journal entry editor and does not get into the detail of the journal entry editor. 2023-03-14 16:59:54 +08:00
fe87c3a7de Fixed the documentation of the #side property of the JavaScript JournalEntryEditor class. 2023-03-14 16:59:51 +08:00
2013f8cbd9 Removed the initializeNewJournalEntry method from the JavaScript SummaryEditor. It does not do meaningful things at all. 2023-03-14 16:59:49 +08:00
2325842471 Fixed the documentation of the JavaScript for the transaction form. 2023-03-14 16:59:48 +08:00
c80e58b049 Renamed the journal entry form to journal entry editor, to be clear. 2023-03-14 16:59:46 +08:00
be0ae5eba4 Replaced the function-based JavaScript with the object-oriented TransactionForm, CurrencySubForm, DebitCreditSideSubForm, JournalEntrySubForm, and JournalEntryForm classes for the transaction form. 2023-03-14 16:59:36 +08:00
2b84f64554 Replaced the function-based JavaScript with the object-oriented AccountForm class for the currency form. 2023-03-12 22:15:56 +08:00
0a658a76e8 Replaced the function-based JavaScript with the object-oriented AccountForm class for the account form. 2023-03-12 22:15:54 +08:00
50dc79d865 Added the missing is-invalid class on errors to the currency field in the currency sub-forms of the transaction form. 2023-03-12 16:51:25 +08:00
8e5377a416 Replaced the payable account with the petty-cash account in the SummeryEditorTestCase test case. 2023-03-12 01:34:47 +08:00
4299fd6fbd Revised the code in the JavaScript initializeBaseAccountSelector function in the account form. 2023-03-12 01:34:45 +08:00
1d6a53f7cd Revised the account form so that the if-offset-needed option is only available for real accounts. 2023-03-12 01:34:42 +08:00
bb2993b0c0 Reordered the code in the "accounting.transaction.forms.journal_entry" module. 2023-03-11 20:36:38 +08:00
f6946c1165 Revised the IsBalanced validator so that it no longer need the __future__ annotation. 2023-03-11 19:10:47 +08:00
8e219d8006 Fixed the type hint of the form parameter in the NeedSomeJournalEntries validator. 2023-03-11 19:10:44 +08:00
53565eb9e6 Changed the IsBalanced validator from an inner class inside the TransferCurrencyForm form to an independent class. 2023-03-11 19:10:42 +08:00
965e78d8ad Revised the rule for the accounts that need offset in the accounting-init-accounts console command. 2023-03-11 17:15:08 +08:00
74b81d3e23 Renamed the offset_original_id column to original_entry_id, and the offset_original relationship to original_entry in the JournalEntry data model. 2023-03-11 16:34:30 +08:00
a0fba6387f Added the order to the search report. 2023-03-11 16:34:30 +08:00
d28bdf2064 Revised the parameter order in the template of the currency sub-form of the transaction form. 2023-03-11 16:34:29 +08:00
edf0c00e34 Shortened the names of the #filterAccountOptions, #getAccountCodeUsedInForm, and #shouldAccountOptionShow methods to #filterOptions, #getCodesUsedInForm, and #shouldOptionShow, respectively, in the JavaScript AccountSelector class. 2023-03-11 16:34:29 +08:00
107d161379 Removed a debug output from the JavaScript AccountSelector class. 2023-03-11 16:34:29 +08:00
f2c184f769 Rewrote the JavaScript AccountSelector to store the page elements in the object. 2023-03-11 16:34:28 +08:00
b45986ecfc Fixed the parameter type for the JavaScript AccountSelector class. 2023-03-11 16:34:28 +08:00
a2c2452ec5 Added a missing blank line to the JavaScript AccountSelector class. 2023-03-11 16:34:28 +08:00
5194258b48 Removed the redundant #init method from the JavaScript AccountSelector class. 2023-03-11 16:34:28 +08:00
3fe7eb41ac Removed the unused "__in_use_account_id" property from the TransactionForm form. 2023-03-11 16:34:28 +08:00
7fb9e2f0a1 Added missing documentation to the OptionLink data model in the "accounting.report.utils.option_link" module. 2023-03-11 16:34:28 +08:00
1d443f7b76 Renamed the "accounting.transaction.form" module to "accounting.transaction.forms". It only contains forms now. 2023-03-11 16:34:28 +08:00
6ad4fba9cd Moved the "accounting.transaction.operators", "accounting.transaction.summary_editor" and "accounting.transaction.form.account_option" modules into the "accounting.transaction.utils" module. 2023-03-11 16:34:28 +08:00
3dda6531b5 Split the "accounting.transaction.forms" module into various submodules in the "accounting.transaction.form" module. 2023-03-11 16:33:51 +08:00
4d11517e21 Advanced to version 0.5.0. 2023-03-10 08:36:22 +08:00
308e4ac69d Updated the Sphinx documentation. 2023-03-10 08:36:08 +08:00
de09e1498b Added the __get_default_page_uri() function to the "accounting.transaction.views" module to simplify the code. 2023-03-10 08:34:44 +08:00
c26c4686c5 Renamed the "original_id" column to "offset_original_id", and the "original" and "offset" relationships to "offset_original" and "offsets", respectively, in the JournalEntry data model. 2023-03-10 08:25:38 +08:00
c95f4fcc47 Added the __str property and changed the query_values property from a pseudo property to a real property in the AccountOption data model, so that it does not need to hold the account object forever. 2023-03-09 22:50:18 +08:00
71af74fc8a Added documentation to the properties of the AccountOption data model. 2023-03-09 22:47:46 +08:00
56e972c371 Fixed so that the download buttons on the report pages are disabled when there is no data. 2023-03-09 22:29:44 +08:00
7feb6da062 Fixed the JavaScript period chooser error when there is no data. 2023-03-09 22:25:26 +08:00
af71874f9d Fixed an error checking if there is any data in the PeriodChooser utility. 2023-03-09 22:20:24 +08:00
3fa8818a27 Added the is_check_as parameter to the get_txn_op function so that the "as" query parameter is not checked when showing the transaction detail. 2023-03-09 22:14:22 +08:00
be46d8aa14 Renamed the default_io_account_code and default_io_account functions to default_ie_account_code and default_ie_account, respectively. That was a mistake. 2023-03-09 20:59:21 +08:00
20f55058ac Shortened the name of the "accounting.report.utils.income_expenses_account" module to "accounting.report.utils.ie_account". 2023-03-09 20:59:21 +08:00
e9d1a53e03 Shortened the name of the "accounting.report.utils.income_expenses_account" module to "accounting.report.utils.ie_account". 2023-03-09 20:59:21 +08:00
38141759fd Removed an excess blank line in the "accounting.report.view" module. 2023-03-09 20:59:20 +08:00
7fb3e3bc2c Shortened the names of the views of the reports. 2023-03-09 20:59:20 +08:00
05ac5158f8 Added the default report view as the income and expenses log with the default currency, default account and default period. Changed the previous default journal links to the current default. 2023-03-09 20:59:09 +08:00
ec257a4b57 Renamed the "accounting.report.period.periods" module to "accounting.report.period.shortcuts", to be clear. 2023-03-09 20:13:15 +08:00
5ebb89a6d5 Moved the month_end utility from the "accounting.report.period.period" module to the new "accounting.report.period.month_end" module. 2023-03-09 19:56:06 +08:00
900d60d1ae Moved the shortcut named periods from the "accounting.report.period.period" module to the "accounting.report.period.periods" module. 2023-03-09 19:44:53 +08:00
bc792c145f Replaced the Period.get_instance method with the get_period function in the "accounting.report.period.parser" module. Changed the parse_spec function in the "accounting.report.period.parser" to private. 2023-03-09 19:40:34 +08:00
4432484acd Replaced the PeriodSpecification object-based utility with the get_spec function-based utility, for simplicity. 2023-03-09 19:30:36 +08:00
7ad3f9e0cb Replaced the PeriodDescription object-based utility with the get_desc function-based utility, for simplicity. 2023-03-09 19:25:43 +08:00
060a52f7a2 Moved the period specification parser from the "accounting.report.period.period" module to the "accounting.report.period.parser" module. 2023-03-09 19:10:21 +08:00
c17430d211 Renamed the "accounting.report.period.period_chooser" module to "accounting.report.period.chooser", for simplicity. 2023-03-09 19:07:58 +08:00
8fd99bb617 Simplified the import of the datetime module in the "accounting.report.period.period" module. 2023-03-09 19:05:27 +08:00
ce388eb6c8 Moved the PeriodSpecification and PeriodDescription utilities from the "accounting.report.period.period" module to the "accounting.report.period.specification" and "accounting.report.period.description" modules, respectively. 2023-03-09 18:57:29 +08:00
1850f9787e Moved the period and period chooser to the "accounting.report.period" module. 2023-03-09 18:30:41 +08:00
c6d55fad1c Renamed the "accounting.report.utils.period_choosers" module to "accounting.report.utils.period_chooser", because there is only period chooser now. 2023-03-09 18:14:20 +08:00
0c647d8f21 Moved the "accounting.reports.period" and "accounting.reports.income_expense_account" utility modules into the "accounting.reports.utils" module. 2023-03-09 18:13:18 +08:00
5d1f87582e Moved the "accounting.report.reports.utils" module to "accounting.report.utils". It does not make sense to have a wierd and long module name just to make the import pretty. 2023-03-09 18:09:08 +08:00
ef086b3f81 Revised to simplify the YearPeriod period. 2023-03-09 18:03:02 +08:00
b4be1db712 Revised the imports in the "accounting.report.reports.utils.period_chooser" module. 2023-03-09 18:00:38 +08:00
5d44ebdfd8 Revised the properties of the Today, Yesterday, and AllTime periods. 2023-03-09 17:58:49 +08:00
9859604c81 Revised the documentation of the _set_properties method of the Period utility. 2023-03-09 17:56:27 +08:00
d31e495f6b Added the AllTime class as a named period. 2023-03-09 17:49:55 +08:00
7c4102be44 Fixed the documentation of the "ReportType.SEARCH" enum item. 2023-03-09 17:49:05 +08:00
1fd50e23d9 Changed the PeriodChooser utility from abstract to real, and replaced the various trivial subclasses with the get_url callable as the parameter. 2023-03-09 17:43:21 +08:00
9635448f18 Added the missing documentation to the sections property of the PageParams data model in the income statement report. 2023-03-09 17:36:33 +08:00
e7f1ca332e Revised the imports in the modules of ledger, income and expenses log, trial balance, and income statement. 2023-03-09 17:32:22 +08:00
3d2e40865e Revised the PeriodChooser utility to find the start of the data by itself. It can do that. It's child classes are all doing the same thing. There is no need to do that in its child classes. 2023-03-09 17:20:52 +08:00
5132141c68 Renamed the "is_pay_off_needed" column of the Account data model to "is_offset_needed", and the "pay_off_target_id" column of the JournalEntry data model to "original_id". 2023-03-09 17:16:05 +08:00
e37f6792c9 Replaced aria-label with aria-labelled-by in the search modal of the report, for simplicity. 2023-03-09 16:42:13 +08:00
e6b1136a14 Fixed so that the brought-forward row is not added for norminal accounts in the ledger. 2023-03-09 16:25:59 +08:00
d7bc01ccb4 Updated the Sphinx documentation. 2023-03-09 14:38:45 +08:00
27beff3f8f Renamed the accounting-search and accounting-search-label HTML ID to accounting-toolbar-search and accounting-toolbar-search-label, respectively. 2023-03-09 14:37:03 +08:00
c6c545b99f Removed the unused accounting-search-form, accounting-search-desktop-form, accounting-search-input, and accounting-search-label classes. 2023-03-09 14:37:02 +08:00
6d5a2fae6a Applied the accounting-toolbar class to the base account list, account list, and currency list. 2023-03-09 14:37:02 +08:00
8819eabcd0 Replaced the separated toolbar for the desktop and mobile screen with the accounting-toolbar class that acts differently on different screen sizes. 2023-03-09 14:37:01 +08:00
3582d960ca Replaced the toolbar button group with individual buttons on the reports. 2023-03-09 14:37:01 +08:00
02e10a301a Removed the unused custom "btn-actions" class from the templates. 2023-03-09 14:36:59 +08:00
f0187434d2 Fixed the error from the month chooser in the period chooser when the current period has no start as the default month. 2023-03-09 14:36:59 +08:00
34af52e3c3 Revised the __add_owner_s_equity method of the AccountCollector of the balance sheet to receive the period instead of the URL, and does its job when there is an amount, so that the URL is build only when there is an amount. 2023-03-09 14:36:58 +08:00
965df82c1c Fixed the logic in the __add_owner_s_equity method of the AccountCollector of the balance sheet, that when there is an existing balance, only set the URL when there's amount to be added. 2023-03-09 14:36:57 +08:00
df53f06094 Renamed the "accounting.report.reports.utils.get_url" module to "accounting.report.reports.utils.urls", and shortened the names of the utilities, for readability. 2023-03-09 14:36:56 +08:00
140d3c6010 Added the get_balance_sheet_url utility to replace the common codes to retrieve the URL of an income statement. 2023-03-09 14:36:55 +08:00
a65dccac92 Added the get_journal_url utility to replace the common codes to retrieve the URL of an income statement. 2023-03-09 14:36:55 +08:00
740e1cfac1 Added the get_trial_balance_url utility to replace the common codes to retrieve the URL of an income statement. 2023-03-09 14:36:54 +08:00
b62f31d385 Added the get_income_statement_url utility to replace the common codes to retrieve the URL of an income statement. 2023-03-09 14:36:54 +08:00
1c740b9bbc Added the get_income_expenses_url utility to replace the common codes to retrieve the URL of an income and expenses log. 2023-03-09 14:36:53 +08:00
380256eda7 Revised the imports in the reports. 2023-03-09 14:36:52 +08:00
74b695c089 Added the get_ledger_url utility to replace the common codes to retrieve the URL of a ledger. 2023-03-09 14:36:50 +08:00
6d1e705e4b Revised the documentation of the "accounting.report.reports.utils.csv_export" module. 2023-03-09 14:36:49 +08:00
8abe20dba5 Revised the __set_data method of the trial balance and the __query_balances of the income statement for consistency. 2023-03-09 14:36:48 +08:00
ed7a8ac0fd Added the __query_balance method to the AccountCollector of balance sheet to simplify the queris in the __query_accumulated and __query_currency_period methods. 2023-03-09 14:36:48 +08:00
74eee034d0 Replaced the "transaction" property with the "url" property in the ReportEntry model of the income and expenses log, so that the report entry does not need to keep the transaction object. 2023-03-09 14:36:47 +08:00
d19d23fe37 Replaced the "transaction" property with the "url" property in the ReportEntry model of ledger, so that the report entry does not need to keep the transaction object. 2023-03-09 14:36:46 +08:00
4ce577d7d8 Removed the unused entry property from the ReportEntry model of the income and expenses log. 2023-03-09 14:36:45 +08:00
a340fad109 Removed the unused entry and account properties from the ReportEntry model of the ledger. 2023-03-09 14:36:45 +08:00
555ad388bc Added the debit and credit pseudo properties to the JournalEntry data model, and retired the redundant ReportEntry model from the "accounting.report.reports.journal" module. 2023-03-09 14:36:44 +08:00
2f27ad5bef Replaced querying the transactions later with the "selectinload" query option in the ledger. Retired the unused populate_entries function from the "accounting.report.reports.ledger" module. 2023-03-09 14:36:43 +08:00
c6487bf9d4 Replaced querying the transactions later with the "selectinload" query option in the income and expenses log. Retired the unused populate_entries function from the "accounting.report.reports.income_expenses" module. 2023-03-09 14:36:42 +08:00
ff3dd28cd7 Replaced querying the transactions later with the "selectinload" query option in the journal and search reports. Retired the unused populate_entries function from the "accounting.report.reports.journal" module. 2023-03-09 14:36:42 +08:00
a14ffa93ed Replaced querying the currencies later with the "selectinload" query option in the journal and search reports. 2023-03-09 14:36:41 +08:00
672fcbcbdf Replaced querying the accounts later with the "selectinload" query option in the income and expenses log. 2023-03-09 14:36:41 +08:00
cb4258dd6d Removed the unused "is_total" property from the ReportEntry class of the journal. 2023-03-09 14:36:40 +08:00
6fc21f82af Changed the entry parameter of the ReportEntry class in journal to be non-optional. There is no optional entry in its actual use. 2023-03-09 14:36:40 +08:00
13e3ef5875 Replaced querying the accounts later with the "selectinload" query option in the journal and search reports, and restored the lazy setting in the account relationship of the JournalEntry data model. 2023-03-09 14:36:40 +08:00
21b3320e66 Revised the add-txn-material-fab.html template to simplify the code to include it. 2023-03-09 14:36:39 +08:00
5c47e63ae3 Moved the add-txn-material-fab.html template from the accounting/include directory to the accounting/report/include directory, because it is only used in the reports now. 2023-03-09 14:36:39 +08:00
f59378002e Removed the list_transactions view that is not used now. 2023-03-09 14:36:38 +08:00
531e90e8ad Revised the imports in the "accounting.transaction.view" module. 2023-03-09 14:36:38 +08:00
8fc33131dd Changed the transaction operation to return to the default journal instead of the transaction list. The transaction list is to be removed. There is no link to the transaction list at all, and it's layout is undecided. 2023-03-09 14:36:38 +08:00
62716eb545 Fixed the report chooser to set the current report when the current report is the search page. 2023-03-09 14:36:36 +08:00
14d5d1e8d6 Renamed the action-buttons.html template to toolbar-buttons.html. 2023-03-09 14:36:35 +08:00
4306ed739f Added the is_search property to the report chooser to highlight the search when it is on the search page. 2023-03-09 14:36:34 +08:00
1f87bc00e8 Removed the excess "with_type" from the success redirection of the update_transaction view. 2023-03-09 14:36:33 +08:00
ff9ff4bdcf Removed the excess "with_type" from the success redirection of the delete_transaction view. 2023-03-09 14:36:33 +08:00
578233d66d Renamed the sort_accounts view to sort_transactions in the "accounting.transaction.views" module, and fixed its url endpoints on success. 2023-03-09 14:36:32 +08:00
5e7f790f87 Moved the __get_csv_rows method of the Journal report to the get_csv_rows function, and revised the Search report to use it, because both of their __get_csv_rows methods are identical. 2023-03-09 14:36:32 +08:00
d64f354ee0 Added the DATE_SPEC_RE constant to simplify the regular expression matching in the _parse_period_spec function. 2023-03-09 14:36:32 +08:00
ba3d8c6d4e Removed a redundant test in the _parse_period_spec function in the "accounting.report.period" module. 2023-03-09 14:36:31 +08:00
4f7f87b10d Removed an unused import from the "accounting.report.reports.utils" module. 2023-03-09 14:36:30 +08:00
4273f99644 Fixed the regular expression to match the extra note in the summary for security, as suggested by SonarQube. 2023-03-09 14:36:30 +08:00
ffe834bedd Added the DATE_REQUIRED constant to the "accounting.transaction.forms" module as the common date field validator. 2023-03-09 14:36:29 +08:00
e448e009c9 Simplified the declaration of the "available_years" property in the PeriodChooser utility. 2023-03-09 14:36:29 +08:00
b6802c51bb Removed an excess blank line in the __get_since_desc method of the PeriodDescription utility. 2023-03-09 14:36:29 +08:00
2515c1ea1f Added the __get_since_spec and __get_until_spec methods to simplify the __get_spec method in the PeriodSpecification utility. 2023-03-09 14:36:28 +08:00
0ef6409f75 Revised the documentation of the PeriodDescription utility. 2023-03-09 14:36:28 +08:00
ed18b81ad8 Moved the code to compose the period specification from the Period utility to the PeriodSpecification utility, to simplify the code. 2023-03-09 14:36:27 +08:00
b46cec6fab Updated the translation. 2023-03-09 14:36:27 +08:00
6c122666a0 Revised to simplify the PeriodDescription utility. 2023-03-09 14:36:27 +08:00
7ddc9ececf Added the __format_day method to the PeriodDescription utility to simplify the code. 2023-03-09 14:36:26 +08:00
4eebbd9692 Moved the code to compose the period description from the Period utility to the PeriodDescription utility, to simplify the code. 2023-03-09 14:36:25 +08:00
338b49c965 Added the __get_since_desc and __get_until_desc methods to simplify the __get_desc method in the Period utility. 2023-03-09 14:36:25 +08:00
f438f97571 Revised the styles of the f-strings in the Period utility. 2023-03-09 14:36:24 +08:00
9b273115a0 Removed the empty _set_properties method override from the YearPeriod period. 2023-03-09 14:36:24 +08:00
58d1add810 Added type hints to the CASH_CODE, ACCUMULATED_CHANGE_CODE, and NET_CHANGE_CODE constants. 2023-03-09 14:36:23 +08:00
c189615ca4 Renamed the CASH, ACCUMULATED_CHANGE, and NET_CHANGE constants to CASH_CODE, ACCUMULATED_CHANGE_CODE, and NET_CHANGE_CODE, respectively, to avoid confusion. 2023-03-09 14:36:23 +08:00
5687852dfb Added the _get_currency_options method to the BasePageParams class, and applied it to the currency_options pseudo property of the PageParams classes of the ledger, income and expenses log, trial balance, income statement, and balance sheet reports. 2023-03-09 14:36:22 +08:00
d74c62dbb7 Removed excess property documentation from the Journal and Search classes. 2023-03-09 14:36:22 +08:00
987e98ebc0 Moved the code to collect the report entries to the EntryCollector class in the Search report. 2023-03-09 14:36:21 +08:00
7083f22577 Revised the documentation in the page parameters and the report in the ledger and income and expenses log. 2023-03-09 14:36:21 +08:00
7b10eb68bc Revised the documentation of the EntryCollector class in the ledger and income and expenses log. 2023-03-09 14:36:20 +08:00
f277010991 Renamed the TrialBalanceTotal class to Total, to be short and clear. 2023-03-09 14:36:19 +08:00
729a7fd107 Renamed the TrialBalanceAccount, IncomeStatementAccount, and BalanceSheetAccount classes to ReportAccount, to be short and clear. 2023-03-09 14:36:19 +08:00
c8230c949d Renamed the Entry class to ReportEntry in the journal, ledger, income and expenses log, and search result, to be clear without confusion. 2023-03-09 14:36:18 +08:00
3c98960efe Replaced the Entry CSVRow, and populate_entries in the "accounting.report.reports.search" module with those in the journal module, because their contents are identical. 2023-03-09 14:36:18 +08:00
c5d0d91a7d Renamed the _populate_entries functions to populate_entries in journal, ledger, income and expenses log, and search result, changing them from protected to public so that they can be reused. 2023-03-09 14:36:17 +08:00
fb06e9db44 Shortened the names of the BalanceSheetSubsection and BalanceSheetSubsection classes to Section and Subsection, respectively. 2023-03-09 14:36:17 +08:00
d47e2e231b Shortened the names of the IncomeStatementSection, IncomeStatementSubsection, and IncomeStatementAccumulatedTotal classes to Section, Subsection, and AccumulatedTotal, respectively. 2023-03-09 14:36:17 +08:00
cb89f34455 Renamed the "PageParams" class to "BasePageParams", and renamed its module from "accounting.report.reports.utils.page_params" to "accounting.report.reports.utils.base_page_params". Renamed all its subclasses to PageParams, to shorten their names and make code more readable. 2023-03-09 14:36:17 +08:00
11ab4a4ba6 Revised the documentation of the CSV rows for the reports. 2023-03-09 14:36:16 +08:00
5dc8387ad9 Fixed the incorrect account in the __add_current_period method of the AccountCollector class in the "accounting.report.reports.balance_sheet" module. 2023-03-09 14:36:16 +08:00
26b70bb625 Fixed the logic for all-time in the period_spec function in the "accounting.report.reports.utils.csv_export" module. 2023-03-09 14:36:15 +08:00
f30a96d7e9 Simplified the logic in the period_spec method in the "accounting.report.reports.utils.csv_export" module. 2023-03-09 14:36:15 +08:00
a1627b7fbf Revised to use a simpler way to run the class methods in the __get_desc method of the Period utility, to prevent confusion with SonarQube. 2023-03-09 14:36:14 +08:00
7c3b8c8f44 Revised to store the newly-constructed period chooser and month chooser in variables to prevent SonarQube from complaining. 2023-03-09 14:36:13 +08:00
b19f4fa939 Added "use strict"; to all the JavaScript files. 2023-03-09 14:36:13 +08:00
41c3e06ce4 Removed the period chooser JavaScript from the search result page. 2023-03-09 14:36:12 +08:00
8a3df7a689 Revised the search report to match the amount when the query keyword is a number, instead of matching the amount as a text string. For example, "0150.00" matches 150, while "50" does not match 150. 2023-03-09 14:36:11 +08:00
196a115c99 Revised the coding style in the __get_transaction_condition method of the Search report. 2023-03-09 14:36:11 +08:00
005f9083aa Revised the constructor of the IncomeExpensesAccount pseudo account. 2023-03-09 14:36:10 +08:00
12dbae56c4 Revised the f-strings in the "accounting.models" module. 2023-03-09 14:36:10 +08:00
a98723c57b Removed an unused import from the "accounting.utils.pagination" module. 2023-03-09 14:36:10 +08:00
d5bd3b8383 Fixed an HTML error in the template of the trial balance. 2023-03-09 14:36:09 +08:00
617dd29f23 Added the period_spec function to be used to compose the download file name, to replace the spec property of the Period utility. 2023-03-09 14:36:08 +08:00
b0a4a735f3 Added the is_a_month property to the Period utility. 2023-03-09 14:36:08 +08:00
41770e38b8 Updated the translation. 2023-03-09 14:36:08 +08:00
d8a6614543 Fixed the text for the account used in the titles of the ledger and the income and expenses log. 2023-03-09 14:36:07 +08:00
8d76b5130e Fixed the localization function used in the titles of the reports. 2023-03-09 14:36:07 +08:00
43fc4b9b8d Renamed "total revenue" to "total operating revenue". 2023-03-09 14:36:07 +08:00
3ed8d7f1d2 Removed the now-unused table-row-link.js. It is replaced by the grid display. 2023-03-09 14:36:06 +08:00
ea7c194d7e Revised the period-chooser.html template to simplify the code to include it. 2023-03-09 14:36:06 +08:00
041a905fc0 Added font-awesome icons to the report chooser. 2023-03-09 14:36:05 +08:00
10d1be8bd1 Added the action-buttons.html template and retired the report-chooser.html and currency-chooser.html templates, as the template for the common action buttons. 2023-03-09 14:36:03 +08:00
6e1d35eda4 Revised the report-chooser.html template to simplify the reports. 2023-03-09 14:36:03 +08:00
52b5151fe0 Added the currency-chooser.html template to simplify the templates of the report. 2023-03-09 14:36:02 +08:00
f9fc033de6 Removed the unused RECEIVABLE, PAYABLE, and BROUGHT_FORWARD constants and the unused receivable(), payable(), brought_forward(), and net_change() shortcut methods from the Account data model. 2023-03-09 14:36:02 +08:00
116d00a557 Replaced the hard-coded cash account codes with the ACCUMULATED_CHANGE and NET_CHANGE constants and the accumulated_change() method of the Account data model. 2023-03-09 14:36:01 +08:00
329e3d5362 Replaced the hard-coded cash account codes with the CASH constant and the cash() method of the Account data model. 2023-03-09 14:36:00 +08:00
47e8944f06 Changed the constants of the common account codes in the Account data model from private to public. 2023-03-09 14:36:00 +08:00
e7c43ae390 Revised the documentation to use the term "income and expenses log" instead of "income and expenses", for consistency. 2023-03-09 14:36:00 +08:00
b8b51b34d3 Added the income-expenses-row-desktop.html and ledger-row-desktop.html templates to simplify templates of the income and expenses log and ledger. 2023-03-09 14:36:00 +08:00
d083036719 Renamed the income-expenses-mobile-row.html and ledger-mobile-row.html templates to income-expenses-row-mobile.html and ledger-row-mobile.html, respectively. 2023-03-09 14:35:59 +08:00
7fe81c710b Added the balance-sheet-section.html template to simplify the template of balance sheet. 2023-03-09 14:35:59 +08:00
144 changed files with 9266 additions and 5220 deletions

1
.gitignore vendored
View File

@ -38,3 +38,4 @@ excludes
*.mo
zh_Hans
test_temp.py
dummy.js

View File

@ -15,6 +15,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
exclude src/accounting/static/js/dummy.js
include src/accounting/translations/*
include src/accounting/translations/*/LC_MESSAGES/*
include docs/*
@ -22,6 +23,7 @@ include docs/source/*
include docs/source/_static/*
include docs/source/_templates/*
include tests/*
exclude tests/test_temp.py
include tests/test_site/*
include tests/test_site/templates/*
include tests/test_site/translations/*

View File

@ -28,10 +28,10 @@ accounting.account.forms module
:undoc-members:
:show-inheritance:
accounting.account.query module
-------------------------------
accounting.account.queries module
---------------------------------
.. automodule:: accounting.account.query
.. automodule:: accounting.account.queries
:members:
:undoc-members:
:show-inheritance:

View File

@ -20,10 +20,10 @@ accounting.base\_account.converters module
:undoc-members:
:show-inheritance:
accounting.base\_account.query module
-------------------------------------
accounting.base\_account.queries module
---------------------------------------
.. automodule:: accounting.base_account.query
.. automodule:: accounting.base_account.queries
:members:
:undoc-members:
:show-inheritance:

View File

@ -28,10 +28,10 @@ accounting.currency.forms module
:undoc-members:
:show-inheritance:
accounting.currency.query module
--------------------------------
accounting.currency.queries module
----------------------------------
.. automodule:: accounting.currency.query
.. automodule:: accounting.currency.queries
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,69 @@
accounting.report.period package
================================
Submodules
----------
accounting.report.period.chooser module
---------------------------------------
.. automodule:: accounting.report.period.chooser
:members:
:undoc-members:
:show-inheritance:
accounting.report.period.description module
-------------------------------------------
.. automodule:: accounting.report.period.description
:members:
:undoc-members:
:show-inheritance:
accounting.report.period.month\_end module
------------------------------------------
.. automodule:: accounting.report.period.month_end
:members:
:undoc-members:
:show-inheritance:
accounting.report.period.parser module
--------------------------------------
.. automodule:: accounting.report.period.parser
:members:
:undoc-members:
:show-inheritance:
accounting.report.period.period module
--------------------------------------
.. automodule:: accounting.report.period.period
:members:
:undoc-members:
:show-inheritance:
accounting.report.period.shortcuts module
-----------------------------------------
.. automodule:: accounting.report.period.shortcuts
:members:
:undoc-members:
:show-inheritance:
accounting.report.period.specification module
---------------------------------------------
.. automodule:: accounting.report.period.specification
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.report.period
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,69 @@
accounting.report.reports package
=================================
Submodules
----------
accounting.report.reports.balance\_sheet module
-----------------------------------------------
.. automodule:: accounting.report.reports.balance_sheet
:members:
:undoc-members:
:show-inheritance:
accounting.report.reports.income\_expenses module
-------------------------------------------------
.. automodule:: accounting.report.reports.income_expenses
:members:
:undoc-members:
:show-inheritance:
accounting.report.reports.income\_statement module
--------------------------------------------------
.. automodule:: accounting.report.reports.income_statement
:members:
:undoc-members:
:show-inheritance:
accounting.report.reports.journal module
----------------------------------------
.. automodule:: accounting.report.reports.journal
:members:
:undoc-members:
:show-inheritance:
accounting.report.reports.ledger module
---------------------------------------
.. automodule:: accounting.report.reports.ledger
:members:
:undoc-members:
:show-inheritance:
accounting.report.reports.search module
---------------------------------------
.. automodule:: accounting.report.reports.search
:members:
:undoc-members:
:show-inheritance:
accounting.report.reports.trial\_balance module
-----------------------------------------------
.. automodule:: accounting.report.reports.trial_balance
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.report.reports
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,47 @@
accounting.report package
=========================
Subpackages
-----------
.. toctree::
:maxdepth: 4
accounting.report.period
accounting.report.reports
accounting.report.utils
Submodules
----------
accounting.report.converters module
-----------------------------------
.. automodule:: accounting.report.converters
:members:
:undoc-members:
:show-inheritance:
accounting.report.template\_filters module
------------------------------------------
.. automodule:: accounting.report.template_filters
:members:
:undoc-members:
:show-inheritance:
accounting.report.views module
------------------------------
.. automodule:: accounting.report.views
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.report
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,77 @@
accounting.report.utils package
===============================
Submodules
----------
accounting.report.utils.base\_page\_params module
-------------------------------------------------
.. automodule:: accounting.report.utils.base_page_params
:members:
:undoc-members:
:show-inheritance:
accounting.report.utils.base\_report module
-------------------------------------------
.. automodule:: accounting.report.utils.base_report
:members:
:undoc-members:
:show-inheritance:
accounting.report.utils.csv\_export module
------------------------------------------
.. automodule:: accounting.report.utils.csv_export
:members:
:undoc-members:
:show-inheritance:
accounting.report.utils.ie\_account module
------------------------------------------
.. automodule:: accounting.report.utils.ie_account
:members:
:undoc-members:
:show-inheritance:
accounting.report.utils.option\_link module
-------------------------------------------
.. automodule:: accounting.report.utils.option_link
:members:
:undoc-members:
:show-inheritance:
accounting.report.utils.report\_chooser module
----------------------------------------------
.. automodule:: accounting.report.utils.report_chooser
:members:
:undoc-members:
:show-inheritance:
accounting.report.utils.report\_type module
-------------------------------------------
.. automodule:: accounting.report.utils.report_type
:members:
:undoc-members:
:show-inheritance:
accounting.report.utils.urls module
-----------------------------------
.. automodule:: accounting.report.utils.urls
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.report.utils
:members:
:undoc-members:
:show-inheritance:

View File

@ -10,6 +10,7 @@ Subpackages
accounting.account
accounting.base_account
accounting.currency
accounting.report
accounting.transaction
accounting.utils
@ -32,6 +33,22 @@ accounting.models module
:undoc-members:
:show-inheritance:
accounting.template\_filters module
-----------------------------------
.. automodule:: accounting.template_filters
:members:
:undoc-members:
:show-inheritance:
accounting.template\_globals module
-----------------------------------
.. automodule:: accounting.template_globals
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------

View File

@ -0,0 +1,45 @@
accounting.transaction.forms package
====================================
Submodules
----------
accounting.transaction.forms.currency module
--------------------------------------------
.. automodule:: accounting.transaction.forms.currency
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.forms.journal\_entry module
--------------------------------------------------
.. automodule:: accounting.transaction.forms.journal_entry
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.forms.reorder module
-------------------------------------------
.. automodule:: accounting.transaction.forms.reorder
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.forms.transaction module
-----------------------------------------------
.. automodule:: accounting.transaction.forms.transaction
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.transaction.forms
:members:
:undoc-members:
:show-inheritance:

View File

@ -1,6 +1,15 @@
accounting.transaction package
==============================
Subpackages
-----------
.. toctree::
:maxdepth: 4
accounting.transaction.forms
accounting.transaction.utils
Submodules
----------
@ -12,42 +21,10 @@ accounting.transaction.converters module
:undoc-members:
:show-inheritance:
accounting.transaction.dispatcher module
----------------------------------------
accounting.transaction.template\_filters module
-----------------------------------------------
.. automodule:: accounting.transaction.dispatcher
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.forms module
-----------------------------------
.. automodule:: accounting.transaction.forms
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.query module
-----------------------------------
.. automodule:: accounting.transaction.query
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.summary\_helper module
---------------------------------------------
.. automodule:: accounting.transaction.summary_helper
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.template module
--------------------------------------
.. automodule:: accounting.transaction.template
.. automodule:: accounting.transaction.template_filters
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,53 @@
accounting.transaction.utils package
====================================
Submodules
----------
accounting.transaction.utils.account\_option module
---------------------------------------------------
.. automodule:: accounting.transaction.utils.account_option
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.utils.offset\_alias module
-------------------------------------------------
.. automodule:: accounting.transaction.utils.offset_alias
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.utils.operators module
---------------------------------------------
.. automodule:: accounting.transaction.utils.operators
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.utils.original\_entries module
-----------------------------------------------------
.. automodule:: accounting.transaction.utils.original_entries
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.utils.summary\_editor module
---------------------------------------------------
.. automodule:: accounting.transaction.utils.summary_editor
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.transaction.utils
:members:
:undoc-members:
:show-inheritance:

View File

@ -4,6 +4,14 @@ accounting.utils package
Submodules
----------
accounting.utils.cast module
----------------------------
.. automodule:: accounting.utils.cast
:members:
:undoc-members:
:show-inheritance:
accounting.utils.flash\_errors module
-------------------------------------
@ -60,6 +68,14 @@ accounting.utils.strip\_text module
:undoc-members:
:show-inheritance:
accounting.utils.txn\_types module
----------------------------------
.. automodule:: accounting.utils.txn_types
:members:
:undoc-members:
:show-inheritance:
accounting.utils.user module
----------------------------

View File

@ -17,7 +17,7 @@
[metadata]
name = mia-accounting-flask
version = 0.4.0
version = 0.6.0
author = imacat
author_email = imacat@mail.imacat.idv.tw
description = The Mia! Accounting Flask project.

View File

@ -17,13 +17,12 @@
"""The accounting application.
"""
import typing as t
from pathlib import Path
from flask import Flask, Blueprint
from flask_sqlalchemy import SQLAlchemy
from accounting.utils.user import AbstractUserUtils
from accounting.utils.user import UserUtilityInterface
db: SQLAlchemy = SQLAlchemy()
"""The database instance."""
@ -31,19 +30,13 @@ data_dir: Path = Path(__file__).parent / "data"
"""The data directory."""
def init_app(app: Flask, user_utils: AbstractUserUtils,
url_prefix: str = "/accounting",
can_view_func: t.Callable[[], bool] | None = None,
can_edit_func: t.Callable[[], bool] | None = None) -> None:
def init_app(app: Flask, user_utils: UserUtilityInterface,
url_prefix: str = "/accounting") -> None:
"""Initialize the application.
:param app: The Flask application.
:param user_utils: The user utilities.
:param url_prefix: The URL prefix of the accounting application.
:param can_view_func: A callback that returns whether the current user can
view the accounting data.
:param can_edit_func: A callback that returns whether the current user can
edit the accounting data.
:return: None.
"""
# The database instance must be set before loading everything
@ -73,7 +66,7 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
locale.init_app(app, bp)
from .utils import permission
permission.init_app(bp, can_view_func, can_edit_func)
permission.init_app(bp, user_utils)
from .utils import next_uri
next_uri.init_app(bp)

View File

@ -18,7 +18,6 @@
"""
import os
import re
from secrets import randbelow
import click
@ -30,7 +29,7 @@ from accounting.utils.user import has_user, get_user_pk
AccountData = tuple[int, str, int, str, str, str, bool]
"""The format of the account data, as a list of (ID, base account code, number,
English, Traditional Chinese, Simplified Chinese, is-pay-off-needed) tuples."""
English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples."""
def __validate_username(ctx: click.core.Context, param: click.core.Option,
@ -93,14 +92,36 @@ def init_accounts_command(username: str) -> None:
data: list[AccountData] = []
for base in bases_to_add:
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
is_pay_off_needed: bool = True if re.match("^[12]1[34]", base.code) \
else False
is_need_offset: bool = __is_need_offset(base.code)
data.append((get_new_id(), base.code, 1, base.title_l10n,
l10n["zh_Hant"], l10n["zh_Hans"], is_pay_off_needed))
l10n["zh_Hant"], l10n["zh_Hans"], is_need_offset))
__add_accounting_accounts(data, creator_pk)
click.echo(F"{len(data)} added. Accounting accounts initialized.")
def __is_need_offset(base_code: str) -> bool:
"""Checks that whether entries in the account need offset.
:param base_code: The code of the base account.
:return: True if entries in the account need offset, or False otherwise.
"""
# Assets
if base_code[0] == "1":
if base_code[:3] in {"113", "114", "118", "184"}:
return True
if base_code in {"1411", "1421", "1431", "1441", "1511", "1521",
"1581", "1611", "1851", ""}:
return True
return False
# Liabilities
if base_code[0] == "2":
if base_code in {"2111", "2114", "2284", "2293"}:
return False
return True
# Only assets and liabilities need offset
return False
def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
-> None:
"""Adds the accounts.
@ -113,7 +134,7 @@ def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
base_code=x[1],
no=x[2],
title_l10n=x[3],
is_pay_off_needed=x[6],
is_need_offset=x[6],
created_by_id=creator_pk,
updated_by_id=creator_pk)
for x in data]

View File

@ -53,6 +53,20 @@ class BaseAccountAvailable:
"The base account is not available."))
class NoOffsetNominalAccount:
"""The validator to check nominal account is not to be offset."""
def __call__(self, form: FlaskForm, field: BooleanField) -> None:
assert isinstance(form, AccountForm)
if not field.data:
return
if form.base_code.data is None:
return
if form.base_code.data[0] not in {"1", "2", "3"}:
raise ValidationError(lazy_gettext(
"A nominal account does not need offset."))
class AccountForm(FlaskForm):
"""The form to create or edit an account."""
base_code = StringField(
@ -66,8 +80,9 @@ class AccountForm(FlaskForm):
filters=[strip_text],
validators=[DataRequired(lazy_gettext("Please fill in the title"))])
"""The title."""
is_pay_off_needed = BooleanField()
"""Whether the the entries of this account need pay-off."""
is_need_offset = BooleanField(
validators=[NoOffsetNominalAccount()])
"""Whether the the entries of this account need offset."""
def populate_obj(self, obj: Account) -> None:
"""Populates the form data into an account object.
@ -87,7 +102,10 @@ class AccountForm(FlaskForm):
obj.base_code = self.base_code.data
obj.no = count + 1
obj.title = self.title.data
obj.is_pay_off_needed = self.is_pay_off_needed.data
if self.base_code.data[0] in {"1", "2", "3"}:
obj.is_need_offset = self.is_need_offset.data
else:
obj.is_need_offset = False
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk

View File

@ -47,8 +47,8 @@ def get_account_query() -> list[Account]:
Account.title_l10n.contains(k),
code.contains(k),
Account.id.in_(l10n_matches)]
if k in gettext("Pay-off needed"):
sub_conditions.append(Account.is_pay_off_needed)
if k in gettext("Need offset"):
sub_conditions.append(Account.is_need_offset)
conditions.append(sa.or_(*sub_conditions))
return Account.query.filter(*conditions)\

View File

@ -27,6 +27,7 @@ from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Account, BaseAccount
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.pagination import Pagination
@ -86,7 +87,7 @@ def add_account() -> redirect:
form.populate_obj(account)
db.session.add(account)
db.session.commit()
flash(lazy_gettext("The account is added successfully"), "success")
flash(s(lazy_gettext("The account is added successfully")), "success")
return redirect(inherit_next(__get_detail_uri(account)))
@ -138,12 +139,12 @@ def update_account(account: Account) -> redirect:
with db.session.no_autoflush:
form.populate_obj(account)
if not account.is_modified:
flash(lazy_gettext("The account was not modified."), "success")
flash(s(lazy_gettext("The account was not modified.")), "success")
return redirect(inherit_next(__get_detail_uri(account)))
account.updated_by_id = get_current_user_pk()
account.updated_at = sa.func.now()
db.session.commit()
flash(lazy_gettext("The account is updated successfully."), "success")
flash(s(lazy_gettext("The account is updated successfully.")), "success")
return redirect(inherit_next(__get_detail_uri(account)))
@ -159,7 +160,7 @@ def delete_account(account: Account) -> redirect:
account.delete()
sort_accounts_in(account.base_code, account.id)
db.session.commit()
flash(lazy_gettext("The account is deleted successfully."), "success")
flash(s(lazy_gettext("The account is deleted successfully.")), "success")
return redirect(or_next(__get_list_uri()))
@ -186,10 +187,10 @@ def sort_accounts(base: BaseAccount) -> redirect:
form: AccountReorderForm = AccountReorderForm(base)
form.save_order()
if not form.is_modified:
flash(lazy_gettext("The order was not modified."), "success")
flash(s(lazy_gettext("The order was not modified.")), "success")
return redirect(or_next(__get_list_uri()))
db.session.commit()
flash(lazy_gettext("The order is updated successfully."), "success")
flash(s(lazy_gettext("The order is updated successfully.")), "success")
return redirect(or_next(__get_list_uri()))

View File

@ -17,8 +17,6 @@
"""The forms for the currency management.
"""
from __future__ import annotations
from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError
from wtforms.validators import DataRequired, Regexp, NoneOf
@ -30,14 +28,11 @@ from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk
class CurrencyForm(FlaskForm):
"""The form to create or edit a currency."""
CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"]
"""The reserved codes that are not available."""
class CodeUnique:
"""The validator to check if the code is unique."""
def __call__(self, form: CurrencyForm, field: StringField) -> None:
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, CurrencyForm)
if field.data == "":
return
if form.obj_code is not None and form.obj_code == field.data:
@ -46,6 +41,11 @@ class CurrencyForm(FlaskForm):
raise ValidationError(lazy_gettext(
"Code conflicts with another currency."))
class CurrencyForm(FlaskForm):
"""The form to create or edit a currency."""
CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"]
"""The reserved codes that are not available."""
code = StringField(
filters=[strip_text],
validators=[DataRequired(lazy_gettext("Please fill in the code.")),

View File

@ -27,6 +27,7 @@ from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.pagination import Pagination
@ -88,7 +89,7 @@ def add_currency() -> redirect:
form.populate_obj(currency)
db.session.add(currency)
db.session.commit()
flash(lazy_gettext("The currency is added successfully"), "success")
flash(s(lazy_gettext("The currency is added successfully")), "success")
return redirect(inherit_next(__get_detail_uri(currency)))
@ -141,12 +142,12 @@ def update_currency(currency: Currency) -> redirect:
with db.session.no_autoflush:
form.populate_obj(currency)
if not currency.is_modified:
flash(lazy_gettext("The currency was not modified."), "success")
flash(s(lazy_gettext("The currency was not modified.")), "success")
return redirect(inherit_next(__get_detail_uri(currency)))
currency.updated_by_id = get_current_user_pk()
currency.updated_at = sa.func.now()
db.session.commit()
flash(lazy_gettext("The currency is updated successfully."), "success")
flash(s(lazy_gettext("The currency is updated successfully.")), "success")
return redirect(inherit_next(__get_detail_uri(currency)))
@ -161,7 +162,7 @@ def delete_currency(currency: Currency) -> redirect:
"""
currency.delete()
db.session.commit()
flash(lazy_gettext("The currency is deleted successfully."), "success")
flash(s(lazy_gettext("The currency is deleted successfully.")), "success")
return redirect(or_next(url_for("accounting.currency.list")))
@ -182,4 +183,3 @@ def __get_detail_uri(currency: Currency) -> str:
:return: The detail URI of the currency.
"""
return url_for("accounting.currency.detail", currency=currency)

View File

@ -21,6 +21,7 @@ from __future__ import annotations
import re
import typing as t
from datetime import date
from decimal import Decimal
import sqlalchemy as sa
@ -52,7 +53,7 @@ class BaseAccount(db.Model):
:return: The string representation of the base account.
"""
return F"{self.code} {self.title}"
return f"{self.code} {self.title}"
@property
def title(self) -> str:
@ -113,8 +114,8 @@ class Account(db.Model):
"""The account number under the base account."""
title_l10n = db.Column("title", db.String, nullable=False)
"""The title."""
is_pay_off_needed = db.Column(db.Boolean, nullable=False, default=False)
"""Whether the entries of this account need pay-off."""
is_need_offset = db.Column(db.Boolean, nullable=False, default=False)
"""Whether the entries of this account need offset."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of creation."""
@ -141,17 +142,11 @@ class Account(db.Model):
entries = db.relationship("JournalEntry", back_populates="account")
"""The journal entries."""
__CASH = "1111-001"
CASH_CODE: str = "1111-001"
"""The code of the cash account,"""
__RECEIVABLE = "1141-001"
"""The code of the receivable account,"""
__PAYABLE = "2141-001"
"""The code of the payable account,"""
__ACCUMULATED_CHANGE = "3351-001"
ACCUMULATED_CHANGE_CODE: str = "3351-001"
"""The code of the accumulated-change account,"""
__BROUGHT_FORWARD = "3352-001"
"""The code of the brought-forward account,"""
__NET_CHANGE = "3353-001"
NET_CHANGE_CODE: str = "3353-001"
"""The code of the net-change account,"""
def __str__(self) -> str:
@ -159,7 +154,7 @@ class Account(db.Model):
:return: The string representation of this account.
"""
return F"{self.base_code}-{self.no:03d} {self.title}"
return f"{self.base_code}-{self.no:03d} {self.title}"
@property
def code(self) -> str:
@ -167,7 +162,7 @@ class Account(db.Model):
:return: The code.
"""
return F"{self.base_code}-{self.no:03d}"
return f"{self.base_code}-{self.no:03d}"
@property
def title(self) -> str:
@ -203,6 +198,52 @@ class Account(db.Model):
return
self.l10n.append(AccountL10n(locale=current_locale, title=value))
@property
def is_real(self) -> bool:
"""Returns whether the account is a real account.
:return: True if the account is a real account, or False otherwise.
"""
return self.base_code[0] in {"1", "2", "3"}
@property
def is_nominal(self) -> bool:
"""Returns whether the account is a nominal account.
:return: True if the account is a nominal account, or False otherwise.
"""
return not self.is_real
@property
def query_values(self) -> list[str]:
"""Returns the values to be queried.
:return: The values to be queried.
"""
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
@property
def is_modified(self) -> bool:
"""Returns whether a product account was modified.
:return: True if modified, or False otherwise.
"""
if db.session.is_modified(self):
return True
for l10n in self.l10n:
if db.session.is_modified(l10n):
return True
return False
def delete(self) -> None:
"""Deletes this account.
:return: None.
"""
AccountL10n.query.filter(AccountL10n.account == self).delete()
cls: t.Type[t.Self] = self.__class__
cls.query.filter(cls.id == self.id).delete()
@classmethod
def find_by_code(cls, code: str) -> t.Self | None:
"""Finds an account by its code.
@ -257,37 +298,13 @@ class Account(db.Model):
cls.base_code != "3353")\
.order_by(cls.base_code, cls.no).all()
@property
def query_values(self) -> list[str]:
"""Returns the values to be queried.
:return: The values to be queried.
"""
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
@classmethod
def cash(cls) -> t.Self:
"""Returns the cash account.
:return: The cash account
"""
return cls.find_by_code(cls.__CASH)
@classmethod
def receivable(cls) -> t.Self:
"""Returns the receivable account.
:return: The receivable account
"""
return cls.find_by_code(cls.__RECEIVABLE)
@classmethod
def payable(cls) -> t.Self:
"""Returns the payable account.
:return: The payable account
"""
return cls.find_by_code(cls.__PAYABLE)
return cls.find_by_code(cls.CASH_CODE)
@classmethod
def accumulated_change(cls) -> t.Self:
@ -295,45 +312,7 @@ class Account(db.Model):
:return: The accumulated-change account
"""
return cls.find_by_code(cls.__ACCUMULATED_CHANGE)
@classmethod
def brought_forward(cls) -> t.Self:
"""Returns the brought-forward account.
:return: The brought-forward account
"""
return cls.find_by_code(cls.__BROUGHT_FORWARD)
@classmethod
def net_change(cls) -> t.Self:
"""Returns the net-change account.
:return: The net-change account
"""
return cls.find_by_code(cls.__NET_CHANGE)
@property
def is_modified(self) -> bool:
"""Returns whether a product account was modified.
:return: True if modified, or False otherwise.
"""
if db.session.is_modified(self):
return True
for l10n in self.l10n:
if db.session.is_modified(l10n):
return True
return False
def delete(self) -> None:
"""Deletes this account.
:return: None.
"""
AccountL10n.query.filter(AccountL10n.account == self).delete()
cls: t.Type[t.Self] = self.__class__
cls.query.filter(cls.id == self.id).delete()
return cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE)
class AccountL10n(db.Model):
@ -392,7 +371,7 @@ class Currency(db.Model):
:return: The string representation of the currency.
"""
return F"{self.name} ({self.code})"
return f"{self.name} ({self.code})"
@property
def name(self) -> str:
@ -588,7 +567,7 @@ class Transaction(db.Model):
for currency in self.currencies:
if len(currency.debit) > 1:
return False
if currency.debit[0].account.code != "1111-001":
if currency.debit[0].account.code != Account.CASH_CODE:
return False
return True
@ -602,10 +581,25 @@ class Transaction(db.Model):
for currency in self.currencies:
if len(currency.credit) > 1:
return False
if currency.credit[0].account.code != "1111-001":
if currency.credit[0].account.code != Account.CASH_CODE:
return False
return True
@property
def can_delete(self) -> bool:
"""Returns whether the transaction can be deleted.
:return: True if the transaction can be deleted, or False otherwise.
"""
if not hasattr(self, "__can_delete"):
def has_offset() -> bool:
for entry in self.entries:
if len(entry.offsets) > 0:
return True
return False
setattr(self, "__can_delete", not has_offset())
return getattr(self, "__can_delete")
def delete(self) -> None:
"""Deletes the transaction.
@ -635,15 +629,15 @@ class JournalEntry(db.Model):
"""True for a debit entry, or False for a credit entry."""
no = db.Column(db.Integer, nullable=False)
"""The entry number under the transaction and debit or credit."""
pay_off_target_id = db.Column(db.Integer,
original_entry_id = db.Column(db.Integer,
db.ForeignKey(id, onupdate="CASCADE"),
nullable=True)
"""The ID of the pay-off target entry."""
pay_off_target = db.relationship("JournalEntry", back_populates="pay_off",
"""The ID of the original entry."""
original_entry = db.relationship("JournalEntry", back_populates="offsets",
remote_side=id, passive_deletes=True)
"""The pay-off target entry."""
pay_off = db.relationship("JournalEntry", back_populates="pay_off_target")
"""The pay-off entries."""
"""The original entry."""
offsets = db.relationship("JournalEntry", back_populates="original_entry")
"""The offset entries."""
currency_code = db.Column(db.String,
db.ForeignKey(Currency.code, onupdate="CASCADE"),
nullable=False)
@ -655,13 +649,28 @@ class JournalEntry(db.Model):
onupdate="CASCADE"),
nullable=False)
"""The account ID."""
account = db.relationship(Account, back_populates="entries")
account = db.relationship(Account, back_populates="entries", lazy=False)
"""The account."""
summary = db.Column(db.String, nullable=True)
"""The summary."""
amount = db.Column(db.Numeric(14, 2), nullable=False)
"""The amount."""
def __str__(self) -> str:
"""Returns the string representation of the journal entry.
:return: The string representation of the journal entry.
"""
if not hasattr(self, "__str"):
from accounting.template_filters import format_date, format_amount
setattr(self, "__str",
gettext("%(date)s %(summary)s %(amount)s",
date=format_date(self.transaction.date),
summary="" if self.summary is None
else self.summary,
amount=format_amount(self.amount)))
return getattr(self, "__str")
@property
def eid(self) -> int | None:
"""Returns the journal entry ID. This is the alternative name of the
@ -678,3 +687,75 @@ class JournalEntry(db.Model):
:return: The account code.
"""
return self.account.code
@property
def debit(self) -> Decimal | None:
"""Returns the debit amount.
:return: The debit amount, or None if this is not a debit entry.
"""
return self.amount if self.is_debit else None
@property
def is_need_offset(self) -> bool:
"""Returns whether the entry needs offset.
:return: True if the entry needs offset, or False otherwise.
"""
if not self.account.is_need_offset:
return False
if self.account.base_code[0] == "1" and not self.is_debit:
return False
if self.account.base_code[0] == "2" and self.is_debit:
return False
return True
@property
def credit(self) -> Decimal | None:
"""Returns the credit amount.
:return: The credit amount, or None if this is not a credit entry.
"""
return None if self.is_debit else self.amount
@property
def net_balance(self) -> Decimal:
"""Returns the net balance.
:return: The net balance.
"""
if not hasattr(self, "__net_balance"):
setattr(self, "__net_balance", self.amount + sum(
[x.amount if x.is_debit == self.is_debit else -x.amount
for x in self.offsets]))
return getattr(self, "__net_balance")
@net_balance.setter
def net_balance(self, net_balance: Decimal) -> None:
"""Sets the net balance.
:param net_balance: The net balance.
:return: None.
"""
setattr(self, "__net_balance", net_balance)
@property
def query_values(self) -> tuple[list[str], list[str]]:
"""Returns the values to be queried.
:return: The values to be queried.
"""
def format_amount(value: Decimal) -> str:
whole: int = int(value)
frac: Decimal = (value - whole).normalize()
return str(whole) + str(abs(frac))[1:]
txn_day: date = self.transaction.date
summary: str = "" if self.summary is None else self.summary
return ([summary],
[str(txn_day.year),
"{}/{}".format(txn_day.year, txn_day.month),
"{}/{}".format(txn_day.month, txn_day.day),
"{}/{}/{}".format(txn_day.year, txn_day.month, txn_day.day),
format_amount(self.amount),
format_amount(self.net_balance)])

View File

@ -29,7 +29,7 @@ def init_app(app: Flask, bp: Blueprint) -> None:
"""
from .converters import PeriodConverter, IncomeExpensesAccountConverter
app.url_map.converters["period"] = PeriodConverter
app.url_map.converters["ioAccount"] = IncomeExpensesAccountConverter
app.url_map.converters["ieAccount"] = IncomeExpensesAccountConverter
from .views import bp as report_bp
bp.register_blueprint(report_bp, url_prefix="/reports")

View File

@ -23,8 +23,8 @@ from flask import abort
from werkzeug.routing import BaseConverter
from accounting.models import Account
from .income_expense_account import IncomeExpensesAccount
from .period import Period
from .period import Period, get_period
from .utils.ie_account import IncomeExpensesAccount
class PeriodConverter(BaseConverter):
@ -38,7 +38,7 @@ class PeriodConverter(BaseConverter):
:return: The corresponding period.
"""
try:
return Period.get_instance(value)
return get_period(value)
except ValueError:
abort(404)
@ -52,8 +52,8 @@ class PeriodConverter(BaseConverter):
class IncomeExpensesAccountConverter(BaseConverter):
"""The supplier converter to convert the income and expenses pseudo account
code from and to the corresponding pseudo account in the routes."""
"""The supplier converter to convert the income and expenses log pseudo
account code from and to the corresponding pseudo account in the routes."""
def to_python(self, value: str) -> IncomeExpensesAccount:
"""Converts an account code to an account.

View File

@ -1,555 +0,0 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The date period.
This file is largely taken from the NanoParma ERP project, first written in
2021/9/16 by imacat (imacat@nanoparma.com).
"""
import calendar
import datetime
import re
import typing as t
from accounting.locale import gettext
class Period:
"""A date period."""
def __init__(self, start: datetime.date | None, end: datetime.date | None):
"""Constructs a new date period.
:param start: The start date, or None from the very beginning.
:param end: The end date, or None till no end.
"""
self.start: datetime.date | None = start
"""The start of the period."""
self.end: datetime.date | None = end
"""The end of the period."""
self.is_default: bool = False
"""Whether the is the default period."""
self.is_this_month: bool = False
"""Whether the period is this month."""
self.is_last_month: bool = False
"""Whether the period is last month."""
self.is_since_last_month: bool = False
"""Whether the period is since last month."""
self.is_this_year: bool = False
"""Whether the period is this year."""
self.is_last_year: bool = False
"""Whether the period is last year."""
self.is_today: bool = False
"""Whether the period is today."""
self.is_yesterday: bool = False
"""Whether the period is yesterday."""
self.is_all: bool = start is None and end is None
"""Whether the period is all time."""
self.spec: str = ""
"""The period specification."""
self.desc: str = ""
"""The text description."""
self.is_type_month: bool = False
"""Whether the period is for the month chooser."""
self.is_a_year: bool = False
"""Whether the period is a whole year."""
self.is_a_day: bool = False
"""Whether the period is a single day."""
self._set_properties()
def _set_properties(self) -> None:
"""Sets the following properties.
* self.spec
* self.desc
* self.is_a_month
* self.is_type_month
* self.is_a_year
* self.is_a_day
Override this method to set the properties in the subclasses.
:return: None.
"""
self.spec = self.__get_spec()
self.desc = self.__get_desc()
if self.start is None or self.end is None:
return
self.is_type_month \
= self.start.day == 1 and self.end == _month_end(self.start)
self.is_a_year = self.start == datetime.date(self.start.year, 1, 1) \
and self.end == datetime.date(self.start.year, 12, 31)
self.is_a_day = self.start == self.end
@classmethod
def get_instance(cls, spec: str | None = None) -> t.Self:
"""Returns a period instance.
:param spec: The period specification, or omit for the default.
:return: The period instance.
:raise ValueError: When the period is invalid.
"""
if spec is None:
return ThisMonth()
named_periods: dict[str, t.Type[t.Callable[[], Period]]] = {
"this-month": lambda: ThisMonth(),
"last-month": lambda: LastMonth(),
"since-last-month": lambda: SinceLastMonth(),
"this-year": lambda: ThisYear(),
"last-year": lambda: LastYear(),
"today": lambda: Today(),
"yesterday": lambda: Yesterday(),
}
if spec in named_periods:
return named_periods[spec]()
start: datetime.date
end: datetime.date
start, end = _parse_period_spec(spec)
if start is not None and end is not None and start > end:
raise ValueError
return cls(start, end)
def __get_spec(self) -> str:
"""Returns the period specification.
:return: The period specification.
"""
if self.start is None:
if self.end is None:
return "-"
else:
if self.end.day != _month_end(self.end).day:
return "-%04d-%02d-%02d" % (
self.end.year, self.end.month, self.end.day)
if self.end.month != 12:
return "-%04d-%02d" % (self.end.year, self.end.month)
return "-%04d" % self.end.year
else:
if self.end is None:
if self.start.day != 1:
return "%04d-%02d-%02d-" % (
self.start.year, self.start.month, self.start.day)
if self.start.month != 1:
return "%04d-%02d-" % (self.start.year, self.start.month)
return "%04d-" % self.start.year
else:
try:
return self.__get_year_spec()
except ValueError:
pass
try:
return self.__get_month_spec()
except ValueError:
pass
return self.__get_day_spec()
def __get_year_spec(self) -> str:
"""Returns the period specification as a year range.
:return: The period specification as a year range.
:raise ValueError: The period is not a year range.
"""
if self.start.month != 1 or self.start.day != 1 \
or self.end.month != 12 or self.end.day != 31:
raise ValueError
if self.start.year == self.end.year:
return "%04d" % self.start.year
return "%04d-%04d" % (self.start.year, self.end.year)
def __get_month_spec(self) -> str:
"""Returns the period specification as a month range.
:return: The period specification as a month range.
:raise ValueError: The period is not a month range.
"""
if self.start.day != 1 or self.end != _month_end(self.end):
raise ValueError
if self.start.year == self.end.year \
and self.start.month == self.end.month:
return "%04d-%02d" % (self.start.year, self.start.month)
return "%04d-%02d-%04d-%02d" % (
self.start.year, self.start.month,
self.end.year, self.end.month)
def __get_day_spec(self) -> str:
"""Returns the period specification as a day range.
:return: The period specification as a day range.
:raise ValueError: The period is a month or year range.
"""
if self.start == self.end:
return "%04d-%02d-%02d" % (
self.start.year, self.start.month, self.start.day)
return "%04d-%02d-%02d-%04d-%02d-%02d" % (
self.start.year, self.start.month, self.start.day,
self.end.year, self.end.month, self.end.day)
def __get_desc(self) -> str:
"""Returns the period description.
:return: The period description.
"""
cls: t.Type[t.Self] = self.__class__
if self.start is None:
if self.end is None:
return gettext("for all time")
else:
if self.end != _month_end(self.end):
return gettext("until %(end)s",
end=cls.__format_date(self.end))
if self.end.month != 12:
return gettext("until %(end)s",
end=cls.__format_month(self.end))
return gettext("until %(end)s", end=str(self.end.year))
else:
if self.end is None:
if self.start.day != 1:
return gettext("since %(start)s",
start=cls.__format_date(self.start))
if self.start.month != 1:
return gettext("since %(start)s",
start=cls.__format_month(self.start))
return gettext("since %(start)s", start=str(self.start.year))
else:
try:
return self.__get_year_desc()
except ValueError:
pass
try:
return self.__get_month_desc()
except ValueError:
pass
return self.__get_day_desc()
@staticmethod
def __format_date(date: datetime.date) -> str:
"""Formats a date.
:param date: The date.
:return: The formatted date.
"""
return F"{date.year}/{date.month}/{date.day}"
@staticmethod
def __format_month(month: datetime.date) -> str:
"""Formats a month.
:param month: The month.
:return: The formatted month.
"""
return F"{month.year}/{month.month}"
def __get_year_desc(self) -> str:
"""Returns the description as a year range.
:return: The description as a year range.
:raise ValueError: The period is not a year range.
"""
if self.start.month != 1 or self.start.day != 1 \
or self.end.month != 12 or self.end.day != 31:
raise ValueError
start: str = str(self.start.year)
if self.start.year == self.end.year:
return gettext("in %(period)s", period=start)
end: str = str(self.end.year)
return gettext("in %(start)s-%(end)s", start=start, end=end)
def __get_month_desc(self) -> str:
"""Returns the description as a month range.
:return: The description as a month range.
:raise ValueError: The period is not a month range.
"""
if self.start.day != 1 or self.end != _month_end(self.end):
raise ValueError
start: str = F"{self.start.year}/{self.start.month}"
if self.start.year == self.end.year \
and self.start.month == self.end.month:
return gettext("in %(period)s", period=start)
if self.start.year == self.end.year:
end_month: str = str(self.end.month)
return gettext("in %(start)s-%(end)s", start=start, end=end_month)
end: str = F"{self.end.year}/{self.end.month}"
return gettext("in %(start)s-%(end)s", start=start, end=end)
def __get_day_desc(self) -> str:
"""Returns the description as a day range.
:return: The description as a day range.
:raise ValueError: The period is a month or year range.
"""
start: str = F"{self.start.year}/{self.start.month}/{self.start.day}"
if self.start == self.end:
return gettext("in %(period)s", period=start)
if self.start.year == self.end.year \
and self.start.month == self.end.month:
end_day: str = str(self.end.day)
return gettext("in %(start)s-%(end)s", start=start, end=end_day)
if self.start.year == self.end.year:
end_month_day: str = F"{self.end.month}/{self.end.day}"
return gettext("in %(start)s-%(end)s",
start=start, end=end_month_day)
end: str = F"{self.end.year}/{self.end.month}/{self.end.day}"
return gettext("in %(start)s-%(end)s", start=start, end=end)
def is_year(self, year: int) -> bool:
"""Returns whether the period is the specific year period.
:param year: The year.
:return: True if the period is the year period, or False otherwise.
"""
if not self.is_a_year:
return False
return self.start.year == year
@property
def is_type_arbitrary(self) -> bool:
"""Returns whether this period is an arbitrary period.
:return: True if this is an arbitrary period, or False otherwise.
"""
return not self.is_type_month and not self.is_a_year \
and not self.is_a_day
@property
def before(self) -> t.Self | None:
"""Returns the period before this period.
:return: The period before this period.
"""
if self.start is None:
return None
return Period(None, self.start - datetime.timedelta(days=1))
class ThisMonth(Period):
"""The period of this month."""
def __init__(self):
today: datetime.date = datetime.date.today()
this_month_start: datetime.date \
= datetime.date(today.year, today.month, 1)
super().__init__(this_month_start, _month_end(today))
self.is_default = True
self.is_this_month = True
def _set_properties(self) -> None:
self.spec = "this-month"
self.desc = gettext("This month")
self.is_a_month = True
self.is_type_month = True
class LastMonth(Period):
"""The period of this month."""
def __init__(self):
today: datetime.date = datetime.date.today()
year: int = today.year
month: int = today.month - 1
if month < 1:
year = year - 1
month = 12
start: datetime.date = datetime.date(year, month, 1)
super().__init__(start, _month_end(start))
self.is_last_month = True
def _set_properties(self) -> None:
self.spec = "last-month"
self.desc = gettext("Last month")
self.is_a_month = True
self.is_type_month = True
class SinceLastMonth(Period):
"""The period of this month."""
def __init__(self):
today: datetime.date = datetime.date.today()
year: int = today.year
month: int = today.month - 1
if month < 1:
year = year - 1
month = 12
start: datetime.date = datetime.date(year, month, 1)
super().__init__(start, None)
self.is_since_last_month = True
def _set_properties(self) -> None:
self.spec = "since-last-month"
self.desc = gettext("Since last month")
self.is_type_month = True
class ThisYear(Period):
"""The period of this year."""
def __init__(self):
year: int = datetime.date.today().year
start: datetime.date = datetime.date(year, 1, 1)
end: datetime.date = datetime.date(year, 12, 31)
super().__init__(start, end)
self.is_this_year = True
def _set_properties(self) -> None:
self.spec = "this-year"
self.desc = gettext("This year")
self.is_a_year = True
class LastYear(Period):
"""The period of last year."""
def __init__(self):
year: int = datetime.date.today().year
start: datetime.date = datetime.date(year - 1, 1, 1)
end: datetime.date = datetime.date(year - 1, 12, 31)
super().__init__(start, end)
self.is_last_year = True
def _set_properties(self) -> None:
self.spec = "last-year"
self.desc = gettext("Last year")
self.is_a_year = True
class Today(Period):
"""The period of today."""
def __init__(self):
today: datetime.date = datetime.date.today()
super().__init__(today, today)
self.is_this_year = True
def _set_properties(self) -> None:
self.spec = "today"
self.desc = gettext("Today")
self.is_a_day = True
self.is_today = True
class Yesterday(Period):
"""The period of yesterday."""
def __init__(self):
yesterday: datetime.date \
= datetime.date.today() - datetime.timedelta(days=1)
super().__init__(yesterday, yesterday)
self.is_this_year = True
def _set_properties(self) -> None:
self.spec = "yesterday"
self.desc = gettext("Yesterday")
self.is_a_day = True
self.is_yesterday = True
class TemplatePeriod(Period):
"""The period template."""
def __init__(self):
super().__init__(None, None)
def _set_properties(self) -> None:
self.spec = "PERIOD"
class YearPeriod(Period):
"""A year period."""
def __init__(self, year: int):
"""Constructs a year period.
:param year: The year.
"""
start: datetime.date = datetime.date(year, 1, 1)
end: datetime.date = datetime.date(year, 12, 31)
super().__init__(start, end)
self.spec = str(year)
self.is_a_year = True
def _set_properties(self) -> None:
pass
def _parse_period_spec(text: str) \
-> tuple[datetime.date | None, datetime.date | None]:
"""Parses the period specification.
:param text: The period specification.
:return: The start and end day of the period. The start and end day
may be None.
:raise ValueError: When the date is invalid.
"""
if text == "this-month":
today: datetime.date = datetime.date.today()
return datetime.date(today.year, today.month, 1), _month_end(today)
if text == "-":
return None, None
m = re.match(r"^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$", text)
if m is not None:
return __get_start(m[1], m[2], m[3]), \
__get_end(m[1], m[2], m[3])
m = re.match(r"^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?-$", text)
if m is not None:
return __get_start(m[1], m[2], m[3]), None
m = re.match(r"-(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$", text)
if m is not None:
return None, __get_end(m[1], m[2], m[3])
m = re.match(r"^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?-(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$", text)
if m is not None:
return __get_start(m[1], m[2], m[3]), \
__get_end(m[4], m[5], m[6])
raise ValueError
def __get_start(year: str, month: str | None, day: str | None)\
-> datetime.date:
"""Returns the start of the period from the date representation.
:param year: The year.
:param month: The month, if any.
:param day: The day, if any.
:return: The start of the period.
:raise ValueError: When the date is invalid.
"""
if day is not None:
return datetime.date(int(year), int(month), int(day))
if month is not None:
return datetime.date(int(year), int(month), 1)
return datetime.date(int(year), 1, 1)
def __get_end(year: str, month: str | None, day: str | None)\
-> datetime.date:
"""Returns the end of the period from the date representation.
:param year: The year.
:param month: The month, if any.
:param day: The day, if any.
:return: The end of the period.
:raise ValueError: When the date is invalid.
"""
if day is not None:
return datetime.date(int(year), int(month), int(day))
if month is not None:
year_n: int = int(year)
month_n: int = int(month)
day_n: int = calendar.monthrange(year_n, month_n)[1]
return datetime.date(year_n, month_n, day_n)
return datetime.date(int(year), 12, 31)
def _month_end(date: datetime.date) -> datetime.date:
"""Returns the end day of month for a date.
:param date: The date.
:return: The end day of the month of that day.
"""
day: int = calendar.monthrange(date.year, date.month)[1]
return datetime.date(date.year, date.month, day)

View File

@ -0,0 +1,22 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The period utility.
"""
from .chooser import PeriodChooser
from .parser import get_period
from .period import Period

View File

@ -0,0 +1,97 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The period chooser.
This file is largely taken from the NanoParma ERP project, first written in
2021/9/16 by imacat (imacat@nanoparma.com).
"""
import typing as t
from datetime import date
from accounting.models import Transaction
from .period import Period
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
class PeriodChooser:
"""The period chooser."""
def __init__(self, get_url: t.Callable[[Period], str]):
"""Constructs a period chooser.
:param get_url: The callback to return the URL of the current report in
a period.
"""
self.__get_url: t.Callable[[Period], str] = get_url
"""The callback to return the URL of the current report in a period."""
# Shortcut periods
self.this_month_url: str = get_url(ThisMonth())
"""The URL for this month."""
self.last_month_url: str = get_url(LastMonth())
"""The URL for last month."""
self.since_last_month_url: str = get_url(SinceLastMonth())
"""The URL since last mint."""
self.this_year_url: str = get_url(ThisYear())
"""The URL for this year."""
self.last_year_url: str = get_url(LastYear())
"""The URL for last year."""
self.today_url: str = get_url(Today())
"""The URL for today."""
self.yesterday_url: str = get_url(Yesterday())
"""The URL for yesterday."""
self.all_url: str = get_url(AllTime())
"""The URL for all period."""
self.url_template: str = get_url(TemplatePeriod())
"""The URL template."""
first: Transaction | None \
= Transaction.query.order_by(Transaction.date).first()
start: date | None = None if first is None else first.date
# Attributes
self.data_start: date | None = start
"""The start of the data."""
self.has_data: bool = start is not None
"""Whether there is any data."""
self.has_last_month: bool = False
"""Where there is data in last month."""
self.has_last_year: bool = False
"""Whether there is data in last year."""
self.has_yesterday: bool = False
"""Whether there is data in yesterday."""
self.available_years: list[int] = []
"""The available years."""
if self.has_data:
today: date = date.today()
self.has_last_month = start < date(today.year, today.month, 1)
self.has_last_year = start.year < today.year
self.has_yesterday = start < today
if start.year < today.year - 1:
self.available_years \
= reversed(range(start.year, today.year - 1))
def year_url(self, year: int) -> str:
"""Returns the period URL of a year.
:param year: The year
:return: The period URL of the year.
"""
return self.__get_url(YearPeriod(year))

View File

@ -0,0 +1,179 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The period description composer.
"""
from datetime import date, timedelta
from accounting.locale import gettext
def get_desc(start: date | None, end: date | None) -> str:
"""Returns the period description.
:param start: The start of the period.
:param end: The end of the period.
:return: The period description.
"""
if start is None and end is None:
return gettext("for all time")
if start is None:
return __get_until_desc(end)
if end is None:
return __get_since_desc(start)
try:
return __get_year_desc(start, end)
except ValueError:
pass
try:
return __get_month_desc(start, end)
except ValueError:
pass
return __get_day_desc(start, end)
def __get_since_desc(start: date) -> str:
"""Returns the description without the end day.
:param start: The start of the period.
:return: The description without the end day.
"""
def get_start_desc() -> str:
"""Returns the description of the start day.
:return: The description of the start day.
"""
if start.month == 1 and start.day == 1:
return str(start.year)
if start.day == 1:
return __format_month(start)
return __format_day(start)
return gettext("since %(start)s", start=get_start_desc())
def __get_until_desc(end: date) -> str:
"""Returns the description without the start day.
:param end: The end of the period.
:return: The description without the start day.
"""
def get_end_desc() -> str:
"""Returns the description of the end day.
:return: The description of the end day.
"""
if end.month == 12 and end.day == 31:
return str(end.year)
if (end + timedelta(days=1)).day == 1:
return __format_month(end)
return __format_day(end)
return gettext("until %(end)s", end=get_end_desc())
def __get_year_desc(start: date, end: date) -> str:
"""Returns the description as a year range.
:param start: The start of the period.
:param end: The end of the period.
:return: The description as a year range.
:raise ValueError: The period is not a year range.
"""
if start.month != 1 or start.day != 1 \
or end.month != 12 or end.day != 31:
raise ValueError
start_text: str = str(start.year)
if start.year == end.year:
return __get_in_desc(start_text)
return __get_from_to_desc(start_text, str(end.year))
def __get_month_desc(start: date, end: date) -> str:
"""Returns the description as a month range.
:param start: The start of the period.
:param end: The end of the period.
:return: The description as a month range.
:raise ValueError: The period is not a month range.
"""
if start.day != 1 or (end + timedelta(days=1)).day != 1:
raise ValueError
start_text: str = __format_month(start)
if start.year == end.year and start.month == end.month:
return __get_in_desc(start_text)
if start.year == end.year:
return __get_from_to_desc(start_text, str(end.month))
return __get_from_to_desc(start_text, __format_month(end))
def __get_day_desc(start: date, end: date) -> str:
"""Returns the description as a day range.
:param start: The start of the period.
:param end: The end of the period.
:return: The description as a day range.
:raise ValueError: The period is a month or year range.
"""
start_text: str = __format_day(start)
if start == end:
return __get_in_desc(start_text)
if start.year == end.year and start.month == end.month:
return __get_from_to_desc(start_text, str(end.day))
if start.year == end.year:
end_month_day: str = f"{end.month}/{end.day}"
return __get_from_to_desc(start_text, end_month_day)
return __get_from_to_desc(start_text, __format_day(end))
def __format_month(month: date) -> str:
"""Formats a month.
:param month: The month.
:return: The formatted month.
"""
return f"{month.year}/{month.month}"
def __format_day(day: date) -> str:
"""Formats a day.
:param day: The day.
:return: The formatted day.
"""
return f"{day.year}/{day.month}/{day.day}"
def __get_in_desc(period: str) -> str:
"""Returns the description of a whole year, month, or day.
:param period: The time period.
:return: The description of a whole year, month, or day.
"""
return gettext("in %(period)s", period=period)
def __get_from_to_desc(start: str, end: str) -> str:
"""Returns the description of a separated start and end.
:param start: The start.
:param end: The end.
:return: The description of the separated start and end.
"""
return gettext("in %(start)s-%(end)s", start=start, end=end)

View File

@ -0,0 +1,31 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The utility to return the end of a month.
"""
import calendar
from datetime import date
def month_end(day: date) -> date:
"""Returns the end day of month for a date.
:param day: The date.
:return: The end day of the month of that day.
"""
last_day: int = calendar.monthrange(day.year, day.month)[1]
return date(day.year, day.month, last_day)

View File

@ -0,0 +1,119 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The period specification parser.
"""
import calendar
import re
import typing as t
from datetime import date
from .period import Period
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
LastYear, Today, Yesterday, AllTime
DATE_SPEC_RE: str = r"(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?"
"""The regular expression of a date specification."""
def get_period(spec: str | None = None) -> Period:
"""Returns a period instance.
:param spec: The period specification, or omit for the default.
:return: The period instance.
:raise ValueError: When the period specification is invalid.
"""
if spec is None:
return ThisMonth()
named_periods: dict[str, t.Type[t.Callable[[], Period]]] = {
"this-month": lambda: ThisMonth(),
"last-month": lambda: LastMonth(),
"since-last-month": lambda: SinceLastMonth(),
"this-year": lambda: ThisYear(),
"last-year": lambda: LastYear(),
"today": lambda: Today(),
"yesterday": lambda: Yesterday(),
"all-time": lambda: AllTime(),
}
if spec in named_periods:
return named_periods[spec]()
start, end = __parse_spec(spec)
if start is not None and end is not None and start > end:
raise ValueError
return Period(start, end)
def __parse_spec(text: str) -> tuple[date | None, date | None]:
"""Parses the period specification.
:param text: The period specification.
:return: The start and end day of the period. The start and end day
may be None.
:raise ValueError: When the date is invalid.
"""
if text == "-":
return None, None
m = re.match(f"^{DATE_SPEC_RE}$", text)
if m is not None:
return __get_start(m[1], m[2], m[3]), \
__get_end(m[1], m[2], m[3])
m = re.match(f"^{DATE_SPEC_RE}-$", text)
if m is not None:
return __get_start(m[1], m[2], m[3]), None
m = re.match(f"-{DATE_SPEC_RE}$", text)
if m is not None:
return None, __get_end(m[1], m[2], m[3])
m = re.match(f"^{DATE_SPEC_RE}-{DATE_SPEC_RE}$", text)
if m is not None:
return __get_start(m[1], m[2], m[3]), \
__get_end(m[4], m[5], m[6])
raise ValueError
def __get_start(year: str, month: str | None, day: str | None) -> date:
"""Returns the start of the period from the date representation.
:param year: The year.
:param month: The month, if any.
:param day: The day, if any.
:return: The start of the period.
:raise ValueError: When the date is invalid.
"""
if day is not None:
return date(int(year), int(month), int(day))
if month is not None:
return date(int(year), int(month), 1)
return date(int(year), 1, 1)
def __get_end(year: str, month: str | None, day: str | None) -> date:
"""Returns the end of the period from the date representation.
:param year: The year.
:param month: The month, if any.
:param day: The day, if any.
:return: The end of the period.
:raise ValueError: When the date is invalid.
"""
if day is not None:
return date(int(year), int(month), int(day))
if month is not None:
year_n: int = int(year)
month_n: int = int(month)
day_n: int = calendar.monthrange(year_n, month_n)[1]
return date(year_n, month_n, day_n)
return date(int(year), 12, 31)

View File

@ -0,0 +1,129 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The date period.
This file is largely taken from the NanoParma ERP project, first written in
2021/9/16 by imacat (imacat@nanoparma.com).
"""
import typing as t
from datetime import date, timedelta
from .description import get_desc
from .month_end import month_end
from .specification import get_spec
class Period:
"""A date period."""
def __init__(self, start: date | None, end: date | None):
"""Constructs a new date period.
:param start: The start date, or None from the very beginning.
:param end: The end date, or None till no end.
"""
self.start: date | None = start
"""The start of the period."""
self.end: date | None = end
"""The end of the period."""
self.is_default: bool = False
"""Whether the is the default period."""
self.is_this_month: bool = False
"""Whether the period is this month."""
self.is_last_month: bool = False
"""Whether the period is last month."""
self.is_since_last_month: bool = False
"""Whether the period is since last month."""
self.is_this_year: bool = False
"""Whether the period is this year."""
self.is_last_year: bool = False
"""Whether the period is last year."""
self.is_today: bool = False
"""Whether the period is today."""
self.is_yesterday: bool = False
"""Whether the period is yesterday."""
self.is_all: bool = start is None and end is None
"""Whether the period is all time."""
self.spec: str = ""
"""The period specification."""
self.desc: str = ""
"""The text description."""
self.is_a_month: bool = False
"""Whether the period is a whole month."""
self.is_type_month: bool = False
"""Whether the period is for the month chooser."""
self.is_a_year: bool = False
"""Whether the period is a whole year."""
self.is_a_day: bool = False
"""Whether the period is a single day."""
self._set_properties()
def _set_properties(self) -> None:
"""Sets the following properties.
* self.spec
* self.desc
* self.is_a_month
* self.is_type_month
* self.is_a_year
* self.is_a_day
Override this method to set the properties in the subclasses, to skip
the calculation.
:return: None.
"""
self.spec = get_spec(self.start, self.end)
self.desc = get_desc(self.start, self.end)
if self.start is None or self.end is None:
return
self.is_a_month = self.start.day == 1 \
and self.end == month_end(self.start)
self.is_type_month = self.is_a_month
self.is_a_year = self.start == date(self.start.year, 1, 1) \
and self.end == date(self.start.year, 12, 31)
self.is_a_day = self.start == self.end
def is_year(self, year: int) -> bool:
"""Returns whether the period is the specific year period.
:param year: The year.
:return: True if the period is the year period, or False otherwise.
"""
if not self.is_a_year:
return False
return self.start.year == year
@property
def is_type_arbitrary(self) -> bool:
"""Returns whether this period is an arbitrary period.
:return: True if this is an arbitrary period, or False otherwise.
"""
return not self.is_type_month and not self.is_a_year \
and not self.is_a_day
@property
def before(self) -> t.Self | None:
"""Returns the period before this period.
:return: The period before this period.
"""
if self.start is None:
return None
return Period(None, self.start - timedelta(days=1))

View File

@ -0,0 +1,168 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The named shortcut periods.
"""
from datetime import date, timedelta
from accounting.locale import gettext
from .month_end import month_end
from .period import Period
class ThisMonth(Period):
"""The period of this month."""
def __init__(self):
today: date = date.today()
this_month_start: date = date(today.year, today.month, 1)
super().__init__(this_month_start, month_end(today))
self.is_default = True
self.is_this_month = True
def _set_properties(self) -> None:
self.spec = "this-month"
self.desc = gettext("This month")
self.is_a_month = True
self.is_type_month = True
class LastMonth(Period):
"""The period of this month."""
def __init__(self):
today: date = date.today()
year: int = today.year
month: int = today.month - 1
if month < 1:
year = year - 1
month = 12
start: date = date(year, month, 1)
super().__init__(start, month_end(start))
self.is_last_month = True
def _set_properties(self) -> None:
self.spec = "last-month"
self.desc = gettext("Last month")
self.is_a_month = True
self.is_type_month = True
class SinceLastMonth(Period):
"""The period of this month."""
def __init__(self):
today: date = date.today()
year: int = today.year
month: int = today.month - 1
if month < 1:
year = year - 1
month = 12
start: date = date(year, month, 1)
super().__init__(start, None)
self.is_since_last_month = True
def _set_properties(self) -> None:
self.spec = "since-last-month"
self.desc = gettext("Since last month")
self.is_type_month = True
class ThisYear(Period):
"""The period of this year."""
def __init__(self):
year: int = date.today().year
start: date = date(year, 1, 1)
end: date = date(year, 12, 31)
super().__init__(start, end)
self.is_this_year = True
def _set_properties(self) -> None:
self.spec = "this-year"
self.desc = gettext("This year")
self.is_a_year = True
class LastYear(Period):
"""The period of last year."""
def __init__(self):
year: int = date.today().year
start: date = date(year - 1, 1, 1)
end: date = date(year - 1, 12, 31)
super().__init__(start, end)
self.is_last_year = True
def _set_properties(self) -> None:
self.spec = "last-year"
self.desc = gettext("Last year")
self.is_a_year = True
class Today(Period):
"""The period of today."""
def __init__(self):
today: date = date.today()
super().__init__(today, today)
self.is_today = True
def _set_properties(self) -> None:
self.spec = "today"
self.desc = gettext("Today")
self.is_a_day = True
class Yesterday(Period):
"""The period of yesterday."""
def __init__(self):
yesterday: date = date.today() - timedelta(days=1)
super().__init__(yesterday, yesterday)
self.is_yesterday = True
def _set_properties(self) -> None:
self.spec = "yesterday"
self.desc = gettext("Yesterday")
self.is_a_day = True
class AllTime(Period):
"""The period of all time."""
def __init__(self):
super().__init__(None, None)
self.is_all = True
def _set_properties(self) -> None:
self.spec = "all-time"
self.desc = gettext("All")
class TemplatePeriod(Period):
"""The period template."""
def __init__(self):
super().__init__(None, None)
def _set_properties(self) -> None:
self.spec = "PERIOD"
class YearPeriod(Period):
"""A year period."""
def __init__(self, year: int):
"""Constructs a year period.
:param year: The year.
"""
start: date = date(year, 1, 1)
end: date = date(year, 12, 31)
super().__init__(start, end)

View File

@ -0,0 +1,120 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The period specification composer.
"""
from datetime import date, timedelta
def get_spec(start: date | None, end: date | None) -> str:
"""Returns the period specification.
:param start: The start of the period.
:param end: The end of the period.
:return: The period specification.
"""
if start is None and end is None:
return "-"
if end is None:
return __get_since_spec(start)
if start is None:
return __get_until_spec(end)
try:
return __get_year_spec(start, end)
except ValueError:
pass
try:
return __get_month_spec(start, end)
except ValueError:
pass
return __get_day_spec(start, end)
def __get_since_spec(start: date) -> str:
"""Returns the period specification without the end day.
:param start: The start of the period.
:return: The period specification without the end day
"""
if start.month == 1 and start.day == 1:
return start.strftime("%Y-")
if start.day == 1:
return start.strftime("%Y-%m-")
return start.strftime("%Y-%m-%d-")
def __get_until_spec(end: date) -> str:
"""Returns the period specification without the start day.
:param end: The end of the period.
:return: The period specification without the start day
"""
if end.month == 12 and end.day == 31:
return end.strftime("-%Y")
if (end + timedelta(days=1)).day == 1:
return end.strftime("-%Y-%m")
return end.strftime("-%Y-%m-%d")
def __get_year_spec(start: date, end: date) -> str:
"""Returns the period specification as a year range.
:param start: The start of the period.
:param end: The end of the period.
:return: The period specification as a year range.
:raise ValueError: The period is not a year range.
"""
if start.month != 1 or start.day != 1 \
or end.month != 12 or end.day != 31:
raise ValueError
start_spec: str = start.strftime("%Y")
if start.year == end.year:
return start_spec
end_spec: str = end.strftime("%Y")
return f"{start_spec}-{end_spec}"
def __get_month_spec(start: date, end: date) -> str:
"""Returns the period specification as a month range.
:param start: The start of the period.
:param end: The end of the period.
:return: The period specification as a month range.
:raise ValueError: The period is not a month range.
"""
if start.day != 1 or (end + timedelta(days=1)).day != 1:
raise ValueError
start_spec: str = start.strftime("%Y-%m")
if start.year == end.year and start.month == end.month:
return start_spec
end_spec: str = end.strftime("%Y-%m")
return f"{start_spec}-{end_spec}"
def __get_day_spec(start: date, end: date) -> str:
"""Returns the period specification as a day range.
:param start: The start of the period.
:param end: The end of the period.
:return: The period specification as a day range.
:raise ValueError: The period is a month or year range.
"""
start_spec: str = start.strftime("%Y-%m-%d")
if start == end:
return start_spec
end_spec: str = end.strftime("%Y-%m-%d")
return f"{start_spec}-{end_spec}"

View File

@ -20,27 +20,29 @@
from decimal import Decimal
import sqlalchemy as sa
from flask import url_for, render_template, Response
from flask import render_template, Response
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, BaseAccount, Account, Transaction, \
JournalEntry
from accounting.report.period import Period
from .utils.base_report import BaseReport
from .utils.csv_export import BaseCSVRow, csv_download
from .utils.option_link import OptionLink
from .utils.page_params import PageParams
from .utils.period_choosers import BalanceSheetPeriodChooser
from .utils.report_chooser import ReportChooser
from .utils.report_type import ReportType
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url, balance_sheet_url, \
income_statement_url
class BalanceSheetAccount:
"""An account in the balance sheet."""
class ReportAccount:
"""An account in the report."""
def __init__(self, account: Account, amount: Decimal, url: str):
"""Constructs an account in the balance sheet.
"""Constructs an account in the report.
:param account: The account.
:param amount: The amount.
@ -54,17 +56,17 @@ class BalanceSheetAccount:
"""The URL to the ledger of the account."""
class BalanceSheetSubsection:
"""A subsection in the balance sheet."""
class Subsection:
"""A subsection."""
def __init__(self, title: BaseAccount):
"""Constructs a subsection in the balance sheet.
"""Constructs a subsection.
:param title: The title account.
"""
self.title: BaseAccount = title
"""The title account."""
self.accounts: list[BalanceSheetAccount] = []
self.accounts: list[ReportAccount] = []
"""The accounts in the subsection."""
@property
@ -76,17 +78,17 @@ class BalanceSheetSubsection:
return sum([x.amount for x in self.accounts])
class BalanceSheetSection:
"""A section in the balance sheet."""
class Section:
"""A section."""
def __init__(self, title: BaseAccount):
"""Constructs a section in the balance sheet.
"""Constructs a section.
:param title: The title account.
"""
self.title: BaseAccount = title
"""The title account."""
self.subsections: list[BalanceSheetSubsection] = []
self.subsections: list[Subsection] = []
"""The subsections in the section."""
@property
@ -111,10 +113,10 @@ class AccountCollector:
"""The currency."""
self.__period: Period = period
"""The period."""
self.accounts: list[BalanceSheetAccount] = self.__query_balances()
self.accounts: list[ReportAccount] = self.__query_balances()
"""The balance sheet accounts."""
def __query_balances(self) -> list[BalanceSheetAccount]:
def __query_balances(self) -> list[ReportAccount]:
"""Queries and returns the balances.
:return: The balances.
@ -144,24 +146,12 @@ class AccountCollector:
Account.base_code == "3353")).all()
account_by_id: dict[int, Account] \
= {x.id: x for x in self.__all_accounts}
def get_url(account: Account) -> str:
"""Returns the ledger URL of an account.
:param account: The account.
:return: The ledger URL of the account.
"""
if self.__period.is_default:
return url_for("accounting.report.ledger-default",
currency=self.__currency, account=account)
return url_for("accounting.report.ledger",
currency=self.__currency, account=account,
period=self.__period)
self.accounts: list[BalanceSheetAccount] \
= [BalanceSheetAccount(account=account_by_id[x.id],
self.accounts: list[ReportAccount] \
= [ReportAccount(account=account_by_id[x.id],
amount=x.balance,
url=get_url(account_by_id[x.id]))
url=ledger_url(self.__currency,
account_by_id[x.id],
self.__period))
for x in account_balances]
self.__add_accumulated()
self.__add_current_period()
@ -176,12 +166,9 @@ class AccountCollector:
:return: None.
"""
code: str = "3351-001"
amount: Decimal | None = self.__query_accumulated()
url: str = url_for("accounting.report.income-statement",
currency=self.__currency,
period=self.__period.before)
self.__add_owner_s_equity(code, amount, url)
self.__add_owner_s_equity(Account.ACCUMULATED_CHANGE_CODE,
self.__query_accumulated(),
self.__period)
def __query_accumulated(self) -> Decimal | None:
"""Queries and returns the accumulated profit or loss.
@ -193,27 +180,18 @@ class AccountCollector:
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code,
Transaction.date < self.__period.start]
conditions.extend([sa.not_(Account.base_code.startswith(x))
for x in {"1", "2"}])
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount)).label("balance")
select_balance: sa.Select = sa.select(balance_func)\
.join(Transaction).join(Account).filter(*conditions)
return db.session.scalar(select_balance)
return self.__query_balance(conditions)
def __add_current_period(self) -> None:
"""Adds the accumulated profit or loss to the balances.
:return: None.
"""
code: str = "3353-001"
amount: Decimal | None = self.__query_currency_period()
url: str = url_for("accounting.report.income-statement",
currency=self.__currency, period=self.__period)
self.__add_owner_s_equity(code, amount, url)
self.__add_owner_s_equity(Account.NET_CHANGE_CODE,
self.__query_current_period(),
self.__period)
def __query_currency_period(self) -> Decimal | None:
def __query_current_period(self) -> Decimal | None:
"""Queries and returns the net income or loss for current period.
:return: The net income or loss for current period.
@ -224,47 +202,58 @@ class AccountCollector:
conditions.append(Transaction.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end)
return self.__query_balance(conditions)
@staticmethod
def __query_balance(conditions: list[sa.BinaryExpression])\
-> Decimal:
"""Queries the balance.
:param conditions: The SQL conditions for the balance.
:return: The balance.
"""
conditions.extend([sa.not_(Account.base_code.startswith(x))
for x in {"1", "2"}])
for x in {"1", "2", "3"}])
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount)).label("balance")
else_=-JournalEntry.amount))
select_balance: sa.Select = sa.select(balance_func)\
.join(Transaction).join(Account).filter(*conditions)
return db.session.scalar(select_balance)
def __add_owner_s_equity(self, code: str, amount: Decimal | None,
url: str) -> None:
period: Period) -> None:
"""Adds an owner's equity balance.
:param code: The code of the account to add.
:param amount: The amount.
:param period: The period.
:return: None.
"""
# There is an existing balance.
account_balance_by_code: dict[str, BalanceSheetAccount] \
= {x.account.code: x for x in self.accounts}
if code in account_balance_by_code:
balance: BalanceSheetAccount = account_balance_by_code[code]
balance.url = url
if amount is not None:
balance.amount = balance.amount + amount
return
# Add a new balance
if amount is None:
return
url: str = income_statement_url(self.__currency, period)
# There is an existing balance.
account_balance_by_code: dict[str, ReportAccount] \
= {x.account.code: x for x in self.accounts}
if code in account_balance_by_code:
balance: ReportAccount = account_balance_by_code[code]
balance.amount = balance.amount + amount
balance.url = url
return
# Add a new balance
account_by_code: dict[str, Account] \
= {x.code: x for x in self.__all_accounts}
self.accounts.append(BalanceSheetAccount(account=account_by_code[code],
self.accounts.append(ReportAccount(account=account_by_code[code],
amount=amount,
url=url))
class CSVHalfRow:
"""A half row in the CSV balance sheet."""
"""A half row in the CSV."""
def __init__(self, title: str | None, amount: Decimal | None):
"""The constructs a half row in the CSV balance sheet.
"""The constructs a half row in the CSV.
:param title: The title.
:param amount: The amount.
@ -276,10 +265,10 @@ class CSVHalfRow:
class CSVRow(BaseCSVRow):
"""A row in the CSV balance sheet."""
"""A row in the CSV."""
def __init__(self):
"""Constructs a row in the CSV balance sheet."""
"""Constructs a row in the CSV."""
self.asset_title: str | None = None
"""The title of the asset."""
self.asset_amount: Decimal | None = None
@ -299,16 +288,16 @@ class CSVRow(BaseCSVRow):
self.liability_title, self.liability_amount]
class BalanceSheetPageParams(PageParams):
"""The HTML parameters of the balance sheet."""
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, currency: Currency,
period: Period,
has_data: bool,
assets: BalanceSheetSection,
liabilities: BalanceSheetSection,
owner_s_equity: BalanceSheetSection):
"""Constructs the HTML parameters of the balance sheet.
assets: Section,
liabilities: Section,
owner_s_equity: Section):
"""Constructs the HTML page parameters.
:param currency: The currency.
:param period: The period.
@ -323,14 +312,14 @@ class BalanceSheetPageParams(PageParams):
"""The period."""
self.__has_data: bool = has_data
"""True if there is any data, or False otherwise."""
self.assets: BalanceSheetSection = assets
self.assets: Section = assets
"""The assets."""
self.liabilities: BalanceSheetSection = liabilities
self.liabilities: Section = liabilities
"""The liabilities."""
self.owner_s_equity: BalanceSheetSection = owner_s_equity
self.owner_s_equity: Section = owner_s_equity
"""The owner's equity."""
self.period_chooser: BalanceSheetPeriodChooser \
= BalanceSheetPeriodChooser(currency)
self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: balance_sheet_url(currency, x))
"""The period chooser."""
@property
@ -357,19 +346,8 @@ class BalanceSheetPageParams(PageParams):
:return: The currency options.
"""
def get_url(currency: Currency):
if self.period.is_default:
return url_for("accounting.report.balance-sheet-default",
currency=currency)
return url_for("accounting.report.balance-sheet",
currency=currency, period=self.period)
in_use: set[str] = set(db.session.scalars(
sa.select(JournalEntry.currency_code)
.group_by(JournalEntry.currency_code)).all())
return [OptionLink(str(x), get_url(x), x.code == self.currency.code)
for x in Currency.query.filter(Currency.code.in_(in_use))
.order_by(Currency.code).all()]
return self._get_currency_options(
lambda x: balance_sheet_url(x, self.period), self.currency)
class BalanceSheet(BaseReport):
@ -387,11 +365,11 @@ class BalanceSheet(BaseReport):
"""The period."""
self.__has_data: bool
"""True if there is any data, or False otherwise."""
self.__assets: BalanceSheetSection
self.__assets: Section
"""The assets."""
self.__liabilities: BalanceSheetSection
self.__liabilities: Section
"""The liabilities."""
self.__owner_s_equity: BalanceSheetSection
self.__owner_s_equity: Section
"""The owner's equity."""
self.__set_data()
@ -401,7 +379,7 @@ class BalanceSheet(BaseReport):
:return: None.
"""
balances: list[BalanceSheetAccount] = AccountCollector(
balances: list[ReportAccount] = AccountCollector(
self.__currency, self.__period).accounts
titles: list[BaseAccount] = BaseAccount.query\
@ -410,10 +388,9 @@ class BalanceSheet(BaseReport):
.filter(BaseAccount.code.in_({x.account.base_code[:2]
for x in balances})).all()
sections: dict[str, BalanceSheetSection] \
= {x.code: BalanceSheetSection(x) for x in titles}
subsections: dict[str, BalanceSheetSubsection] \
= {x.code: BalanceSheetSubsection(x) for x in subtitles}
sections: dict[str, Section] = {x.code: Section(x) for x in titles}
subsections: dict[str, Subsection] = {x.code: Subsection(x)
for x in subtitles}
for subsection in subsections.values():
sections[subsection.title.code[0]].subsections.append(subsection)
for balance in balances:
@ -430,7 +407,8 @@ class BalanceSheet(BaseReport):
:return: The response of the report for download.
"""
filename: str = "balance-sheet-{currency}-{period}.csv"\
.format(currency=self.__currency.code, period=self.__period.spec)
.format(currency=self.__currency.code,
period=period_spec(self.__period))
return csv_download(filename, self.__get_csv_rows())
def __get_csv_rows(self) -> list[CSVRow]:
@ -466,7 +444,7 @@ class BalanceSheet(BaseReport):
return rows
@staticmethod
def __section_csv_rows(section: BalanceSheetSection) -> list[CSVHalfRow]:
def __section_csv_rows(section: Section) -> list[CSVHalfRow]:
"""Gathers the CSV rows for a section.
:param section: The section.
@ -486,8 +464,7 @@ class BalanceSheet(BaseReport):
:return: The report as HTML.
"""
params: BalanceSheetPageParams = BalanceSheetPageParams(
currency=self.__currency,
params: PageParams = PageParams(currency=self.__currency,
period=self.__period,
has_data=self.__has_data,
assets=self.__assets,

View File

@ -22,34 +22,33 @@ from decimal import Decimal
import sqlalchemy as sa
from flask import url_for, render_template, Response
from sqlalchemy.orm import selectinload
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry
from accounting.report.income_expense_account import IncomeExpensesAccount
from accounting.report.period import Period
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.ie_account import IncomeExpensesAccount
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import income_expenses_url
from accounting.utils.cast import be
from accounting.utils.pagination import Pagination
from .utils.base_report import BaseReport
from .utils.csv_export import BaseCSVRow, csv_download
from .utils.option_link import OptionLink
from .utils.page_params import PageParams
from .utils.period_choosers import IncomeExpensesPeriodChooser
from .utils.report_chooser import ReportChooser
from .utils.report_type import ReportType
class Entry:
"""An entry in the income and expenses log."""
class ReportEntry:
"""An entry in the report."""
def __init__(self, entry: JournalEntry | None = None):
"""Constructs the entry in the income and expenses log.
"""Constructs the entry in the report.
:param entry: The journal entry.
"""
self.entry: JournalEntry | None = None
"""The journal entry."""
self.transaction: Transaction | None = None
"""The transaction."""
self.is_brought_forward: bool = False
"""Whether this is the brought-forward entry."""
self.is_total: bool = False
@ -68,19 +67,25 @@ class Entry:
"""The balance."""
self.note: str | None = None
"""The note."""
self.url: str | None = None
"""The URL to the journal entry."""
if entry is not None:
self.entry = entry
self.date = entry.transaction.date
self.account = entry.account
self.summary = entry.summary
self.income = None if entry.is_debit else entry.amount
self.expense = entry.amount if entry.is_debit else None
self.note = entry.transaction.note
self.url = url_for("accounting.transaction.detail",
txn=entry.transaction)
class EntryCollector:
"""The income and expenses log entry collector."""
"""The report entry collector."""
def __init__(self, currency: Currency, account: IncomeExpensesAccount,
period: Period):
"""Constructs the income and expenses log entry collector.
"""Constructs the report entry collector.
:param currency: The currency.
:param account: The account.
@ -92,18 +97,18 @@ class EntryCollector:
"""The account."""
self.__period: Period = period
"""The period"""
self.brought_forward: Entry | None
self.brought_forward: ReportEntry | None
"""The brought-forward entry."""
self.entries: list[Entry]
self.entries: list[ReportEntry]
"""The log entries."""
self.total: Entry | None
self.total: ReportEntry | None
"""The total entry."""
self.brought_forward = self.__get_brought_forward_entry()
self.entries = self.__query_entries()
self.total = self.__get_total_entry()
self.__populate_balance()
def __get_brought_forward_entry(self) -> Entry | None:
def __get_brought_forward_entry(self) -> ReportEntry | None:
"""Queries, composes and returns the brought-forward entry.
:return: The brought-forward entry, or None if the period starts from
@ -116,16 +121,16 @@ class EntryCollector:
else_=-JournalEntry.amount))
select: sa.Select = sa.Select(balance_func)\
.join(Transaction).join(Account)\
.filter(JournalEntry.currency_code == self.__currency.code,
.filter(be(JournalEntry.currency_code == self.__currency.code),
self.__account_condition,
Transaction.date < self.__period.start)
balance: int | None = db.session.scalar(select)
if balance is None:
return None
entry: Entry = Entry()
entry: ReportEntry = ReportEntry()
entry.is_brought_forward = True
entry.date = self.__period.start
entry.account = Account.find_by_code("3351-001")
entry.account = Account.accumulated_change()
entry.summary = gettext("Brought forward")
if balance > 0:
entry.income = balance
@ -134,7 +139,7 @@ class EntryCollector:
entry.balance = balance
return entry
def __query_entries(self) -> list[Entry]:
def __query_entries(self) -> list[ReportEntry]:
"""Queries and returns the log entries.
:return: The log entries.
@ -149,14 +154,17 @@ class EntryCollector:
txn_with_account: sa.Select = sa.Select(Transaction.id).\
join(JournalEntry).join(Account).filter(*conditions)
return [Entry(x)
return [ReportEntry(x)
for x in JournalEntry.query.join(Transaction).join(Account)
.filter(JournalEntry.transaction_id.in_(txn_with_account),
JournalEntry.currency_code == self.__currency.code,
sa.not_(self.__account_condition))
.order_by(Transaction.date,
Transaction.no,
JournalEntry.is_debit,
JournalEntry.no)]
JournalEntry.no)
.options(selectinload(JournalEntry.account),
selectinload(JournalEntry.transaction))]
@property
def __account_condition(self) -> sa.BinaryExpression:
@ -167,14 +175,14 @@ class EntryCollector:
Account.base_code.startswith("22"))
return Account.id == self.__account.id
def __get_total_entry(self) -> Entry | None:
def __get_total_entry(self) -> ReportEntry | None:
"""Composes the total entry.
:return: The total entry, or None if there is no data.
"""
if self.brought_forward is None and len(self.entries) == 0:
return None
entry: Entry = Entry()
entry: ReportEntry = ReportEntry()
entry.is_total = True
entry.summary = gettext("Total")
entry.income = sum([x.income for x in self.entries
@ -202,7 +210,7 @@ class EntryCollector:
class CSVRow(BaseCSVRow):
"""A row in the CSV income and expenses log."""
"""A row in the CSV."""
def __init__(self, txn_date: date | str | None,
account: str | None,
@ -211,7 +219,7 @@ class CSVRow(BaseCSVRow):
expense: str | Decimal | None,
balance: str | Decimal | None,
note: str | None):
"""Constructs a row in the CSV income and expenses log.
"""Constructs a row in the CSV.
:param txn_date: The transaction date.
:param account: The account.
@ -246,18 +254,18 @@ class CSVRow(BaseCSVRow):
self.income, self.expense, self.balance, self.note]
class IncomeExpensesPageParams(PageParams):
"""The HTML parameters of the income and expenses log."""
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, currency: Currency,
account: IncomeExpensesAccount,
period: Period,
has_data: bool,
pagination: Pagination[Entry],
brought_forward: Entry | None,
entries: list[Entry],
total: Entry | None):
"""Constructs the HTML parameters of the income and expenses log.
pagination: Pagination[ReportEntry],
brought_forward: ReportEntry | None,
entries: list[ReportEntry],
total: ReportEntry | None):
"""Constructs the HTML page parameters.
:param currency: The currency.
:param account: The account.
@ -275,16 +283,16 @@ class IncomeExpensesPageParams(PageParams):
"""The period."""
self.__has_data: bool = has_data
"""True if there is any data, or False otherwise."""
self.pagination: Pagination[Entry] = pagination
self.pagination: Pagination[ReportEntry] = pagination
"""The pagination."""
self.brought_forward: Entry | None = brought_forward
self.brought_forward: ReportEntry | None = brought_forward
"""The brought-forward entry."""
self.entries: list[Entry] = entries
"""The entries."""
self.total: Entry | None = total
self.entries: list[ReportEntry] = entries
"""The report entries."""
self.total: ReportEntry | None = total
"""The total entry."""
self.period_chooser: IncomeExpensesPeriodChooser \
= IncomeExpensesPeriodChooser(currency, account)
self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: income_expenses_url(currency, account, x))
"""The period chooser."""
@property
@ -317,20 +325,9 @@ class IncomeExpensesPageParams(PageParams):
:return: The currency options.
"""
def get_url(currency: Currency):
if self.period.is_default:
return url_for("accounting.report.income-expenses-default",
currency=currency, account=self.account)
return url_for("accounting.report.income-expenses",
currency=currency, account=self.account,
period=self.period)
in_use: set[str] = set(db.session.scalars(
sa.select(JournalEntry.currency_code)
.group_by(JournalEntry.currency_code)).all())
return [OptionLink(str(x), get_url(x), x.code == self.currency.code)
for x in Currency.query.filter(Currency.code.in_(in_use))
.order_by(Currency.code).all()]
return self._get_currency_options(
lambda x: income_expenses_url(x, self.account, self.period),
self.currency)
@property
def account_options(self) -> list[OptionLink]:
@ -338,56 +335,32 @@ class IncomeExpensesPageParams(PageParams):
:return: The account options.
"""
def get_url(account: IncomeExpensesAccount):
if self.period.is_default:
return url_for("accounting.report.income-expenses-default",
currency=self.currency, account=account)
return url_for("accounting.report.income-expenses",
currency=self.currency, account=account,
period=self.period)
current_al: IncomeExpensesAccount \
= IncomeExpensesAccount.current_assets_and_liabilities()
options: list[OptionLink] \
= [OptionLink(str(current_al), get_url(current_al),
= [OptionLink(str(current_al),
income_expenses_url(self.currency, current_al,
self.period),
self.account.id == 0)]
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
.join(Account)\
.filter(JournalEntry.currency_code == self.currency.code,
.filter(be(JournalEntry.currency_code == self.currency.code),
sa.or_(Account.base_code.startswith("11"),
Account.base_code.startswith("12"),
Account.base_code.startswith("21"),
Account.base_code.startswith("22")))\
.group_by(JournalEntry.account_id)
options.extend([OptionLink(str(x), get_url(IncomeExpensesAccount(x)),
options.extend([OptionLink(str(x),
income_expenses_url(
self.currency,
IncomeExpensesAccount(x),
self.period),
x.id == self.account.id)
for x in Account.query.filter(Account.id.in_(in_use))
.order_by(Account.base_code, Account.no).all()])
return options
def _populate_entries(entries: list[Entry]) -> None:
"""Populates the income and expenses entries with relative data.
:param entries: The income and expenses entries.
:return: None.
"""
transactions: dict[int, Transaction] \
= {x.id: x for x in Transaction.query.filter(
Transaction.id.in_({x.entry.transaction_id for x in entries
if x.entry is not None}))}
accounts: dict[int, Account] \
= {x.id: x for x in Account.query.filter(
Account.id.in_({x.entry.account_id for x in entries
if x.entry is not None}))}
for entry in entries:
if entry.entry is not None:
entry.transaction = transactions[entry.entry.transaction_id]
entry.date = entry.transaction.date
entry.note = entry.transaction.note
entry.account = accounts[entry.entry.account_id]
class IncomeExpenses(BaseReport):
"""The income and expenses log."""
@ -407,11 +380,11 @@ class IncomeExpenses(BaseReport):
"""The period."""
collector: EntryCollector = EntryCollector(
self.__currency, self.__account, self.__period)
self.__brought_forward: Entry | None = collector.brought_forward
self.__brought_forward: ReportEntry | None = collector.brought_forward
"""The brought-forward entry."""
self.__entries: list[Entry] = collector.entries
"""The log entries."""
self.__total: Entry | None = collector.total
self.__entries: list[ReportEntry] = collector.entries
"""The report entries."""
self.__total: ReportEntry | None = collector.total
"""The total entry."""
def csv(self) -> Response:
@ -421,7 +394,7 @@ class IncomeExpenses(BaseReport):
"""
filename: str = "income-expenses-{currency}-{account}-{period}.csv"\
.format(currency=self.__currency.code, account=self.__account.code,
period=self.__period.spec)
period=period_spec(self.__period))
return csv_download(filename, self.__get_csv_rows())
def __get_csv_rows(self) -> list[CSVRow]:
@ -429,7 +402,6 @@ class IncomeExpenses(BaseReport):
:return: The CSV rows.
"""
_populate_entries(self.__entries)
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Account"),
gettext("Summary"), gettext("Income"),
gettext("Expense"), gettext("Balance"),
@ -456,26 +428,25 @@ class IncomeExpenses(BaseReport):
:return: The report as HTML.
"""
all_entries: list[Entry] = []
all_entries: list[ReportEntry] = []
if self.__brought_forward is not None:
all_entries.append(self.__brought_forward)
all_entries.extend(self.__entries)
if self.__total is not None:
all_entries.append(self.__total)
pagination: Pagination[Entry] = Pagination[Entry](all_entries)
page_entries: list[Entry] = pagination.list
pagination: Pagination[ReportEntry] \
= Pagination[ReportEntry](all_entries, is_reversed=True)
page_entries: list[ReportEntry] = pagination.list
has_data: bool = len(page_entries) > 0
_populate_entries(page_entries)
brought_forward: Entry | None = None
brought_forward: ReportEntry | None = None
if len(page_entries) > 0 and page_entries[0].is_brought_forward:
brought_forward = page_entries[0]
page_entries = page_entries[1:]
total: Entry | None = None
total: ReportEntry | None = None
if len(page_entries) > 0 and page_entries[-1].is_total:
total = page_entries[-1]
page_entries = page_entries[:-1]
params: IncomeExpensesPageParams = IncomeExpensesPageParams(
currency=self.__currency,
params: PageParams = PageParams(currency=self.__currency,
account=self.__account,
period=self.__period,
has_data=has_data,

View File

@ -20,27 +20,28 @@
from decimal import Decimal
import sqlalchemy as sa
from flask import url_for, render_template, Response
from flask import render_template, Response
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, BaseAccount, Account, Transaction, \
JournalEntry
from accounting.report.period import Period
from .utils.base_report import BaseReport
from .utils.csv_export import BaseCSVRow, csv_download
from .utils.option_link import OptionLink
from .utils.page_params import PageParams
from .utils.period_choosers import IncomeStatementPeriodChooser
from .utils.report_chooser import ReportChooser
from .utils.report_type import ReportType
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url, income_statement_url
class IncomeStatementAccount:
"""An account in the income statement."""
class ReportAccount:
"""An account in the report."""
def __init__(self, account: Account, amount: Decimal, url: str):
"""Constructs an account in the income statement.
"""Constructs an account in the report.
:param account: The account.
:param amount: The amount.
@ -54,11 +55,11 @@ class IncomeStatementAccount:
"""The URL to the ledger of the account."""
class IncomeStatementAccumulatedTotal:
"""An accumulated total in the income statement."""
class AccumulatedTotal:
"""An accumulated total."""
def __init__(self, title: str):
"""Constructs an accumulated total in the income statement.
"""Constructs an accumulated total.
:param title: The title.
"""
@ -68,17 +69,17 @@ class IncomeStatementAccumulatedTotal:
"""The amount of the account."""
class IncomeStatementSubsection:
"""A subsection in the income statement."""
class Subsection:
"""A subsection."""
def __init__(self, title: BaseAccount):
"""Constructs a subsection in the income statement.
"""Constructs a subsection.
:param title: The title account.
"""
self.title: BaseAccount = title
"""The title account."""
self.accounts: list[IncomeStatementAccount] = []
self.accounts: list[ReportAccount] = []
"""The accounts in the subsection."""
@property
@ -90,21 +91,21 @@ class IncomeStatementSubsection:
return sum([x.amount for x in self.accounts])
class IncomeStatementSection:
"""A section in the income statement."""
class Section:
"""A section."""
def __init__(self, title: BaseAccount, accumulated_title: str):
"""Constructs a section in the income statement.
"""Constructs a section.
:param title: The title account.
:param accumulated_title: The title for the accumulated total.
"""
self.title: BaseAccount = title
"""The title account."""
self.subsections: list[IncomeStatementSubsection] = []
self.subsections: list[Subsection] = []
"""The subsections in the section."""
self.accumulated: IncomeStatementAccumulatedTotal \
= IncomeStatementAccumulatedTotal(accumulated_title)
self.accumulated: AccumulatedTotal \
= AccumulatedTotal(accumulated_title)
@property
def total(self) -> Decimal:
@ -116,10 +117,10 @@ class IncomeStatementSection:
class CSVRow(BaseCSVRow):
"""A row in the CSV income statement."""
"""A row in the CSV."""
def __init__(self, text: str | None, amount: str | Decimal | None):
"""Constructs a row in the CSV income statement.
"""Constructs a row in the CSV.
:param text: The text.
:param amount: The amount.
@ -138,14 +139,14 @@ class CSVRow(BaseCSVRow):
return [self.text, self.amount]
class IncomeStatementPageParams(PageParams):
"""The HTML parameters of the income statement."""
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, currency: Currency,
period: Period,
has_data: bool,
sections: list[IncomeStatementSection],):
"""Constructs the HTML parameters of the income statement.
sections: list[Section], ):
"""Constructs the HTML page parameters.
:param currency: The currency.
:param period: The period.
@ -157,9 +158,10 @@ class IncomeStatementPageParams(PageParams):
"""The period."""
self.__has_data: bool = has_data
"""True if there is any data, or False otherwise."""
self.sections: list[IncomeStatementSection] = sections
self.period_chooser: IncomeStatementPeriodChooser \
= IncomeStatementPeriodChooser(currency)
self.sections: list[Section] = sections
"""The sections in the income statement."""
self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: income_statement_url(currency, x))
"""The period chooser."""
@property
@ -186,19 +188,8 @@ class IncomeStatementPageParams(PageParams):
:return: The currency options.
"""
def get_url(currency: Currency):
if self.period.is_default:
return url_for("accounting.report.income-statement-default",
currency=currency)
return url_for("accounting.report.income-statement",
currency=currency, period=self.period)
in_use: set[str] = set(db.session.scalars(
sa.select(JournalEntry.currency_code)
.group_by(JournalEntry.currency_code)).all())
return [OptionLink(str(x), get_url(x), x.code == self.currency.code)
for x in Currency.query.filter(Currency.code.in_(in_use))
.order_by(Currency.code).all()]
return self._get_currency_options(
lambda x: income_statement_url(x, self.period), self.currency)
class IncomeStatement(BaseReport):
@ -216,7 +207,7 @@ class IncomeStatement(BaseReport):
"""The period."""
self.__has_data: bool
"""True if there is any data, or False otherwise."""
self.__sections: list[IncomeStatementSection]
self.__sections: list[Section]
"""The sections."""
self.__set_data()
@ -225,7 +216,7 @@ class IncomeStatement(BaseReport):
:return: None.
"""
balances: list[IncomeStatementAccount] = self.__query_balances()
balances: list[ReportAccount] = self.__query_balances()
titles: list[BaseAccount] = BaseAccount.query\
.filter(BaseAccount.code.in_({"4", "5", "6", "7", "8", "9"})).all()
@ -234,18 +225,17 @@ class IncomeStatement(BaseReport):
for x in balances})).all()
total_titles: dict[str, str] \
= {"4": gettext("total revenue"),
= {"4": gettext("total operating revenue"),
"5": gettext("gross income"),
"6": gettext("operating income"),
"7": gettext("before tax income"),
"8": gettext("after tax income"),
"9": gettext("net income or loss for current period")}
sections: dict[str, IncomeStatementSection] \
= {x.code: IncomeStatementSection(x, total_titles[x.code])
for x in titles}
subsections: dict[str, IncomeStatementSubsection] \
= {x.code: IncomeStatementSubsection(x) for x in subtitles}
sections: dict[str, Section] \
= {x.code: Section(x, total_titles[x.code]) for x in titles}
subsections: dict[str, Subsection] \
= {x.code: Subsection(x) for x in subtitles}
for subsection in subsections.values():
sections[subsection.title.code[0]].subsections.append(subsection)
for balance in balances:
@ -258,7 +248,7 @@ class IncomeStatement(BaseReport):
total = total + section.total
section.accumulated.amount = total
def __query_balances(self) -> list[IncomeStatementAccount]:
def __query_balances(self) -> list[ReportAccount]:
"""Queries and returns the balances.
:return: The balances.
@ -275,33 +265,20 @@ class IncomeStatement(BaseReport):
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, -JournalEntry.amount),
else_=JournalEntry.amount)).label("balance")
select_balance: sa.Select \
= sa.select(JournalEntry.account_id, balance_func)\
select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(Transaction).join(Account)\
.filter(*conditions)\
.group_by(JournalEntry.account_id)\
.group_by(Account.id)\
.order_by(Account.base_code, Account.no)
balances: list[sa.Row] = db.session.execute(select_balance).all()
balances: list[sa.Row] = db.session.execute(select_balances).all()
accounts: dict[int, Account] \
= {x.id: x for x in Account.query
.filter(Account.id.in_([x.account_id for x in balances])).all()}
def get_url(account: Account) -> str:
"""Returns the ledger URL of an account.
:param account: The account.
:return: The ledger URL of the account.
"""
if self.__period.is_default:
return url_for("accounting.report.ledger-default",
currency=self.__currency, account=account)
return url_for("accounting.report.ledger",
currency=self.__currency, account=account,
period=self.__period)
return [IncomeStatementAccount(account=accounts[x.account_id],
.filter(Account.id.in_([x.id for x in balances])).all()}
return [ReportAccount(account=accounts[x.id],
amount=x.balance,
url=get_url(accounts[x.account_id]))
url=ledger_url(self.__currency,
accounts[x.id],
self.__period))
for x in balances]
def csv(self) -> Response:
@ -310,7 +287,8 @@ class IncomeStatement(BaseReport):
:return: The response of the report for download.
"""
filename: str = "income-statement-{currency}-{period}.csv"\
.format(currency=self.__currency.code, period=self.__period.spec)
.format(currency=self.__currency.code,
period=period_spec(self.__period))
return csv_download(filename, self.__get_csv_rows())
def __get_csv_rows(self) -> list[CSVRow]:
@ -339,8 +317,7 @@ class IncomeStatement(BaseReport):
:return: The report as HTML.
"""
params: IncomeStatementPageParams = IncomeStatementPageParams(
currency=self.__currency,
params: PageParams = PageParams(currency=self.__currency,
period=self.__period,
has_data=self.__has_data,
sections=self.__sections)

View File

@ -22,56 +22,49 @@ from decimal import Decimal
import sqlalchemy as sa
from flask import render_template, Response
from sqlalchemy.orm import selectinload
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry
from accounting.report.period import Period
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import journal_url
from accounting.utils.pagination import Pagination
from .utils.base_report import BaseReport
from .utils.csv_export import BaseCSVRow, csv_download
from .utils.page_params import PageParams
from .utils.period_choosers import JournalPeriodChooser
from .utils.report_chooser import ReportChooser
from .utils.report_type import ReportType
class Entry:
"""An entry in the journal."""
class ReportEntry:
"""An entry in the report."""
def __init__(self, entry: JournalEntry | None = None):
"""Constructs the entry in the journal.
def __init__(self, entry: JournalEntry):
"""Constructs the entry in the report.
:param entry: The journal entry.
"""
self.entry: JournalEntry | None = None
self.entry: JournalEntry = entry
"""The journal entry."""
self.transaction: Transaction | None = None
self.transaction: Transaction = entry.transaction
"""The transaction."""
self.is_total: bool = False
"""Whether this is the total entry."""
self.currency: Currency | None = None
self.currency: Currency = entry.currency
"""The account."""
self.account: Account | None = None
self.account: Account = entry.account
"""The account."""
self.summary: str | None = None
self.summary: str | None = entry.summary
"""The summary."""
self.debit: Decimal | None = None
self.debit: Decimal | None = entry.debit
"""The debit amount."""
self.credit: Decimal | None = None
self.credit: Decimal | None = entry.credit
"""The credit amount."""
self.amount: Decimal | None = None
self.amount: Decimal = entry.amount
"""The amount."""
if entry is not None:
self.entry = entry
self.summary = entry.summary
self.debit = entry.amount if entry.is_debit else None
self.credit = None if entry.is_debit else entry.amount
self.amount = entry.amount
class CSVRow(BaseCSVRow):
"""A row in the CSV journal."""
"""A row in the CSV."""
def __init__(self, txn_date: str | date,
currency: str,
@ -80,7 +73,7 @@ class CSVRow(BaseCSVRow):
debit: str | Decimal | None,
credit: str | Decimal | None,
note: str | None):
"""Constructs a row in the CSV journal.
"""Constructs a row in the CSV.
:param txn_date: The transaction date.
:param summary: The summary.
@ -113,25 +106,25 @@ class CSVRow(BaseCSVRow):
self.debit, self.credit, self.note]
class JournalPageParams(PageParams):
"""The HTML parameters of the journal."""
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, period: Period,
pagination: Pagination[Entry],
entries: list[Entry]):
"""Constructs the HTML parameters of the journal.
pagination: Pagination[JournalEntry],
entries: list[JournalEntry]):
"""Constructs the HTML page parameters.
:param period: The period.
:param entries: The journal entries.
"""
self.period: Period = period
"""The period."""
self.pagination: Pagination[Entry] = pagination
self.pagination: Pagination[JournalEntry] = pagination
"""The pagination."""
self.entries: list[Entry] = entries
self.entries: list[JournalEntry] = entries
"""The entries."""
self.period_chooser: JournalPeriodChooser \
= JournalPeriodChooser()
self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: journal_url(x))
"""The period chooser."""
@property
@ -152,25 +145,21 @@ class JournalPageParams(PageParams):
period=self.period)
def _populate_entries(entries: list[Entry]) -> None:
"""Populates the journal entries with relative data.
def get_csv_rows(entries: list[JournalEntry]) -> list[CSVRow]:
"""Composes and returns the CSV rows from the report entries.
:param entries: The journal entries.
:return: None.
:param entries: The report entries.
:return: The CSV rows.
"""
transactions: dict[int, Transaction] \
= {x.id: x for x in Transaction.query.filter(
Transaction.id.in_({x.entry.transaction_id for x in entries}))}
accounts: dict[int, Account] \
= {x.id: x for x in Account.query.filter(
Account.id.in_({x.entry.account_id for x in entries}))}
currencies: dict[int, Currency] \
= {x.code: x for x in Currency.query.filter(
Currency.code.in_({x.entry.currency_code for x in entries}))}
for entry in entries:
entry.transaction = transactions[entry.entry.transaction_id]
entry.account = accounts[entry.entry.account_id]
entry.currency = currencies[entry.entry.currency_code]
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
gettext("Account"), gettext("Summary"),
gettext("Debit"), gettext("Credit"),
gettext("Note"))]
rows.extend([CSVRow(x.transaction.date, x.currency.code,
str(x.account).title(), x.summary,
x.debit, x.credit, x.transaction.note)
for x in entries])
return rows
class Journal(BaseReport):
@ -181,13 +170,12 @@ class Journal(BaseReport):
:param period: The period.
"""
"""The account."""
self.__period: Period = period
"""The period."""
self.__entries: list[Entry] = self.__query_entries()
self.__entries: list[JournalEntry] = self.__query_entries()
"""The journal entries."""
def __query_entries(self) -> list[Entry]:
def __query_entries(self) -> list[JournalEntry]:
"""Queries and returns the journal entries.
:return: The journal entries.
@ -197,47 +185,33 @@ class Journal(BaseReport):
conditions.append(Transaction.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end)
return [Entry(x) for x in db.session
.query(JournalEntry).join(Transaction).filter(*conditions)
return JournalEntry.query.join(Transaction)\
.filter(*conditions)\
.order_by(Transaction.date,
Transaction.no,
JournalEntry.is_debit.desc(),
JournalEntry.no).all()]
JournalEntry.no)\
.options(selectinload(JournalEntry.account),
selectinload(JournalEntry.currency),
selectinload(JournalEntry.transaction)).all()
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = f"journal-{self.__period.spec}.csv"
return csv_download(filename, self.__get_csv_rows())
def __get_csv_rows(self) -> list[CSVRow]:
"""Composes and returns the CSV rows.
:return: The CSV rows.
"""
_populate_entries(self.__entries)
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
gettext("Account"), gettext("Summary"),
gettext("Debit"), gettext("Credit"),
gettext("Note"))]
rows.extend([CSVRow(x.transaction.date, x.currency.code,
str(x.account).title(), x.summary,
x.debit, x.credit, x.transaction.note)
for x in self.__entries])
return rows
filename: str = f"journal-{period_spec(self.__period)}.csv"
return csv_download(filename, get_csv_rows(self.__entries))
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
pagination: Pagination[Entry] = Pagination[Entry](self.__entries)
page_entries: list[Entry] = pagination.list
_populate_entries(page_entries)
params: JournalPageParams = JournalPageParams(
period=self.__period,
pagination: Pagination[JournalEntry] \
= Pagination[JournalEntry](self.__entries, is_reversed=True)
params: PageParams = PageParams(period=self.__period,
pagination=pagination,
entries=page_entries)
entries=pagination.list)
return render_template("accounting/report/journal.html",
report=params)

View File

@ -22,41 +22,38 @@ from decimal import Decimal
import sqlalchemy as sa
from flask import url_for, render_template, Response
from sqlalchemy.orm import selectinload
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry
from accounting.report.period import Period
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url
from accounting.utils.cast import be
from accounting.utils.pagination import Pagination
from .utils.base_report import BaseReport
from .utils.csv_export import BaseCSVRow, csv_download
from .utils.option_link import OptionLink
from .utils.page_params import PageParams
from .utils.period_choosers import LedgerPeriodChooser
from .utils.report_chooser import ReportChooser
from .utils.report_type import ReportType
class Entry:
"""An entry in the ledger."""
class ReportEntry:
"""An entry in the report."""
def __init__(self, entry: JournalEntry | None = None):
"""Constructs the entry in the ledger.
"""Constructs the entry in the report.
:param entry: The journal entry.
"""
self.entry: JournalEntry | None = None
"""The journal entry."""
self.transaction: Transaction | None = None
"""The transaction."""
self.is_brought_forward: bool = False
"""Whether this is the brought-forward entry."""
self.is_total: bool = False
"""Whether this is the total entry."""
self.date: date | None = None
"""The date."""
self.account: Account | None = None
"""The account."""
self.summary: str | None = None
"""The summary."""
self.debit: Decimal | None = None
@ -67,18 +64,23 @@ class Entry:
"""The balance."""
self.note: str | None = None
"""The note."""
self.url: str | None = None
"""The URL to the journal entry."""
if entry is not None:
self.entry = entry
self.date = entry.transaction.date
self.summary = entry.summary
self.debit = entry.amount if entry.is_debit else None
self.credit = None if entry.is_debit else entry.amount
self.note = entry.transaction.note
self.url = url_for("accounting.transaction.detail",
txn=entry.transaction)
class EntryCollector:
"""The ledger entry collector."""
"""The report entry collector."""
def __init__(self, currency: Currency, account: Account, period: Period):
"""Constructs the ledger entry collector.
"""Constructs the report entry collector.
:param currency: The currency.
:param account: The account.
@ -90,36 +92,38 @@ class EntryCollector:
"""The account."""
self.__period: Period = period
"""The period"""
self.brought_forward: Entry | None
self.brought_forward: ReportEntry | None
"""The brought-forward entry."""
self.entries: list[Entry]
"""The ledger entries."""
self.total: Entry | None
self.entries: list[ReportEntry]
"""The report entries."""
self.total: ReportEntry | None
"""The total entry."""
self.brought_forward = self.__get_brought_forward_entry()
self.entries = self.__query_entries()
self.total = self.__get_total_entry()
self.__populate_balance()
def __get_brought_forward_entry(self) -> Entry | None:
def __get_brought_forward_entry(self) -> ReportEntry | None:
"""Queries, composes and returns the brought-forward entry.
:return: The brought-forward entry, or None if the ledger starts from
:return: The brought-forward entry, or None if the report starts from
the beginning.
"""
if self.__period.start is None:
return None
if self.__account.is_nominal:
return None
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount))
select: sa.Select = sa.Select(balance_func).join(Transaction)\
.filter(JournalEntry.currency_code == self.__currency.code,
JournalEntry.account_id == self.__account.id,
.filter(be(JournalEntry.currency_code == self.__currency.code),
be(JournalEntry.account_id == self.__account.id),
Transaction.date < self.__period.start)
balance: int | None = db.session.scalar(select)
if balance is None:
return None
entry: Entry = Entry()
entry: ReportEntry = ReportEntry()
entry.is_brought_forward = True
entry.date = self.__period.start
entry.summary = gettext("Brought forward")
@ -130,10 +134,10 @@ class EntryCollector:
entry.balance = balance
return entry
def __query_entries(self) -> list[Entry]:
"""Queries and returns the ledger entries.
def __query_entries(self) -> list[ReportEntry]:
"""Queries and returns the report entries.
:return: The ledger entries.
:return: The report entries.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code,
@ -142,20 +146,22 @@ class EntryCollector:
conditions.append(Transaction.date >= self.__period.start)
if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end)
return [Entry(x) for x in JournalEntry.query.join(Transaction)
return [ReportEntry(x) for x in JournalEntry.query.join(Transaction)
.filter(*conditions)
.order_by(Transaction.date,
Transaction.no,
JournalEntry.is_debit.desc(),
JournalEntry.no).all()]
JournalEntry.no)
.options(selectinload(JournalEntry.transaction)).all()]
def __get_total_entry(self) -> Entry | None:
def __get_total_entry(self) -> ReportEntry | None:
"""Composes the total entry.
:return: The total entry, or None if there is no data.
"""
if self.brought_forward is None and len(self.entries) == 0:
return None
entry: Entry = Entry()
entry: ReportEntry = ReportEntry()
entry.is_total = True
entry.summary = gettext("Total")
entry.debit = sum([x.debit for x in self.entries
@ -172,6 +178,8 @@ class EntryCollector:
:return: None.
"""
if self.__account.is_nominal:
return None
balance: Decimal = 0 if self.brought_forward is None \
else self.brought_forward.balance
for entry in self.entries:
@ -183,7 +191,7 @@ class EntryCollector:
class CSVRow(BaseCSVRow):
"""A row in the CSV ledger."""
"""A row in the CSV."""
def __init__(self, txn_date: date | str | None,
summary: str | None,
@ -191,7 +199,7 @@ class CSVRow(BaseCSVRow):
credit: str | Decimal | None,
balance: str | Decimal | None,
note: str | None):
"""Constructs a row in the CSV ledger.
"""Constructs a row in the CSV.
:param txn_date: The transaction date.
:param summary: The summary.
@ -223,25 +231,25 @@ class CSVRow(BaseCSVRow):
self.debit, self.credit, self.balance, self.note]
class LedgerPageParams(PageParams):
"""The HTML parameters of the ledger."""
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, currency: Currency,
account: Account,
period: Period,
has_data: bool,
pagination: Pagination[Entry],
brought_forward: Entry | None,
entries: list[Entry],
total: Entry | None):
"""Constructs the HTML parameters of the ledger.
pagination: Pagination[ReportEntry],
brought_forward: ReportEntry | None,
entries: list[ReportEntry],
total: ReportEntry | None):
"""Constructs the HTML page parameters.
:param currency: The currency.
:param account: The account.
:param period: The period.
:param has_data: True if there is any data, or False otherwise.
:param brought_forward: The brought-forward entry.
:param entries: The ledger entries.
:param entries: The report entries.
:param total: The total entry.
"""
self.currency: Currency = currency
@ -252,16 +260,16 @@ class LedgerPageParams(PageParams):
"""The period."""
self.__has_data: bool = has_data
"""True if there is any data, or False otherwise."""
self.pagination: Pagination[Entry] = pagination
self.pagination: Pagination[ReportEntry] = pagination
"""The pagination."""
self.brought_forward: Entry | None = brought_forward
self.brought_forward: ReportEntry | None = brought_forward
"""The brought-forward entry."""
self.entries: list[Entry] = entries
self.entries: list[ReportEntry] = entries
"""The entries."""
self.total: Entry | None = total
self.total: ReportEntry | None = total
"""The total entry."""
self.period_chooser: LedgerPeriodChooser \
= LedgerPeriodChooser(currency, account)
self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: ledger_url(currency, account, x))
"""The period chooser."""
@property
@ -289,20 +297,8 @@ class LedgerPageParams(PageParams):
:return: The currency options.
"""
def get_url(currency: Currency):
if self.period.is_default:
return url_for("accounting.report.ledger-default",
currency=currency, account=self.account)
return url_for("accounting.report.ledger",
currency=currency, account=self.account,
period=self.period)
in_use: set[str] = set(db.session.scalars(
sa.select(JournalEntry.currency_code)
.group_by(JournalEntry.currency_code)).all())
return [OptionLink(str(x), get_url(x), x.code == self.currency.code)
for x in Currency.query.filter(Currency.code.in_(in_use))
.order_by(Currency.code).all()]
return self._get_currency_options(
lambda x: ledger_url(x, self.account, self.period), self.currency)
@property
def account_options(self) -> list[OptionLink]:
@ -310,39 +306,15 @@ class LedgerPageParams(PageParams):
:return: The account options.
"""
def get_url(account: Account):
if self.period.is_default:
return url_for("accounting.report.ledger-default",
currency=self.currency, account=account)
return url_for("accounting.report.ledger",
currency=self.currency, account=account,
period=self.period)
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
.filter(JournalEntry.currency_code == self.currency.code)\
.filter(be(JournalEntry.currency_code == self.currency.code))\
.group_by(JournalEntry.account_id)
return [OptionLink(str(x), get_url(x), x.id == self.account.id)
return [OptionLink(str(x), ledger_url(self.currency, x, self.period),
x.id == self.account.id)
for x in Account.query.filter(Account.id.in_(in_use))
.order_by(Account.base_code, Account.no).all()]
def _populate_entries(entries: list[Entry]) -> None:
"""Populates the ledger entries with relative data.
:param entries: The ledger entries.
:return: None.
"""
transactions: dict[int, Transaction] \
= {x.id: x for x in Transaction.query.filter(
Transaction.id.in_({x.entry.transaction_id for x in entries
if x.entry is not None}))}
for entry in entries:
if entry.entry is not None:
entry.transaction = transactions[entry.entry.transaction_id]
entry.date = entry.transaction.date
entry.note = entry.transaction.note
class Ledger(BaseReport):
"""The ledger."""
@ -361,11 +333,11 @@ class Ledger(BaseReport):
"""The period."""
collector: EntryCollector = EntryCollector(
self.__currency, self.__account, self.__period)
self.__brought_forward: Entry | None = collector.brought_forward
self.__brought_forward: ReportEntry | None = collector.brought_forward
"""The brought-forward entry."""
self.__entries: list[Entry] = collector.entries
"""The ledger entries."""
self.__total: Entry | None = collector.total
self.__entries: list[ReportEntry] = collector.entries
"""The report entries."""
self.__total: ReportEntry | None = collector.total
"""The total entry."""
def csv(self) -> Response:
@ -375,7 +347,7 @@ class Ledger(BaseReport):
"""
filename: str = "ledger-{currency}-{account}-{period}.csv"\
.format(currency=self.__currency.code, account=self.__account.code,
period=self.__period.spec)
period=period_spec(self.__period))
return csv_download(filename, self.__get_csv_rows())
def __get_csv_rows(self) -> list[CSVRow]:
@ -383,7 +355,6 @@ class Ledger(BaseReport):
:return: The CSV rows.
"""
_populate_entries(self.__entries)
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Summary"),
gettext("Debit"), gettext("Credit"),
gettext("Balance"), gettext("Note"))]
@ -408,26 +379,25 @@ class Ledger(BaseReport):
:return: The report as HTML.
"""
all_entries: list[Entry] = []
all_entries: list[ReportEntry] = []
if self.__brought_forward is not None:
all_entries.append(self.__brought_forward)
all_entries.extend(self.__entries)
if self.__total is not None:
all_entries.append(self.__total)
pagination: Pagination[Entry] = Pagination[Entry](all_entries)
page_entries: list[Entry] = pagination.list
pagination: Pagination[ReportEntry] \
= Pagination[ReportEntry](all_entries, is_reversed=True)
page_entries: list[ReportEntry] = pagination.list
has_data: bool = len(page_entries) > 0
_populate_entries(page_entries)
brought_forward: Entry | None = None
brought_forward: ReportEntry | None = None
if len(page_entries) > 0 and page_entries[0].is_brought_forward:
brought_forward = page_entries[0]
page_entries = page_entries[1:]
total: Entry | None = None
total: ReportEntry | None = None
if len(page_entries) > 0 and page_entries[-1].is_total:
total = page_entries[-1]
page_entries = page_entries[:-1]
params: LedgerPageParams = LedgerPageParams(
currency=self.__currency,
params: PageParams = PageParams(currency=self.__currency,
account=self.__account,
period=self.__period,
has_data=has_data,

View File

@ -17,163 +17,36 @@
"""The search.
"""
from datetime import date, datetime
from datetime import datetime
from decimal import Decimal
import sqlalchemy as sa
from flask import Response, render_template, request
from sqlalchemy.orm import selectinload
from accounting.locale import gettext
from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
Transaction, JournalEntry
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import csv_download
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.utils.cast import be
from accounting.utils.pagination import Pagination
from accounting.utils.query import parse_query_keywords
from .utils.base_report import BaseReport
from .utils.csv_export import BaseCSVRow, csv_download
from .utils.page_params import PageParams
from .utils.report_chooser import ReportChooser
from .utils.report_type import ReportType
from .journal import get_csv_rows
class Entry:
"""An entry in the search result."""
def __init__(self, entry: JournalEntry | None = None):
"""Constructs the entry in the search result.
:param entry: The journal entry.
"""
self.entry: JournalEntry | None = None
"""The journal entry."""
self.transaction: Transaction | None = None
"""The transaction."""
self.is_total: bool = False
"""Whether this is the total entry."""
self.currency: Currency | None = None
"""The account."""
self.account: Account | None = None
"""The account."""
self.summary: str | None = None
"""The summary."""
self.debit: Decimal | None = None
"""The debit amount."""
self.credit: Decimal | None = None
"""The credit amount."""
self.amount: Decimal | None = None
"""The amount."""
if entry is not None:
self.entry = entry
self.summary = entry.summary
self.debit = entry.amount if entry.is_debit else None
self.credit = None if entry.is_debit else entry.amount
self.amount = entry.amount
class CSVRow(BaseCSVRow):
"""A row in the CSV search result."""
def __init__(self, txn_date: str | date,
currency: str,
account: str,
summary: str | None,
debit: str | Decimal | None,
credit: str | Decimal | None,
note: str | None):
"""Constructs a row in the CSV search result.
:param txn_date: The transaction date.
:param summary: The summary.
:param debit: The debit amount.
:param credit: The credit amount.
:param note: The note.
"""
self.date: str | date = txn_date
"""The date."""
self.currency: str = currency
"""The currency."""
self.account: str = account
"""The account."""
self.summary: str | None = summary
"""The summary."""
self.debit: str | Decimal | None = debit
"""The debit amount."""
self.credit: str | Decimal | None = credit
"""The credit amount."""
self.note: str | None = note
"""The note."""
@property
def values(self) -> list[str | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.date, self.currency, self.account, self.summary,
self.debit, self.credit, self.note]
class SearchPageParams(PageParams):
"""The HTML parameters of the search result."""
def __init__(self, pagination: Pagination[Entry],
entries: list[Entry]):
"""Constructs the HTML parameters of the search result.
:param entries: The search result entries.
"""
self.pagination: Pagination[Entry] = pagination
"""The pagination."""
self.entries: list[Entry] = entries
"""The entries."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return len(self.entries) > 0
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.SEARCH)
def _populate_entries(entries: list[Entry]) -> None:
"""Populates the search result entries with relative data.
:param entries: The search result entries.
:return: None.
"""
transactions: dict[int, Transaction] \
= {x.id: x for x in Transaction.query.filter(
Transaction.id.in_({x.entry.transaction_id for x in entries}))}
accounts: dict[int, Account] \
= {x.id: x for x in Account.query.filter(
Account.id.in_({x.entry.account_id for x in entries}))}
currencies: dict[int, Currency] \
= {x.code: x for x in Currency.query.filter(
Currency.code.in_({x.entry.currency_code for x in entries}))}
for entry in entries:
entry.transaction = transactions[entry.entry.transaction_id]
entry.account = accounts[entry.entry.account_id]
entry.currency = currencies[entry.entry.currency_code]
class Search(BaseReport):
"""The search."""
class EntryCollector:
"""The report entry collector."""
def __init__(self):
"""Constructs a search."""
"""The account."""
self.__entries: list[Entry] = self.__query_entries()
"""The journal entries."""
"""Constructs the report entry collector."""
self.entries: list[JournalEntry] = self.__query_entries()
"""The report entries."""
def __query_entries(self) -> list[Entry]:
def __query_entries(self) -> list[JournalEntry]:
"""Queries and returns the journal entries.
:return: The journal entries.
@ -183,15 +56,27 @@ class Search(BaseReport):
return []
conditions: list[sa.BinaryExpression] = []
for k in keywords:
conditions.append(sa.or_(
JournalEntry.summary.contains(k),
sa.cast(JournalEntry.amount, sa.String).contains(k),
JournalEntry.account_id.in_(self.__get_account_condition(k)),
sub_conditions: list[sa.BinaryExpression] \
= [JournalEntry.summary.contains(k),
JournalEntry.account_id.in_(
self.__get_account_condition(k)),
JournalEntry.currency_code.in_(
self.__get_currency_condition(k)),
JournalEntry.transaction_id.in_(
self.__get_transaction_condition(k))))
return [Entry(x) for x in JournalEntry.query.filter(*conditions)]
self.__get_transaction_condition(k))]
try:
sub_conditions.append(JournalEntry.amount == Decimal(k))
except ArithmeticError:
pass
conditions.append(sa.or_(*sub_conditions))
return JournalEntry.query.join(Transaction).filter(*conditions)\
.order_by(Transaction.date,
Transaction.no,
JournalEntry.is_debit,
JournalEntry.no)\
.options(selectinload(JournalEntry.account),
selectinload(JournalEntry.currency),
selectinload(JournalEntry.transaction)).all()
@staticmethod
def __get_account_condition(k: str) -> sa.Select:
@ -211,8 +96,8 @@ class Search(BaseReport):
Account.title_l10n.contains(k),
code.contains(k),
Account.id.in_(select_l10n)]
if k in gettext("Pay-off needed"):
conditions.append(Account.is_pay_off_needed)
if k in gettext("Need offset"):
conditions.append(Account.is_need_offset)
return sa.select(Account.id).filter(sa.or_(*conditions))
@staticmethod
@ -236,13 +121,12 @@ class Search(BaseReport):
:param k: The keyword.
:return: The condition to filter the transaction.
"""
conditions: list[sa.BinaryExpression] \
= [Transaction.note.contains(k)]
conditions: list[sa.BinaryExpression] = [Transaction.note.contains(k)]
txn_date: datetime
try:
txn_date = datetime.strptime(k, "%Y")
conditions.append(
sa.extract("year", Transaction.date) == txn_date.year)
be(sa.extract("year", Transaction.date) == txn_date.year))
except ValueError:
pass
try:
@ -261,39 +145,62 @@ class Search(BaseReport):
pass
return sa.select(Transaction.id).filter(sa.or_(*conditions))
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, pagination: Pagination[JournalEntry],
entries: list[JournalEntry]):
"""Constructs the HTML page parameters.
:param entries: The search result entries.
"""
self.pagination: Pagination[JournalEntry] = pagination
"""The pagination."""
self.entries: list[JournalEntry] = entries
"""The entries."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return len(self.entries) > 0
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.SEARCH)
class Search(BaseReport):
"""The search."""
def __init__(self):
"""Constructs a search."""
self.__entries: list[JournalEntry] = EntryCollector().entries
"""The journal entries."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = "search-{q}.csv".format(q=request.args["q"])
return csv_download(filename, self.__get_csv_rows())
def __get_csv_rows(self) -> list[CSVRow]:
"""Composes and returns the CSV rows.
:return: The CSV rows.
"""
_populate_entries(self.__entries)
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
gettext("Account"), gettext("Summary"),
gettext("Debit"), gettext("Credit"),
gettext("Note"))]
rows.extend([CSVRow(x.transaction.date, x.currency.code,
str(x.account).title(), x.summary,
x.debit, x.credit, x.transaction.note)
for x in self.__entries])
return rows
return csv_download(filename, get_csv_rows(self.__entries))
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
pagination: Pagination[Entry] = Pagination[Entry](self.__entries)
page_entries: list[Entry] = pagination.list
_populate_entries(page_entries)
params: SearchPageParams = SearchPageParams(pagination=pagination,
entries=page_entries)
pagination: Pagination[JournalEntry] \
= Pagination[JournalEntry](self.__entries, is_reversed=True)
params: PageParams = PageParams(pagination=pagination,
entries=pagination.list)
return render_template("accounting/report/search.html",
report=params)

View File

@ -20,26 +20,27 @@
from decimal import Decimal
import sqlalchemy as sa
from flask import url_for, Response, render_template
from flask import Response, render_template
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry
from accounting.report.period import Period
from .utils.base_report import BaseReport
from .utils.csv_export import BaseCSVRow, csv_download
from .utils.option_link import OptionLink
from .utils.page_params import PageParams
from .utils.period_choosers import TrialBalancePeriodChooser
from .utils.report_chooser import ReportChooser
from .utils.report_type import ReportType
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url, trial_balance_url
class TrialBalanceAccount:
"""An account in the trial balance."""
class ReportAccount:
"""An account in the report."""
def __init__(self, account: Account, amount: Decimal, url: str):
"""Constructs an account in the trial balance.
"""Constructs an account in the report.
:param account: The account.
:param amount: The amount.
@ -55,8 +56,8 @@ class TrialBalanceAccount:
"""The URL to the ledger of the account."""
class TrialBalanceTotal:
"""The total in the trial balance."""
class Total:
"""The totals."""
def __init__(self, debit: Decimal, credit: Decimal):
"""Constructs the total in the trial balance.
@ -71,12 +72,12 @@ class TrialBalanceTotal:
class CSVRow(BaseCSVRow):
"""A row in the CSV trial balance."""
"""A row in the CSV."""
def __init__(self, text: str | None,
debit: str | Decimal | None,
credit: str | Decimal | None):
"""Constructs a row in the CSV trial balance.
"""Constructs a row in the CSV.
:param text: The text.
:param debit: The debit amount.
@ -98,14 +99,14 @@ class CSVRow(BaseCSVRow):
return [self.text, self.debit, self.credit]
class TrialBalancePageParams(PageParams):
"""The HTML parameters of the trial balance."""
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, currency: Currency,
period: Period,
accounts: list[TrialBalanceAccount],
total: TrialBalanceTotal):
"""Constructs the HTML parameters of the trial balance.
accounts: list[ReportAccount],
total: Total):
"""Constructs the HTML page parameters.
:param currency: The currency.
:param period: The period.
@ -116,12 +117,12 @@ class TrialBalancePageParams(PageParams):
"""The currency."""
self.period: Period = period
"""The period."""
self.accounts: list[TrialBalanceAccount] = accounts
self.accounts: list[ReportAccount] = accounts
"""The accounts in the trial balance."""
self.total: TrialBalanceTotal = total
self.total: Total = total
"""The total of the trial balance."""
self.period_chooser: TrialBalancePeriodChooser \
= TrialBalancePeriodChooser(currency)
self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: trial_balance_url(currency, x))
"""The period chooser."""
@property
@ -148,19 +149,8 @@ class TrialBalancePageParams(PageParams):
:return: The currency options.
"""
def get_url(currency: Currency):
if self.period.is_default:
return url_for("accounting.report.trial-balance-default",
currency=currency)
return url_for("accounting.report.trial-balance",
currency=currency, period=self.period)
in_use: set[str] = set(db.session.scalars(
sa.select(JournalEntry.currency_code)
.group_by(JournalEntry.currency_code)).all())
return [OptionLink(str(x), get_url(x), x.code == self.currency.code)
for x in Currency.query.filter(Currency.code.in_(in_use))
.order_by(Currency.code).all()]
return self._get_currency_options(
lambda x: trial_balance_url(x, self.period), self.currency)
class TrialBalance(BaseReport):
@ -176,9 +166,9 @@ class TrialBalance(BaseReport):
"""The currency."""
self.__period: Period = period
"""The period."""
self.__accounts: list[TrialBalanceAccount]
self.__accounts: list[ReportAccount]
"""The accounts in the trial balance."""
self.__total: TrialBalanceTotal
self.__total: Total
"""The total of the trial balance."""
self.__set_data()
@ -196,35 +186,22 @@ class TrialBalance(BaseReport):
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount)).label("balance")
select_balances: sa.Select \
= sa.select(Account.id, balance_func)\
select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(Transaction).join(Account)\
.filter(*conditions)\
.group_by(JournalEntry.account_id)\
.group_by(Account.id)\
.order_by(Account.base_code, Account.no)
balances: list[sa.Row] = db.session.execute(select_balances).all()
accounts: dict[int, Account] \
= {x.id: x for x in Account.query
.filter(Account.id.in_([x.id for x in balances])).all()}
def get_url(account: Account) -> str:
"""Returns the ledger URL of an account.
:param account: The account.
:return: The ledger URL of the account.
"""
if self.__period.is_default:
return url_for("accounting.report.ledger-default",
currency=self.__currency, account=account)
return url_for("accounting.report.ledger",
currency=self.__currency, account=account,
period=self.__period)
self.__accounts = [TrialBalanceAccount(account=accounts[x.id],
self.__accounts = [ReportAccount(account=accounts[x.id],
amount=x.balance,
url=get_url(accounts[x.id]))
url=ledger_url(self.__currency,
accounts[x.id],
self.__period))
for x in balances]
self.__total = TrialBalanceTotal(
self.__total = Total(
sum([x.debit for x in self.__accounts if x.debit is not None]),
sum([x.credit for x in self.__accounts if x.credit is not None]))
@ -234,7 +211,8 @@ class TrialBalance(BaseReport):
:return: The response of the report for download.
"""
filename: str = "trial-balance-{currency}-{period}.csv"\
.format(currency=self.__currency.code, period=self.__period.spec)
.format(currency=self.__currency.code,
period=period_spec(self.__period))
return csv_download(filename, self.__get_csv_rows())
def __get_csv_rows(self) -> list[CSVRow]:
@ -255,8 +233,7 @@ class TrialBalance(BaseReport):
:return: The report as HTML.
"""
params: TrialBalancePageParams = TrialBalancePageParams(
currency=self.__currency,
params: PageParams = PageParams(currency=self.__currency,
period=self.__period,
accounts=self.__accounts,
total=self.__total)

View File

@ -1,54 +0,0 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The utility to export the report as CSV for download.
"""
import csv
from abc import ABC, abstractmethod
from decimal import Decimal
from io import StringIO
from flask import Response
class BaseCSVRow(ABC):
"""The base CSV row."""
@property
@abstractmethod
def values(self) -> list[str | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
def csv_download(filename: str, rows: list[BaseCSVRow]) -> Response:
"""Exports the data rows as a CSV file for download.
:param filename: The download file name.
:param rows: The data rows.
:return: The response for download the CSV file.
"""
with StringIO() as fp:
writer = csv.writer(fp)
writer.writerows([x.values for x in rows])
fp.seek(0)
response: Response = Response(fp.read(), mimetype="text/csv")
response.headers["Content-Disposition"] \
= f"attachment; filename={filename}"
return response

View File

@ -1,220 +0,0 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The period choosers.
This file is largely taken from the NanoParma ERP project, first written in
2021/9/16 by imacat (imacat@nanoparma.com).
"""
import typing as t
from abc import ABC, abstractmethod
from datetime import date
from flask import url_for
from accounting.models import Currency, Account, Transaction
from accounting.report.income_expense_account import IncomeExpensesAccount
from accounting.report.period import YearPeriod, Period, ThisMonth, \
LastMonth, SinceLastMonth, ThisYear, LastYear, Today, Yesterday, \
TemplatePeriod
class PeriodChooser(ABC):
"""The period chooser."""
def __init__(self, start: date | None):
"""Constructs a period chooser.
:param start: The start of the period.
"""
# Shortcut periods
self.this_month_url: str = self._url_for(ThisMonth())
"""The URL for this month."""
self.last_month_url: str = self._url_for(LastMonth())
"""The URL for last month."""
self.since_last_month_url: str = self._url_for(SinceLastMonth())
"""The URL since last mint."""
self.this_year_url: str = self._url_for(ThisYear())
"""The URL for this year."""
self.last_year_url: str = self._url_for(LastYear())
"""The URL for last year."""
self.today_url: str = self._url_for(Today())
"""The URL for today."""
self.yesterday_url: str = self._url_for(Yesterday())
"""The URL for yesterday."""
self.all_url: str = self._url_for(Period(None, None))
"""The URL for all period."""
self.url_template: str = self._url_for(TemplatePeriod())
"""The URL template."""
# Attributes
self.data_start: date | None = start
"""The start of the data."""
self.has_data: bool = start is not None
"""Whether there is any data."""
self.has_last_month: bool = False
"""Where there is data in last month."""
self.has_last_year: bool = False
"""Whether there is data in last year."""
self.has_yesterday: bool = False
"""Whether there is data in yesterday."""
self.available_years: t.Iterator[int] = []
"""The available years."""
if self.has_data is not None:
today: date = date.today()
self.has_last_month = start < date(today.year, today.month, 1)
self.has_last_year = start.year < today.year
self.has_yesterday = start < today
self.available_years: t.Iterator[int] = []
if start.year < today.year - 1:
self.available_years \
= reversed(range(start.year, today.year - 1))
@abstractmethod
def _url_for(self, period: Period) -> str:
"""Returns the URL for a period.
:param period: The period.
:return: The URL for the period.
"""
pass
def year_url(self, year: int) -> str:
"""Returns the period URL of a year.
:param year: The year
:return: The period URL of the year.
"""
return self._url_for(YearPeriod(year))
class JournalPeriodChooser(PeriodChooser):
"""The journal period chooser."""
def __init__(self):
"""Constructs the journal period chooser."""
first: Transaction | None \
= Transaction.query.order_by(Transaction.date).first()
super().__init__(None if first is None else first.date)
def _url_for(self, period: Period) -> str:
if period.is_default:
return url_for("accounting.report.journal-default")
return url_for("accounting.report.journal", period=period)
class LedgerPeriodChooser(PeriodChooser):
"""The ledger period chooser."""
def __init__(self, currency: Currency, account: Account):
"""Constructs the ledger period chooser."""
self.currency: Currency = currency
"""The currency."""
self.account: Account = account
"""The account."""
first: Transaction | None \
= Transaction.query.order_by(Transaction.date).first()
super().__init__(None if first is None else first.date)
def _url_for(self, period: Period) -> str:
if period.is_default:
return url_for("accounting.report.ledger-default",
currency=self.currency, account=self.account)
return url_for("accounting.report.ledger",
currency=self.currency, account=self.account,
period=period)
class IncomeExpensesPeriodChooser(PeriodChooser):
"""The income and expenses period chooser."""
def __init__(self, currency: Currency, account: IncomeExpensesAccount):
"""Constructs the income and expenses period chooser."""
self.currency: Currency = currency
"""The currency."""
self.account: IncomeExpensesAccount = account
"""The account."""
first: Transaction | None \
= Transaction.query.order_by(Transaction.date).first()
super().__init__(None if first is None else first.date)
def _url_for(self, period: Period) -> str:
if period.is_default:
return url_for("accounting.report.income-expenses-default",
currency=self.currency, account=self.account)
return url_for("accounting.report.income-expenses",
currency=self.currency, account=self.account,
period=period)
class TrialBalancePeriodChooser(PeriodChooser):
"""The trial balance period chooser."""
def __init__(self, currency: Currency):
"""Constructs the trial balance period chooser."""
self.currency: Currency = currency
"""The currency."""
first: Transaction | None \
= Transaction.query.order_by(Transaction.date).first()
super().__init__(None if first is None else first.date)
def _url_for(self, period: Period) -> str:
if period.is_default:
return url_for("accounting.report.trial-balance-default",
currency=self.currency)
return url_for("accounting.report.trial-balance",
currency=self.currency, period=period)
class IncomeStatementPeriodChooser(PeriodChooser):
"""The income statement period chooser."""
def __init__(self, currency: Currency):
"""Constructs the income statement period chooser."""
self.currency: Currency = currency
"""The currency."""
first: Transaction | None \
= Transaction.query.order_by(Transaction.date).first()
super().__init__(None if first is None else first.date)
def _url_for(self, period: Period) -> str:
if period.is_default:
return url_for("accounting.report.income-statement-default",
currency=self.currency)
return url_for("accounting.report.income-statement",
currency=self.currency, period=period)
class BalanceSheetPeriodChooser(PeriodChooser):
"""The balance sheet period chooser."""
def __init__(self, currency: Currency):
"""Constructs the balance sheet period chooser."""
self.currency: Currency = currency
"""The currency."""
first: Transaction | None \
= Transaction.query.order_by(Transaction.date).first()
super().__init__(None if first is None else first.date)
def _url_for(self, period: Period) -> str:
if period.is_default:
return url_for("accounting.report.balance-sheet-default",
currency=self.currency)
return url_for("accounting.report.balance-sheet",
currency=self.currency, period=period)

View File

@ -14,6 +14,6 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The utilities to generate reports.
"""The utilities for the reports.
"""

View File

@ -22,14 +22,18 @@ from abc import ABC, abstractmethod
from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \
urlunparse
import sqlalchemy as sa
from flask import request
from accounting import db
from accounting.models import Currency, JournalEntry
from accounting.utils.txn_types import TransactionType
from .option_link import OptionLink
from .report_chooser import ReportChooser
class PageParams(ABC):
"""The page parameters of a report."""
class BasePageParams(ABC):
"""The base HTML page parameters class."""
@property
@abstractmethod
@ -66,3 +70,19 @@ class PageParams(ABC):
parts: list[str] = list(uri_p)
parts[4] = urlencode(params)
return urlunparse(parts)
@staticmethod
def _get_currency_options(get_url: t.Callable[[Currency], str],
active_currency: Currency) -> list[OptionLink]:
"""Returns the currency options.
:param get_url: The callback to return the URL of a currency.
:param active_currency: The active currency.
:return: The currency options.
"""
in_use: set[str] = set(db.session.scalars(
sa.select(JournalEntry.currency_code)
.group_by(JournalEntry.currency_code)).all())
return [OptionLink(str(x), get_url(x), x.code == active_currency.code)
for x in Currency.query.filter(Currency.code.in_(in_use))
.order_by(Currency.code).all()]

View File

@ -0,0 +1,108 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The utilities to export the report as CSV for download.
"""
import csv
from abc import ABC, abstractmethod
from datetime import timedelta, date
from decimal import Decimal
from io import StringIO
from flask import Response
from accounting.report.period import Period
class BaseCSVRow(ABC):
"""The base CSV row."""
@property
@abstractmethod
def values(self) -> list[str | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
def csv_download(filename: str, rows: list[BaseCSVRow]) -> Response:
"""Exports the data rows as a CSV file for download.
:param filename: The download file name.
:param rows: The data rows.
:return: The response for download the CSV file.
"""
with StringIO() as fp:
writer = csv.writer(fp)
writer.writerows([x.values for x in rows])
fp.seek(0)
response: Response = Response(fp.read(), mimetype="text/csv")
response.headers["Content-Disposition"] \
= f"attachment; filename={filename}"
return response
def period_spec(period: Period) -> str:
"""Constructs the period specification to be used in the filename.
:param period: The period.
:return: The period specification to be used in the filename.
"""
start: str | None = __get_start_str(period.start)
end: str | None = __get_end_str(period.end)
if period.start is None and period.end is None:
return "all-time"
if start == end:
return start
if period.start is None:
return f"until-{end}"
if period.end is None:
return f"since-{start}"
return f"{start}-{end}"
def __get_start_str(start: date | None) -> str | None:
"""Returns the string representation of the start date.
:param start: The start date.
:return: The string representation of the start date, or None if the start
date is None.
"""
if start is None:
return None
if start.month == 1 and start.day == 1:
return str(start.year)
if start.day == 1:
return start.strftime("%Y%m")
return start.strftime("%Y%m%d")
def __get_end_str(end: date | None) -> str | None:
"""Returns the string representation of the end date.
:param end: The end date.
:return: The string representation of the end date, or None if the end
date is None.
"""
if end is None:
return None
if end.month == 12 and end.day == 31:
return str(end.year)
if (end + timedelta(days=1)).day == 1:
return end.strftime("%Y%m")
return end.strftime("%Y%m%d")

View File

@ -19,6 +19,8 @@
"""
import typing as t
from flask import current_app
from accounting.locale import gettext
from accounting.models import Account
@ -33,21 +35,15 @@ class IncomeExpensesAccount:
:param account: The actual account.
"""
self.account: Account | None = None
self.id: int | None = None
self.account: Account | None = account
self.id: int = -1 if account is None else account.id
"""The ID."""
self.code: str | None = None
self.code: str = "" if account is None else account.code
"""The code."""
self.title: str | None = None
self.title: str = "" if account is None else account.title
"""The title."""
self.str: str = ""
self.str: str = "" if account is None else str(account)
"""The string representation of the account."""
if account is not None:
self.account = account
self.id = account.id
self.code = account.code
self.title = account.title
self.str = str(account)
def __str__(self) -> str:
"""Returns the string representation of the account.
@ -68,3 +64,23 @@ class IncomeExpensesAccount:
account.title = gettext("current assets and liabilities")
account.str = account.title
return account
def default_ie_account_code() -> str:
"""Returns the default account code for the income and expenses log.
:return: The default account code for the income and expenses log.
"""
with current_app.app_context():
return current_app.config.get("DEFAULT_IE_ACCOUNT", Account.CASH_CODE)
def default_ie_account() -> IncomeExpensesAccount:
"""Returns the default account for the income and expenses log.
:return: The default account for the income and expenses log.
"""
code: str = default_ie_account_code()
if code == IncomeExpensesAccount.CURRENT_AL_CODE:
return IncomeExpensesAccount.current_assets_and_liabilities()
return IncomeExpensesAccount(Account.find_by_code(code))

View File

@ -22,13 +22,20 @@
class OptionLink:
"""An option link."""
def __init__(self, title: str, url: str, is_active: bool):
def __init__(self, title: str, url: str, is_active: bool,
fa_icon: str | None = None):
"""Constructs an option link.
:param title: The title.
:param url: The URI.
:param url: The URL.
:param is_active: True if active, or False otherwise
:param fa_icon: The font-awesome icon, if any.
"""
self.title: str = title
"""The title."""
self.url: str = url
"""The URL."""
self.is_active: bool = is_active
"""True if active, or False otherwise."""
self.fa_icon: str | None = fa_icon
"""The font-awesome icon, if any."""

View File

@ -23,16 +23,18 @@ This file is largely taken from the NanoParma ERP project, first written in
import re
import typing as t
from flask import url_for
from flask_babel import LazyString
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account
from accounting.report.period import Period
from accounting.report.period import Period, get_period
from accounting.template_globals import default_currency_code
from .ie_account import IncomeExpensesAccount
from .option_link import OptionLink
from .report_type import ReportType
from .urls import journal_url, ledger_url, income_expenses_url, \
trial_balance_url, income_statement_url, balance_sheet_url
class ReportChooser:
@ -51,20 +53,21 @@ class ReportChooser:
"""
self.__active_report: ReportType = active_report
"""The currently active report."""
self.__period: Period = Period.get_instance() if period is None \
else period
self.__period: Period = get_period() if period is None else period
"""The period."""
self.__currency: Currency = db.session.get(
Currency, default_currency_code()) \
if currency is None else currency
"""The currency."""
self.__account: Account = Account.find_by_code("1111-001") \
if account is None else account
self.__account: Account = Account.cash() if account is None \
else account
"""The currency."""
self.__reports: list[OptionLink] = []
"""The links to the reports."""
self.current_report: str | LazyString = ""
"""The title of the current report."""
self.is_search: bool = active_report == ReportType.SEARCH
"""Whether the current report is the search page."""
self.__reports.append(self.__journal)
self.__reports.append(self.__ledger)
self.__reports.append(self.__income_expenses)
@ -74,6 +77,8 @@ class ReportChooser:
for report in self.__reports:
if report.is_active:
self.current_report = report.title
if self.is_search:
self.current_report = gettext("Search")
@property
def __journal(self) -> OptionLink:
@ -81,11 +86,9 @@ class ReportChooser:
:return: The journal.
"""
url: str = url_for("accounting.report.journal-default") \
if self.__period.is_default \
else url_for("accounting.report.journal", period=self.__period)
return OptionLink(gettext("Journal"), url,
self.__active_report == ReportType.JOURNAL)
return OptionLink(gettext("Journal"), journal_url(self.__period),
self.__active_report == ReportType.JOURNAL,
fa_icon="fa-solid fa-book")
@property
def __ledger(self) -> OptionLink:
@ -93,32 +96,27 @@ class ReportChooser:
:return: The ledger.
"""
url: str = url_for("accounting.report.ledger-default",
currency=self.__currency, account=self.__account) \
if self.__period.is_default \
else url_for("accounting.report.ledger",
currency=self.__currency, account=self.__account,
period=self.__period)
return OptionLink(gettext("Ledger"), url,
self.__active_report == ReportType.LEDGER)
return OptionLink(gettext("Ledger"),
ledger_url(self.__currency, self.__account,
self.__period),
self.__active_report == ReportType.LEDGER,
fa_icon="fa-solid fa-clipboard")
@property
def __income_expenses(self) -> OptionLink:
"""Returns the income and expenses.
"""Returns the income and expenses log.
:return: The income and expenses.
:return: The income and expenses log.
"""
account: Account = self.__account
if not re.match(r"[12][12]", account.base_code):
account: Account = Account.find_by_code("1111-001")
url: str = url_for("accounting.report.income-expenses-default",
currency=self.__currency, account=account) \
if self.__period.is_default \
else url_for("accounting.report.income-expenses",
currency=self.__currency, account=account,
period=self.__period)
return OptionLink(gettext("Income and Expenses"), url,
self.__active_report == ReportType.INCOME_EXPENSES)
account: Account = Account.cash()
return OptionLink(gettext("Income and Expenses Log"),
income_expenses_url(self.__currency,
IncomeExpensesAccount(account),
self.__period),
self.__active_report == ReportType.INCOME_EXPENSES,
fa_icon="fa-solid fa-money-bill-wave")
@property
def __trial_balance(self) -> OptionLink:
@ -126,13 +124,10 @@ class ReportChooser:
:return: The trial balance.
"""
url: str = url_for("accounting.report.trial-balance-default",
currency=self.__currency) \
if self.__period.is_default \
else url_for("accounting.report.trial-balance",
currency=self.__currency, period=self.__period)
return OptionLink(gettext("Trial Balance"), url,
self.__active_report == ReportType.TRIAL_BALANCE)
return OptionLink(gettext("Trial Balance"),
trial_balance_url(self.__currency, self.__period),
self.__active_report == ReportType.TRIAL_BALANCE,
fa_icon="fa-solid fa-scale-unbalanced")
@property
def __income_statement(self) -> OptionLink:
@ -140,13 +135,10 @@ class ReportChooser:
:return: The income statement.
"""
url: str = url_for("accounting.report.income-statement-default",
currency=self.__currency) \
if self.__period.is_default \
else url_for("accounting.report.income-statement",
currency=self.__currency, period=self.__period)
return OptionLink(gettext("Income Statement"), url,
self.__active_report == ReportType.INCOME_STATEMENT)
return OptionLink(gettext("Income Statement"),
income_statement_url(self.__currency, self.__period),
self.__active_report == ReportType.INCOME_STATEMENT,
fa_icon="fa-solid fa-file-invoice-dollar")
@property
def __balance_sheet(self) -> OptionLink:
@ -154,13 +146,10 @@ class ReportChooser:
:return: The balance sheet.
"""
url: str = url_for("accounting.report.balance-sheet-default",
currency=self.__currency) \
if self.__period.is_default \
else url_for("accounting.report.balance-sheet",
currency=self.__currency, period=self.__period)
return OptionLink(gettext("Balance Sheet"), url,
self.__active_report == ReportType.BALANCE_SHEET)
return OptionLink(gettext("Balance Sheet"),
balance_sheet_url(self.__currency, self.__period),
self.__active_report == ReportType.BALANCE_SHEET,
fa_icon="fa-solid fa-scale-balanced")
def __iter__(self) -> t.Iterator[OptionLink]:
"""Returns the iteration of the reports.

View File

@ -27,7 +27,7 @@ class ReportType(Enum):
LEDGER: str = "ledger"
"""The ledger."""
INCOME_EXPENSES: str = "income-expenses"
"""The income and expenses."""
"""The income and expenses log."""
TRIAL_BALANCE: str = "trial-balance"
"""The trial balance."""
INCOME_STATEMENT: str = "income-statement"
@ -35,4 +35,4 @@ class ReportType(Enum):
BALANCE_SHEET: str = "balance-sheet"
"""The balance sheet."""
SEARCH: str = "search"
"""The balance sheet."""
"""The search."""

View File

@ -0,0 +1,117 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The utilities to get the ledger URL.
"""
from flask import url_for
from accounting.models import Currency, Account
from accounting.report.period import Period
from accounting.template_globals import default_currency_code
from .ie_account import IncomeExpensesAccount, default_ie_account_code
def journal_url(period: Period) \
-> str:
"""Returns the URL of a journal.
:param period: The period.
:return: The URL of the journal.
"""
if period.is_default:
return url_for("accounting.report.journal-default")
return url_for("accounting.report.journal", period=period)
def ledger_url(currency: Currency, account: Account, period: Period) \
-> str:
"""Returns the URL of a ledger.
:param currency: The currency.
:param account: The account.
:param period: The period.
:return: The URL of the ledger.
"""
if period.is_default:
return url_for("accounting.report.ledger-default",
currency=currency, account=account)
return url_for("accounting.report.ledger",
currency=currency, account=account,
period=period)
def income_expenses_url(currency: Currency, account: IncomeExpensesAccount,
period: Period) -> str:
"""Returns the URL of an income and expenses log.
:param currency: The currency.
:param account: The account.
:param period: The period.
:return: The URL of the income and expenses log.
"""
if currency.code == default_currency_code() \
and account.code == default_ie_account_code() \
and period.is_default:
return url_for("accounting.report.default")
if period.is_default:
return url_for("accounting.report.income-expenses-default",
currency=currency, account=account)
return url_for("accounting.report.income-expenses",
currency=currency, account=account,
period=period)
def trial_balance_url(currency: Currency, period: Period) -> str:
"""Returns the URL of a trial balance.
:param currency: The currency.
:param period: The period.
:return: The URL of the trial balance.
"""
if period.is_default:
return url_for("accounting.report.trial-balance-default",
currency=currency)
return url_for("accounting.report.trial-balance",
currency=currency, period=period)
def income_statement_url(currency: Currency, period: Period) -> str:
"""Returns the URL of an income statement.
:param currency: The currency.
:param period: The period.
:return: The URL of the income statement.
"""
if period.is_default:
return url_for("accounting.report.income-statement-default",
currency=currency)
return url_for("accounting.report.income-statement",
currency=currency, period=period)
def balance_sheet_url(currency: Currency, period: Period) -> str:
"""Returns the URL of a balance sheet.
:param currency: The currency.
:param period: The period.
:return: The URL of the balance sheet.
"""
if period.is_default:
return url_for("accounting.report.balance-sheet-default",
currency=currency)
return url_for("accounting.report.balance-sheet",
currency=currency, period=period)

View File

@ -19,41 +19,56 @@
"""
from flask import Blueprint, request, Response
from accounting import db
from accounting.models import Currency, Account
from accounting.report.period import Period, get_period
from accounting.template_globals import default_currency_code
from accounting.utils.permission import has_permission, can_view
from .income_expense_account import IncomeExpensesAccount
from .period import Period
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
IncomeStatement, BalanceSheet, Search
from .template_filters import format_amount
from .utils.ie_account import IncomeExpensesAccount, default_ie_account
bp: Blueprint = Blueprint("report", __name__)
"""The view blueprint for the reports."""
bp.add_app_template_filter(format_amount, "accounting_report_format_amount")
@bp.get("", endpoint="default")
@has_permission(can_view)
def get_default_report() -> str | Response:
"""Returns the income and expenses log in the default period.
:return: The income and expenses log in the default period.
"""
return __get_income_expenses(
db.session.get(Currency, default_currency_code()),
default_ie_account(),
get_period())
@bp.get("journal", endpoint="journal-default")
@has_permission(can_view)
def get_default_journal_list() -> str | Response:
def get_default_journal() -> str | Response:
"""Returns the journal in the default period.
:return: The journal in the default period.
"""
return __get_journal_list(Period.get_instance())
return __get_journal(get_period())
@bp.get("journal/<period:period>", endpoint="journal")
@has_permission(can_view)
def get_journal_list(period: Period) -> str | Response:
def get_journal(period: Period) -> str | Response:
"""Returns the journal.
:param period: The period.
:return: The journal in the period.
"""
return __get_journal_list(period)
return __get_journal(period)
def __get_journal_list(period: Period) -> str | Response:
def __get_journal(period: Period) -> str | Response:
"""Returns the journal.
:param period: The period.
@ -68,21 +83,20 @@ def __get_journal_list(period: Period) -> str | Response:
@bp.get("ledger/<currency:currency>/<account:account>",
endpoint="ledger-default")
@has_permission(can_view)
def get_default_ledger_list(currency: Currency, account: Account) \
-> str | Response:
def get_default_ledger(currency: Currency, account: Account) -> str | Response:
"""Returns the ledger in the default period.
:param currency: The currency.
:param account: The account.
:return: The ledger in the default period.
"""
return __get_ledger_list(currency, account, Period.get_instance())
return __get_ledger(currency, account, get_period())
@bp.get("ledger/<currency:currency>/<account:account>/<period:period>",
endpoint="ledger")
@has_permission(can_view)
def get_ledger_list(currency: Currency, account: Account, period: Period) \
def get_ledger(currency: Currency, account: Account, period: Period) \
-> str | Response:
"""Returns the ledger.
@ -91,10 +105,10 @@ def get_ledger_list(currency: Currency, account: Account, period: Period) \
:param period: The period.
:return: The ledger in the period.
"""
return __get_ledger_list(currency, account, period)
return __get_ledger(currency, account, period)
def __get_ledger_list(currency: Currency, account: Account, period: Period) \
def __get_ledger(currency: Currency, account: Account, period: Period) \
-> str | Response:
"""Returns the ledger.
@ -109,47 +123,45 @@ def __get_ledger_list(currency: Currency, account: Account, period: Period) \
return report.html()
@bp.get("income-expenses/<currency:currency>/<ioAccount:account>",
@bp.get("income-expenses/<currency:currency>/<ieAccount:account>",
endpoint="income-expenses-default")
@has_permission(can_view)
def get_default_income_expenses_list(currency: Currency,
def get_default_income_expenses(currency: Currency,
account: IncomeExpensesAccount) \
-> str | Response:
"""Returns the income and expenses in the default period.
"""Returns the income and expenses log in the default period.
:param currency: The currency.
:param account: The account.
:return: The income and expenses in the default period.
:return: The income and expenses log in the default period.
"""
return __get_income_expenses_list(currency, account, Period.get_instance())
return __get_income_expenses(currency, account, get_period())
@bp.get(
"income-expenses/<currency:currency>/<ioAccount:account>/<period:period>",
"income-expenses/<currency:currency>/<ieAccount:account>/<period:period>",
endpoint="income-expenses")
@has_permission(can_view)
def get_income_expenses_list(currency: Currency,
account: IncomeExpensesAccount,
def get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
period: Period) -> str | Response:
"""Returns the income and expenses.
"""Returns the income and expenses log.
:param currency: The currency.
:param account: The account.
:param period: The period.
:return: The income and expenses in the period.
:return: The income and expenses log in the period.
"""
return __get_income_expenses_list(currency, account, period)
return __get_income_expenses(currency, account, period)
def __get_income_expenses_list(currency: Currency,
account: IncomeExpensesAccount,
def __get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
period: Period) -> str | Response:
"""Returns the income and expenses.
"""Returns the income and expenses log.
:param currency: The currency.
:param account: The account.
:param period: The period.
:return: The income and expenses in the period.
:return: The income and expenses log in the period.
"""
report: IncomeExpenses = IncomeExpenses(currency, account, period)
if "as" in request.args and request.args["as"] == "csv":
@ -160,31 +172,29 @@ def __get_income_expenses_list(currency: Currency,
@bp.get("trial-balance/<currency:currency>",
endpoint="trial-balance-default")
@has_permission(can_view)
def get_default_trial_balance_list(currency: Currency) -> str | Response:
def get_default_trial_balance(currency: Currency) -> str | Response:
"""Returns the trial balance in the default period.
:param currency: The currency.
:return: The trial balance in the default period.
"""
return __get_trial_balance_list(currency, Period.get_instance())
return __get_trial_balance(currency, get_period())
@bp.get("trial-balance/<currency:currency>/<period:period>",
endpoint="trial-balance")
@has_permission(can_view)
def get_trial_balance_list(currency: Currency, period: Period) \
-> str | Response:
def get_trial_balance(currency: Currency, period: Period) -> str | Response:
"""Returns the trial balance.
:param currency: The currency.
:param period: The period.
:return: The trial balance in the period.
"""
return __get_trial_balance_list(currency, period)
return __get_trial_balance(currency, period)
def __get_trial_balance_list(currency: Currency, period: Period) \
-> str | Response:
def __get_trial_balance(currency: Currency, period: Period) -> str | Response:
"""Returns the trial balance.
:param currency: The currency.
@ -200,30 +210,29 @@ def __get_trial_balance_list(currency: Currency, period: Period) \
@bp.get("income-statement/<currency:currency>",
endpoint="income-statement-default")
@has_permission(can_view)
def get_default_income_statement_list(currency: Currency) -> str | Response:
def get_default_income_statement(currency: Currency) -> str | Response:
"""Returns the income statement in the default period.
:param currency: The currency.
:return: The income statement in the default period.
"""
return __get_income_statement_list(currency, Period.get_instance())
return __get_income_statement(currency, get_period())
@bp.get("income-statement/<currency:currency>/<period:period>",
endpoint="income-statement")
@has_permission(can_view)
def get_income_statement_list(currency: Currency, period: Period) \
-> str | Response:
def get_income_statement(currency: Currency, period: Period) -> str | Response:
"""Returns the income statement.
:param currency: The currency.
:param period: The period.
:return: The income statement in the period.
"""
return __get_income_statement_list(currency, period)
return __get_income_statement(currency, period)
def __get_income_statement_list(currency: Currency, period: Period) \
def __get_income_statement(currency: Currency, period: Period) \
-> str | Response:
"""Returns the income statement.
@ -240,19 +249,19 @@ def __get_income_statement_list(currency: Currency, period: Period) \
@bp.get("balance-sheet/<currency:currency>",
endpoint="balance-sheet-default")
@has_permission(can_view)
def get_default_balance_sheet_list(currency: Currency) -> str | Response:
def get_default_balance_sheet(currency: Currency) -> str | Response:
"""Returns the balance sheet in the default period.
:param currency: The currency.
:return: The balance sheet in the default period.
"""
return __get_balance_sheet_list(currency, Period.get_instance())
return __get_balance_sheet(currency, get_period())
@bp.get("balance-sheet/<currency:currency>/<period:period>",
endpoint="balance-sheet")
@has_permission(can_view)
def get_balance_sheet_list(currency: Currency, period: Period) \
def get_balance_sheet(currency: Currency, period: Period) \
-> str | Response:
"""Returns the balance sheet.
@ -260,10 +269,10 @@ def get_balance_sheet_list(currency: Currency, period: Period) \
:param period: The period.
:return: The balance sheet in the period.
"""
return __get_balance_sheet_list(currency, period)
return __get_balance_sheet(currency, period)
def __get_balance_sheet_list(currency: Currency, period: Period) \
def __get_balance_sheet(currency: Currency, period: Period) \
-> str | Response:
"""Returns the balance sheet.
@ -288,4 +297,3 @@ def search() -> str | Response:
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()

View File

@ -24,19 +24,6 @@
.accounting-clickable {
cursor: pointer;
}
.accounting-search-desktop-form {
max-width: 16rem;
}
.btn-group .btn .accounting-search-input {
min-height: calc(1em + .5rem + 2px);
padding: 0 0.5rem;
}
.btn-group .btn .accounting-search-label button {
border: none;
background-color: transparent;
color: inherit;
padding-right: 0;
}
.form-floating > textarea.form-control {
height: 6rem;
}
@ -44,6 +31,64 @@
color: #141619;
background-color: #D3D3D4;
}
.form-control.accounting-disabled {
background-color: #e9ecef;
}
/** The toolbar */
.accounting-toolbar {
display: flex;
}
.accounting-toolbar .input-group > .input-group-text {
padding: 0;
background-color: transparent;
color: inherit;
border: 0;
}
.accounting-toolbar .input-group > .input-group-text > button {
background-color: transparent;
color: inherit;
border: 0;
}
.accounting-toolbar form.btn > .form-control {
min-height: calc(1.5em + 2px);
padding-top: 0.1rem;
padding-bottom: 0.1rem;
}
@media(min-width: 768px) {
.accounting-toolbar > .btn, .accounting-toolbar > .btn-group > .btn {
border-radius: 0;
}
.accounting-toolbar > .btn:first-child, .accounting-toolbar > .btn-group:first-child > .btn {
border-top-left-radius: 0.375rem;
border-bottom-left-radius: 0.375rem;
}
.accounting-toolbar > .btn:last-child, .accounting-toolbar > .btn-group:last-child > .btn {
border-top-right-radius: 0.375rem;
border-bottom-right-radius: 0.375rem;
}
.accounting-toolbar .btn.input-group {
width: 16rem;
}
}
@media(max-width:767px) {
.accounting-toolbar > .btn:not(form), .accounting-toolbar > .btn-group > .btn {
height: 3.2rem;
width: 3.2rem;
border-radius: 50%;
margin-left: 1rem;
}
.accounting-toolbar > a.btn, .accounting-toolbar > .btn-group > a.btn {
padding-top: 0.7rem;
}
.accounting-toolbar > form.btn {
width: 12rem;
height: 2.6rem;
border-radius: 0.375rem;
margin-top: 0.3rem;
margin-left: 1rem;
}
}
/** The card layout */
.accounting-card {
@ -71,6 +116,33 @@
border-bottom: thick double slategray;
}
/* Links between objects */
.accounting-original-entry {
border-top: thin solid darkslategray;
padding: 0.2rem 0.5rem;
}
.accounting-original-entry a {
color: inherit;
text-decoration: none;
}
.accounting-original-entry a:hover {
color: inherit;
}
.accounting-offset-entries {
border-top: thin solid darkslategray;
padding: 0.2rem 0.5rem;
}
.accounting-offset-entries ul li {
list-style: none;
}
.accounting-offset-entries ul li a {
color: inherit;
text-decoration: none;
}
.accounting-offset-entries ul li a:hover {
color: inherit;
}
/** The option selector */
.accounting-selector-list {
height: 20rem;
@ -94,9 +166,6 @@
.accounting-list-group-stripped .list-group-item:nth-child(2n+1) {
background-color: #f2f2f2;
}
.accounting-list-group-stripped .list-group-item-success:nth-child(2n+1) {
background-color: #c7dbd2;
}
.accounting-list-group-hover .list-group-item:hover {
background-color: #ececec;
}
@ -111,6 +180,9 @@
font-weight: bolder;
border-top: thick double slategray;
}
.accounting-entry-editor-original-entry-content {
width: calc(100% - 3rem);
}
/* The report table */
.accounting-report-table-header, .accounting-report-table-footer {
@ -149,12 +221,18 @@ a.accounting-report-table-row {
.accounting-journal-table .accounting-report-table-row {
grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr;
}
.accounting-ledger-table .accounting-report-table-row {
.accounting-ledger-real-table .accounting-report-table-row {
grid-template-columns: 1fr 4fr 1fr 1fr 1fr;
}
.accounting-ledger-table .accounting-report-table-footer .accounting-report-table-row {
.accounting-ledger-real-table .accounting-report-table-footer .accounting-report-table-row {
grid-template-columns: 5fr 1fr 1fr 1fr;
}
.accounting-ledger-nominal-table .accounting-report-table-row {
grid-template-columns: 1fr 4fr 1fr 1fr;
}
.accounting-ledger-nominal-table .accounting-report-table-footer .accounting-report-table-row {
grid-template-columns: 5fr 1fr 1fr;
}
.accounting-income-expenses-table .accounting-report-table-row {
grid-template-columns: 1fr 2fr 4fr 1fr 1fr 1fr;
}

View File

@ -20,93 +20,297 @@
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/1
*/
"use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
initializeBaseAccountSelector();
document.getElementById("accounting-base-code")
.onchange = validateBase;
document.getElementById("accounting-title")
.onchange = validateTitle;
document.getElementById("accounting-form")
.onsubmit = validateForm;
AccountForm.initialize();
});
/**
* Initializes the base account selector.
* The account form.
*
* @private
*/
function initializeBaseAccountSelector() {
const selector = document.getElementById("accounting-base-selector-modal");
const base = document.getElementById("accounting-base");
const baseCode = document.getElementById("accounting-base-code");
const baseContent = document.getElementById("accounting-base-content");
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
const btnClear = document.getElementById("accounting-btn-clear-base");
selector.addEventListener("show.bs.modal", () => {
base.classList.add("accounting-not-empty");
for (const option of options) {
option.classList.remove("active");
}
const selected = document.getElementById("accounting-base-option-" + baseCode.value);
if (selected !== null) {
selected.classList.add("active");
}
});
selector.addEventListener("hidden.bs.modal", () => {
if (baseCode.value === "") {
base.classList.remove("accounting-not-empty");
}
});
for (const option of options) {
option.onclick = () => {
baseCode.value = option.dataset.code;
baseContent.innerText = option.dataset.content;
btnClear.classList.add("btn-danger");
btnClear.classList.remove("btn-secondary")
btnClear.disabled = false;
validateBase();
bootstrap.Modal.getInstance(selector).hide();
class AccountForm {
/**
* The base account selector
* @type {BaseAccountSelector}
*/
#baseAccountSelector;
/**
* The form element
* @type {HTMLFormElement}
*/
#formElement;
/**
* The control of the base account
* @type {HTMLDivElement}
*/
#baseControl;
/**
* The input of the base account
* @type {HTMLInputElement}
*/
#baseCode;
/**
* The base account
* @type {HTMLDivElement}
*/
#base;
/**
* The error message for the base account
* @type {HTMLDivElement}
*/
#baseError;
/**
* The title
* @type {HTMLInputElement}
*/
#title;
/**
* The error message of the title
* @type {HTMLDivElement}
*/
#titleError;
/**
* The control of the is-need-offset option
* @type {HTMLDivElement}
*/
#isNeedOffsetControl;
/**
* The is-need-offset option
* @type {HTMLInputElement}
*/
#isNeedOffset;
/**
* Constructs the account form.
*
*/
constructor() {
this.#baseAccountSelector = new BaseAccountSelector(this);
this.#formElement = document.getElementById("accounting-form");
this.#baseControl = document.getElementById("accounting-base-control");
this.#baseCode = document.getElementById("accounting-base-code");
this.#base = document.getElementById("accounting-base");
this.#baseError = document.getElementById("accounting-base-error");
this.#title = document.getElementById("accounting-title");
this.#titleError = document.getElementById("accounting-title-error");
this.#isNeedOffsetControl = document.getElementById("accounting-is-need-offset-control");
this.#isNeedOffset = document.getElementById("accounting-is-need-offset");
this.#formElement.onsubmit = () => {
return this.#validateForm();
};
this.#baseControl.onclick = () => {
this.#baseControl.classList.add("accounting-not-empty");
this.#baseAccountSelector.onOpen(this.#baseCode.value);
};
}
btnClear.onclick = () => {
baseCode.value = "";
baseContent.innerText = "";
btnClear.classList.add("btn-secondary")
btnClear.classList.remove("btn-danger");
btnClear.disabled = true;
validateBase();
bootstrap.Modal.getInstance(selector).hide();
/**
* The callback when the base account selector is closed.
*
*/
onBaseAccountSelectorClosed() {
if (this.#baseCode.value === "") {
this.#baseControl.classList.remove("accounting-not-empty");
}
initializeBaseAccountQuery();
}
/**
* Initializes the query on the base account options.
* Sets the base account.
*
* @param code {string} the base account code
* @param text {string} the text for the base account
*/
setBaseAccount(code, text) {
this.#baseCode.value = code;
this.#base.innerText = text;
if (["1", "2", "3"].includes(code.substring(0, 1))) {
this.#isNeedOffsetControl.classList.remove("d-none");
this.#isNeedOffset.disabled = false;
} else {
this.#isNeedOffsetControl.classList.add("d-none");
this.#isNeedOffset.disabled = true;
this.#isNeedOffset.checked = false;
}
this.#validateBase();
}
/**
* Clears the base account.
*
*/
clearBaseAccount() {
this.#baseCode.value = "";
this.#base.innerText = "";
this.#validateBase();
}
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validateForm() {
let isValid = true;
isValid = this.#validateBase() && isValid;
isValid = this.#validateTitle() && isValid;
return isValid;
}
/**
* Validates the base account.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validateBase() {
if (this.#baseCode.value === "") {
this.#baseControl.classList.add("is-invalid");
this.#baseError.innerText = A_("Please select the base account.");
return false;
}
this.#baseControl.classList.remove("is-invalid");
this.#baseError.innerText = "";
return true;
}
/**
* Validates the title.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validateTitle() {
this.#title.value = this.#title.value.trim();
if (this.#title.value === "") {
this.#title.classList.add("is-invalid");
this.#titleError.innerText = A_("Please fill in the title.");
return false;
}
this.#title.classList.remove("is-invalid");
this.#titleError.innerText = "";
return true;
}
/**
* The account form
* @type {AccountForm} the form
*/
static #form;
static initialize() {
this.#form = new AccountForm();
}
}
/**
* The base account selector.
*
* @private
*/
function initializeBaseAccountQuery() {
const query = document.getElementById("accounting-base-selector-query");
const optionList = document.getElementById("accounting-base-option-list");
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
const queryNoResult = document.getElementById("accounting-base-option-no-result");
query.addEventListener("input", () => {
if (query.value === "") {
for (const option of options) {
class BaseAccountSelector {
/**
* The account form
* @type {AccountForm}
*/
#form;
/**
* The selector modal
* @type {HTMLDivElement}
*/
#modal;
/**
* 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 button to clear the base account value
* @type {HTMLButtonElement}
*/
#clearButton;
/**
* Constructs the base account selector.
*
* @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();
}
/**
* Initializes the query.
*
*/
#initializeBaseAccountQuery() {
this.#query.addEventListener("input", () => {
if (this.#query.value === "") {
for (const option of this.#options) {
option.classList.remove("d-none");
}
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
this.#optionList.classList.remove("d-none");
this.#queryNoResult.classList.add("d-none");
return
}
let hasAnyMatched = false;
for (const option of options) {
for (const option of this.#options) {
const queryValues = JSON.parse(option.dataset.queryValues);
let isMatched = false;
for (const queryValue of queryValues) {
if (queryValue.includes(query.value)) {
if (queryValue.includes(this.#query.value)) {
isMatched = true;
break;
}
@ -119,65 +323,36 @@ function initializeBaseAccountQuery() {
}
}
if (!hasAnyMatched) {
optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none");
this.#optionList.classList.add("d-none");
this.#queryNoResult.classList.remove("d-none");
} else {
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
this.#optionList.classList.remove("d-none");
this.#queryNoResult.classList.add("d-none");
}
});
}
/**
* Validates the form.
* The callback when the base account selector is shown.
*
* @returns {boolean} true if valid, or false otherwise
* @private
* @param baseCode {string} the active base code
*/
function validateForm() {
let isValid = true;
isValid = validateBase() && isValid;
isValid = validateTitle() && isValid;
return isValid;
onOpen(baseCode) {
for (const option of this.#options) {
if (option.dataset.code === baseCode) {
option.classList.add("active");
} else {
option.classList.remove("active");
}
/**
* Validates the base account.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateBase() {
const field = document.getElementById("accounting-base-code");
const error = document.getElementById("accounting-base-code-error");
const displayField = document.getElementById("accounting-base");
field.value = field.value.trim();
if (field.value === "") {
displayField.classList.add("is-invalid");
error.innerText = A_("Please select the base account.");
return false;
}
displayField.classList.remove("is-invalid");
error.innerText = "";
return true;
if (baseCode === "") {
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;
}
/**
* Validates the title.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateTitle() {
const field = document.getElementById("accounting-title");
const error = document.getElementById("accounting-title-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the title.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}

View File

@ -20,6 +20,7 @@
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/2
*/
"use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {

View File

@ -20,11 +20,7 @@
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/28
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
AccountSelector.initialize();
});
"use strict";
/**
* The account selector.
@ -32,11 +28,17 @@ document.addEventListener("DOMContentLoaded", () => {
*/
class AccountSelector {
/**
* The journal entry editor
* @type {JournalEntryEditor}
*/
#entryEditor;
/**
* The entry type
* @type {string}
*/
#entryType;
entryType;
/**
* The prefix of the HTML ID and class
@ -44,78 +46,81 @@ class AccountSelector {
*/
#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 modal {HTMLFormElement} the account selector modal
* @param entryEditor {JournalEntryEditor} the journal entry editor
* @param entryType {string} the entry type, either "debit" or "credit"
*/
constructor(modal) {
this.#entryType = modal.dataset.entryType;
this.#prefix = "accounting-account-selector-" + modal.dataset.entryType;
this.#init();
}
/**
* Initializes the account selector.
*
*/
#init() {
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account");
const more = document.getElementById(this.#prefix + "-more");
const btnClear = document.getElementById(this.#prefix + "-btn-clear");
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
more.onclick = () => {
more.classList.add("d-none");
this.#filterAccountOptions();
};
this.#initializeAccountQuery();
btnClear.onclick = () => {
formAccountControl.classList.remove("accounting-not-empty");
formAccount.innerText = "";
formAccount.dataset.code = "";
formAccount.dataset.text = "";
validateJournalEntryAccount();
};
for (const option of options) {
option.onclick = () => {
formAccountControl.classList.add("accounting-not-empty");
formAccount.innerText = option.dataset.content;
formAccount.dataset.code = option.dataset.code;
formAccount.dataset.text = option.dataset.content;
validateJournalEntryAccount();
constructor(entryEditor, entryType) {
this.#entryEditor = entryEditor
this.entryType = entryType;
this.#prefix = "accounting-account-selector-" + entryType;
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.#entryEditor.clearAccount();
for (const option of this.#options) {
option.onclick = () => this.#entryEditor.saveAccount(option.dataset.code, option.dataset.content, option.classList.contains("accounting-account-is-need-offset"));
}
}
/**
* Initializes the query on the account options.
*
*/
#initializeAccountQuery() {
const query = document.getElementById(this.#prefix + "-query");
query.addEventListener("input", () => {
this.#filterAccountOptions();
this.#query.addEventListener("input", () => {
this.#filterOptions();
});
}
/**
* Filters the account options.
* Filters the options.
*
*/
#filterAccountOptions() {
const query = document.getElementById(this.#prefix + "-query");
const optionList = document.getElementById(this.#prefix + "-option-list");
if (optionList === null) {
console.log(this.#prefix + "-option-list");
}
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
const more = document.getElementById(this.#prefix + "-more");
const queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
const codesInUse = this.#getAccountCodeUsedInForm();
#filterOptions() {
const codesInUse = this.#getCodesUsedInForm();
let shouldAnyShow = false;
for (const option of options) {
const shouldShow = this.#shouldAccountOptionShow(option, more, codesInUse, query);
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;
@ -123,12 +128,12 @@ class AccountSelector {
option.classList.add("d-none");
}
}
if (!shouldAnyShow && more.classList.contains("d-none")) {
optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none");
if (!shouldAnyShow && this.#more.classList.contains("d-none")) {
this.#optionList.classList.add("d-none");
this.#queryNoResult.classList.remove("d-none");
} else {
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
this.#optionList.classList.remove("d-none");
this.#queryNoResult.classList.add("d-none");
}
}
@ -137,26 +142,24 @@ class AccountSelector {
*
* @return {string[]} the account codes that are used in the form
*/
#getAccountCodeUsedInForm() {
const accountCodes = Array.from(document.getElementsByClassName("accounting-" + this.#prefix + "-account-code"));
const formAccount = document.getElementById("accounting-entry-form-account");
const inUse = [formAccount.dataset.code];
for (const accountCode of accountCodes) {
inUse.push(accountCode.value);
#getCodesUsedInForm() {
const inUse = this.#entryEditor.form.getAccountCodesUsed(this.entryType);
if (this.#entryEditor.accountCode !== null) {
inUse.push(this.#entryEditor.accountCode);
}
return inUse
}
/**
* Returns whether an account option should show.
* Returns whether an option should show.
*
* @param option {HTMLLIElement} the account option
* @param more {HTMLLIElement} the more account element
* @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 account option should show, or false otherwise
* @return {boolean} true if the option should show, or false otherwise
*/
#shouldAccountOptionShow(option, more, inUse, query) {
#shouldOptionShow(option, more, inUse, query) {
const isQueryMatched = () => {
if (query.value === "") {
return true;
@ -179,71 +182,43 @@ class AccountSelector {
}
/**
* Initializes the account selector when it is shown.
* The callback when the account selector is shown.
*
*/
initShow() {
const formAccount = document.getElementById("accounting-entry-form-account");
const query = document.getElementById(this.#prefix + "-query")
const more = document.getElementById(this.#prefix + "-more");
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
const btnClear = document.getElementById(this.#prefix + "-btn-clear");
query.value = "";
more.classList.remove("d-none");
this.#filterAccountOptions();
for (const option of options) {
if (option.dataset.code === formAccount.dataset.code) {
onOpen() {
this.#query.value = "";
this.#more.classList.remove("d-none");
this.#filterOptions();
for (const option of this.#options) {
if (option.dataset.code === this.#entryEditor.accountCode) {
option.classList.add("active");
} else {
option.classList.remove("active");
}
}
if (formAccount.dataset.code === "") {
btnClear.classList.add("btn-secondary");
btnClear.classList.remove("btn-danger");
btnClear.disabled = true;
if (this.#entryEditor.accountCode === null) {
this.#clearButton.classList.add("btn-secondary");
this.#clearButton.classList.remove("btn-danger");
this.#clearButton.disabled = true;
} else {
btnClear.classList.add("btn-danger");
btnClear.classList.remove("btn-secondary");
btnClear.disabled = false;
this.#clearButton.classList.add("btn-danger");
this.#clearButton.classList.remove("btn-secondary");
this.#clearButton.disabled = false;
}
}
/**
* The account selectors.
* @type {{debit: AccountSelector, credit: AccountSelector}}
*/
static #selectors = {}
/**
* Initializes the account selectors.
* Returns the account selector instances.
*
* @param entryEditor {JournalEntryEditor} the journal entry editor
* @return {{debit: AccountSelector, credit: AccountSelector}}
*/
static initialize() {
const modals = Array.from(document.getElementsByClassName("accounting-account-selector-modal"));
static getInstances(entryEditor) {
const selectors = {}
const modals = Array.from(document.getElementsByClassName("accounting-account-selector"));
for (const modal of modals) {
const selector = new AccountSelector(modal);
this.#selectors[selector.#entryType] = selector;
selectors[modal.dataset.entryType] = new AccountSelector(entryEditor, modal.dataset.entryType);
}
this.#initializeTransactionForm();
}
/**
* Initializes the transaction form.
*
*/
static #initializeTransactionForm() {
const entryForm = document.getElementById("accounting-entry-form");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
formAccountControl.onclick = () => this.#selectors[entryForm.dataset.entryType].initShow();
}
/**
* Initializes the account selector for the journal entry form.
*x
*/
static initializeJournalEntryForm() {
const entryForm = document.getElementById("accounting-entry-form");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
formAccountControl.dataset.bsTarget = "#accounting-account-selector-" + entryForm.dataset.entryType + "-modal";
return selectors;
}
}

View File

@ -20,155 +20,155 @@
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/6
*/
"use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
document.getElementById("accounting-code")
.onchange = validateCode;
document.getElementById("accounting-name")
.onchange = validateName;
document.getElementById("accounting-form")
.onsubmit = validateForm;
CurrencyForm.initialize();
});
/**
* The asynchronous validation result
* @type {object}
* The currency form.
*
* @private
*/
let isAsyncValid = {};
class CurrencyForm {
/**
* The form.
* @type {HTMLFormElement}
*/
#formElement;
/**
* The code
* @type {HTMLInputElement}
*/
#code;
/**
* The error message of the code
* @type {HTMLDivElement}
*/
#codeError;
/**
* The name
* @type {HTMLInputElement}
*/
#name;
/**
* The error message of the name
* @type {HTMLDivElement}
*/
#nameError;
/**
* Constructs the currency form.
*
*/
constructor() {
this.#formElement = document.getElementById("accounting-form");
this.#code = document.getElementById("accounting-code");
this.#codeError = document.getElementById("accounting-code-error");
this.#name = document.getElementById("accounting-name");
this.#nameError = document.getElementById("accounting-name-error");
this.#code.onchange = () => {
this.#validateCode().then();
};
this.#name.onchange = () => {
this.#validateName();
};
this.#formElement.onsubmit = () => {
this.#validateForm().then((isValid) => {
if (isValid) {
this.#formElement.submit();
}
});
return false;
};
}
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
* @private
* @returns {Promise<boolean>} true if valid, or false otherwise
*/
function validateForm() {
isAsyncValid = {
"code": false,
"_sync": false,
};
async #validateForm() {
let isValid = true;
isValid = validateCode() && isValid;
isValid = validateName() && isValid;
isAsyncValid["_sync"] = isValid;
submitFormIfAllAsyncValid();
return false;
}
/**
* Submits the form if the whole form passed the asynchronous
* validations.
*
* @private
*/
function submitFormIfAllAsyncValid() {
let isValid = true;
for (const key of Object.keys(isAsyncValid)) {
isValid = isAsyncValid[key] && isValid;
}
if (isValid) {
document.getElementById("accounting-form").submit()
}
isValid = await this.#validateCode() && isValid;
isValid = this.#validateName() && isValid;
return isValid;
}
/**
* Validates the code.
*
* @param changeEvent {Event} the change event, if invoked from onchange
* @returns {boolean} true if valid, or false otherwise
* @private
* @returns {Promise<boolean>} true if valid, or false otherwise
*/
function validateCode(changeEvent = null) {
const key = "code";
const isSubmission = changeEvent === null;
let hasAsyncValidation = false;
const field = document.getElementById("accounting-code");
const error = document.getElementById("accounting-code-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the code.");
async #validateCode(changeEvent = null) {
this.#code.value = this.#code.value.trim();
if (this.#code.value === "") {
this.#code.classList.add("is-invalid");
this.#codeError.innerText = A_("Please fill in the code.");
return false;
}
const blocklist = JSON.parse(field.dataset.blocklist);
if (blocklist.includes(field.value)) {
field.classList.add("is-invalid");
error.innerText = A_("This code is not available.");
const blocklist = JSON.parse(this.#code.dataset.blocklist);
if (blocklist.includes(this.#code.value)) {
this.#code.classList.add("is-invalid");
this.#codeError.innerText = A_("This code is not available.");
return false;
}
if (!field.value.match(/^[A-Z]{3}$/)) {
field.classList.add("is-invalid");
error.innerText = A_("Code can only be composed of 3 upper-cased letters.");
if (!this.#code.value.match(/^[A-Z]{3}$/)) {
this.#code.classList.add("is-invalid");
this.#codeError.innerText = A_("Code can only be composed of 3 upper-cased letters.");
return false;
}
const original = field.dataset.original;
if (original === "" || field.value !== original) {
hasAsyncValidation = true;
validateAsyncCodeIsDuplicated(isSubmission, key);
const original = this.#code.dataset.original;
if (original === "" || this.#code.value !== original) {
const response = await fetch(this.#code.dataset.existsUrl + "?q=" + encodeURIComponent(this.#code.value));
const data = await response.json();
if (data["exists"]) {
this.#code.classList.add("is-invalid");
this.#codeError.innerText = A_("Code conflicts with another currency.");
return false;
}
if (!hasAsyncValidation) {
isAsyncValid[key] = true;
field.classList.remove("is-invalid");
error.innerText = "";
}
this.#code.classList.remove("is-invalid");
this.#codeError.innerText = "";
return true;
}
/**
* Validates asynchronously whether the code is duplicated.
* The boolean validation result is stored in isAsyncValid[key].
*
* @param isSubmission {boolean} whether this is invoked from a form submission
* @param key {string} the key to store the result in isAsyncValid
* @private
*/
function validateAsyncCodeIsDuplicated(isSubmission, key) {
const field = document.getElementById("accounting-code");
const error = document.getElementById("accounting-code-error");
const url = field.dataset.existsUrl;
const onLoad = function () {
if (this.status === 200) {
const result = JSON.parse(this.responseText);
if (result["exists"]) {
field.classList.add("is-invalid");
error.innerText = A_("Code conflicts with another currency.");
if (isSubmission) {
isAsyncValid[key] = false;
}
return;
}
field.classList.remove("is-invalid");
error.innerText = "";
if (isSubmission) {
isAsyncValid[key] = true;
submitFormIfAllAsyncValid();
}
}
};
const request = new XMLHttpRequest();
request.onload = onLoad;
request.open("GET", url + "?q=" + encodeURIComponent(field.value));
request.send();
}
/**
* Validates the name.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateName() {
const field = document.getElementById("accounting-name");
const error = document.getElementById("accounting-name-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the name.");
#validateName() {
this.#name.value = this.#name.value.trim();
if (this.#name.value === "") {
this.#name.classList.add("is-invalid");
this.#nameError.innerText = A_("Please fill in the name.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
this.#name.classList.remove("is-invalid");
this.#nameError.innerText = "";
return true;
}
/**
* The form
* @type {CurrencyForm}
*/
static #form;
/**
* Initializes the currency form.
*
*/
static initialize() {
this.#form = new CurrencyForm();
}
}

View File

@ -20,6 +20,7 @@
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/3
*/
"use strict";
/**
* Initializes the drag-and-drop reordering on a list.

View File

@ -0,0 +1,596 @@
/* The Mia! Accounting Flask Project
* journal-entry-editor.js: The JavaScript for the journal entry editor
*/
/* Copyright (c) 2023 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/25
*/
"use strict";
/**
* The journal entry editor.
*
*/
class JournalEntryEditor {
/**
* The transaction form
* @type {TransactionForm}
*/
form;
/**
* The journal entry editor
* @type {HTMLFormElement}
*/
#element;
/**
* The bootstrap modal
* @type {HTMLDivElement}
*/
#modal;
/**
* The entry type, either "debit" or "credit"
* @type {string}
*/
entryType;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix = "accounting-entry-editor"
/**
* The container of the original entry
* @type {HTMLDivElement}
*/
#originalEntryContainer;
/**
* The control of the original entry
* @type {HTMLDivElement}
*/
#originalEntryControl;
/**
* The original entry
* @type {HTMLDivElement}
*/
#originalEntry;
/**
* The error message of the original entry
* @type {HTMLDivElement}
*/
#originalEntryError;
/**
* The delete button of the original entry
* @type {HTMLButtonElement}
*/
#originalEntryDelete;
/**
* The control of the summary
* @type {HTMLDivElement}
*/
#summaryControl;
/**
* The summary
* @type {HTMLDivElement}
*/
#summary;
/**
* The error message of the summary
* @type {HTMLDivElement}
*/
#summaryError;
/**
* The control of the account
* @type {HTMLDivElement}
*/
#accountControl;
/**
* The account
* @type {HTMLDivElement}
*/
#account;
/**
* The error message of the account
* @type {HTMLDivElement}
*/
#accountError;
/**
* The amount
* @type {HTMLInputElement}
*/
#amount;
/**
* The error message of the amount
* @type {HTMLDivElement}
*/
#amountError;
/**
* The journal entry to edit
* @type {JournalEntrySubForm|null}
*/
entry;
/**
* The debit or credit entry side sub-form
* @type {DebitCreditSideSubForm}
*/
#side;
/**
* Whether the journal entry needs offset
* @type {boolean}
*/
isNeedOffset = false;
/**
* The ID of the original entry
* @type {string|null}
*/
originalEntryId = null;
/**
* The date of the original entry
* @type {string|null}
*/
originalEntryDate = null;
/**
* The text of the original entry
* @type {string|null}
*/
originalEntryText = null;
/**
* The account code
* @type {string|null}
*/
accountCode = null;
/**
* The account text
* @type {string|null}
*/
accountText = null;
/**
* The summary
* @type {string|null}
*/
summary = null;
/**
* The amount
* @type {string}
*/
amount = "";
/**
* The summary editors
* @type {{debit: SummaryEditor, credit: SummaryEditor}}
*/
#summaryEditors;
/**
* The account selectors
* @type {{debit: AccountSelector, credit: AccountSelector}}
*/
#accountSelectors;
/**
* The original entry selector
* @type {OriginalEntrySelector}
*/
originalEntrySelector;
/**
* Constructs a new journal entry editor.
*
* @param form {TransactionForm} the transaction form
*/
constructor(form) {
this.form = form;
this.#element = document.getElementById(this.#prefix);
this.#modal = document.getElementById(this.#prefix + "-modal");
this.#originalEntryContainer = document.getElementById(this.#prefix + "-original-entry-container");
this.#originalEntryControl = document.getElementById(this.#prefix + "-original-entry-control");
this.#originalEntry = document.getElementById(this.#prefix + "-original-entry");
this.#originalEntryError = document.getElementById(this.#prefix + "-original-entry-error");
this.#originalEntryDelete = document.getElementById(this.#prefix + "-original-entry-delete");
this.#summaryControl = document.getElementById(this.#prefix + "-summary-control");
this.#summary = document.getElementById(this.#prefix + "-summary");
this.#summaryError = document.getElementById(this.#prefix + "-summary-error");
this.#accountControl = document.getElementById(this.#prefix + "-account-control");
this.#account = document.getElementById(this.#prefix + "-account");
this.#accountError = document.getElementById(this.#prefix + "-account-error")
this.#amount = document.getElementById(this.#prefix + "-amount");
this.#amountError = document.getElementById(this.#prefix + "-amount-error");
this.#summaryEditors = SummaryEditor.getInstances(this);
this.#accountSelectors = AccountSelector.getInstances(this);
this.originalEntrySelector = new OriginalEntrySelector();
this.#originalEntryControl.onclick = () => this.originalEntrySelector.onOpen(this, this.originalEntryId)
this.#originalEntryDelete.onclick = () => this.clearOriginalEntry();
this.#summaryControl.onclick = () => this.#summaryEditors[this.entryType].onOpen();
this.#accountControl.onclick = () => this.#accountSelectors[this.entryType].onOpen();
this.#amount.onchange = () => this.#validateAmount();
this.#element.onsubmit = () => {
if (this.#validate()) {
if (this.entry === null) {
this.entry = this.#side.addJournalEntry();
}
this.amount = this.#amount.value;
this.entry.save(this);
bootstrap.Modal.getInstance(this.#modal).hide();
}
return false;
};
}
/**
* Saves the original entry from the original entry selector.
*
* @param originalEntry {OriginalEntry} the original entry
*/
saveOriginalEntry(originalEntry) {
this.isNeedOffset = false;
this.#originalEntryContainer.classList.remove("d-none");
this.#originalEntryControl.classList.add("accounting-not-empty");
this.originalEntryId = originalEntry.id;
this.originalEntryDate = originalEntry.date;
this.originalEntryText = originalEntry.text;
this.#originalEntry.innerText = originalEntry.text;
this.#setEnableSummaryAccount(false);
if (originalEntry.summary === "") {
this.#summaryControl.classList.remove("accounting-not-empty");
} else {
this.#summaryControl.classList.add("accounting-not-empty");
}
this.summary = originalEntry.summary === ""? null: originalEntry.summary;
this.#summary.innerText = originalEntry.summary;
this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = originalEntry.accountCode;
this.accountText = originalEntry.accountText;
this.#account.innerText = originalEntry.accountText;
this.#amount.value = String(originalEntry.netBalance);
this.#amount.max = String(originalEntry.netBalance);
this.#amount.min = "0";
this.#validate();
}
/**
* Clears the original entry.
*
*/
clearOriginalEntry() {
this.isNeedOffset = false;
this.#originalEntryContainer.classList.add("d-none");
this.#originalEntryControl.classList.remove("accounting-not-empty");
this.originalEntryId = null;
this.originalEntryDate = null;
this.originalEntryText = null;
this.#originalEntry.innerText = "";
this.#setEnableSummaryAccount(true);
this.#accountControl.classList.remove("accounting-not-empty");
this.accountCode = null;
this.accountText = null;
this.#account.innerText = "";
this.#amount.max = "";
}
/**
* Returns the currency code.
*
* @return {string} the currency code
*/
getCurrencyCode() {
return this.#side.currency.getCurrencyCode();
}
/**
* Saves the summary from the summary editor.
*
* @param summary {string} the summary
*/
saveSummary(summary) {
if (summary === "") {
this.#summaryControl.classList.remove("accounting-not-empty");
} else {
this.#summaryControl.classList.add("accounting-not-empty");
}
this.summary = summary === ""? null: summary;
this.#summary.innerText = summary;
this.#validateSummary();
bootstrap.Modal.getOrCreateInstance(this.#modal).show();
}
/**
* Saves the summary with the suggested account from the summary editor.
*
* @param summary {string} the summary
* @param accountCode {string} the account code
* @param accountText {string} the account text
* @param isAccountNeedOffset {boolean} true if the journal entries in the account need offset, or false otherwise
*/
saveSummaryWithAccount(summary, accountCode, accountText, isAccountNeedOffset) {
this.isNeedOffset = isAccountNeedOffset;
this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = accountCode;
this.accountText = accountText;
this.#account.innerText = accountText;
this.#validateAccount();
this.saveSummary(summary)
}
/**
* Clears the account.
*
*/
clearAccount() {
this.isNeedOffset = false;
this.#accountControl.classList.remove("accounting-not-empty");
this.accountCode = null;
this.accountText = null;
this.#account.innerText = "";
this.#validateAccount();
}
/**
* Sets the account.
*
* @param code {string} the account code
* @param text {string} the account text
* @param isNeedOffset {boolean} true if the journal entries in the account need offset or false otherwise
*/
saveAccount(code, text, isNeedOffset) {
this.isNeedOffset = isNeedOffset;
this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = code;
this.accountText = text;
this.#account.innerText = text;
this.#validateAccount();
}
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validate() {
let isValid = true;
isValid = this.#validateOriginalEntry() && isValid;
isValid = this.#validateSummary() && isValid;
isValid = this.#validateAccount() && isValid;
isValid = this.#validateAmount() && isValid
return isValid;
}
/**
* Validates the original entry.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
#validateOriginalEntry() {
this.#originalEntryControl.classList.remove("is-invalid");
this.#originalEntryError.innerText = "";
return true;
}
/**
* Validates the summary.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
#validateSummary() {
this.#summary.classList.remove("is-invalid");
this.#summaryError.innerText = "";
return true;
}
/**
* Validates the account.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateAccount() {
if (this.accountCode === null) {
this.#accountControl.classList.add("is-invalid");
this.#accountError.innerText = A_("Please select the account.");
return false;
}
this.#accountControl.classList.remove("is-invalid");
this.#accountError.innerText = "";
return true;
}
/**
* Validates the amount.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
#validateAmount() {
this.#amount.value = this.#amount.value.trim();
this.#amount.classList.remove("is-invalid");
if (this.#amount.value === "") {
this.#amount.classList.add("is-invalid");
this.#amountError.innerText = A_("Please fill in the amount.");
return false;
}
const amount =new Decimal(this.#amount.value);
if (amount.lessThanOrEqualTo(0)) {
this.#amount.classList.add("is-invalid");
this.#amountError.innerText = A_("Please fill in a positive amount.");
return false;
}
if (this.#amount.max !== "") {
if (amount.greaterThan(new Decimal(this.#amount.max))) {
this.#amount.classList.add("is-invalid");
this.#amountError.innerText = A_("The amount must not exceed the net balance %(balance)s of the original entry.", {balance: new Decimal(this.#amount.max)});
return false;
}
}
if (this.#amount.min !== "") {
const min = new Decimal(this.#amount.min);
if (amount.lessThan(min)) {
this.#amount.classList.add("is-invalid");
this.#amountError.innerText = A_("The amount must not be less than the offset total %(total)s.", {total: formatDecimal(min)});
return false;
}
}
this.#amount.classList.remove("is-invalid");
this.#amountError.innerText = "";
return true;
}
/**
* The callback when adding a new journal entry.
*
* @param side {DebitCreditSideSubForm} the debit or credit side sub-form
*/
onAddNew(side) {
this.entry = null;
this.#side = side;
this.entryType = this.#side.entryType;
this.isNeedOffset = false;
this.#originalEntryContainer.classList.add("d-none");
this.#originalEntryControl.classList.remove("accounting-not-empty");
this.#originalEntryControl.classList.remove("is-invalid");
this.originalEntryId = null;
this.originalEntryDate = null;
this.originalEntryText = null;
this.#originalEntry.innerText = "";
this.#setEnableSummaryAccount(true);
this.#summaryControl.classList.remove("accounting-not-empty");
this.#summaryControl.classList.remove("is-invalid");
this.summary = null;
this.#summary.innerText = ""
this.#summaryError.innerText = ""
this.#accountControl.classList.remove("accounting-not-empty");
this.#accountControl.classList.remove("is-invalid");
this.accountCode = null;
this.accountText = null;
this.#account.innerText = "";
this.#accountError.innerText = "";
this.#amount.value = "";
this.#amount.max = "";
this.#amount.min = "0";
this.#amount.classList.remove("is-invalid");
this.#amountError.innerText = "";
}
/**
* The callback when editing a journal entry.
*
* @param entry {JournalEntrySubForm} the journal entry sub-form
*/
onEdit(entry) {
this.entry = entry;
this.#side = entry.side;
this.entryType = this.#side.entryType;
this.isNeedOffset = entry.isNeedOffset();
this.originalEntryId = entry.getOriginalEntryId();
this.originalEntryDate = entry.getOriginalEntryDate();
this.originalEntryText = entry.getOriginalEntryText();
this.#originalEntry.innerText = this.originalEntryText;
if (this.originalEntryId === null) {
this.#originalEntryContainer.classList.add("d-none");
this.#originalEntryControl.classList.remove("accounting-not-empty");
} else {
this.#originalEntryContainer.classList.remove("d-none");
this.#originalEntryControl.classList.add("accounting-not-empty");
}
this.#setEnableSummaryAccount(!entry.isMatched && this.originalEntryId === null);
this.summary = entry.getSummary();
if (this.summary === null) {
this.#summaryControl.classList.remove("accounting-not-empty");
} else {
this.#summaryControl.classList.add("accounting-not-empty");
}
this.#summary.innerText = this.summary === null? "": this.summary;
if (entry.getAccountCode() === null) {
this.#accountControl.classList.remove("accounting-not-empty");
} else {
this.#accountControl.classList.add("accounting-not-empty");
}
this.accountCode = entry.getAccountCode();
this.accountText = entry.getAccountText();
this.#account.innerText = this.accountText;
this.#amount.value = entry.getAmount() === null? "": String(entry.getAmount());
const maxAmount = this.#getMaxAmount();
this.#amount.max = maxAmount === null? "": maxAmount;
this.#amount.min = entry.getAmountMin() === null? "": String(entry.getAmountMin());
this.#validate();
}
/**
* Finds out the max amount.
*
* @return {Decimal|null} the max amount
*/
#getMaxAmount() {
if (this.originalEntryId === null) {
return null;
}
return this.originalEntrySelector.getNetBalance(this.entry, this.form, this.originalEntryId);
}
/**
* Sets the enable status of the summary and account.
*
* @param isEnabled {boolean} true to enable, or false otherwise
*/
#setEnableSummaryAccount(isEnabled) {
if (isEnabled) {
this.#summaryControl.dataset.bsToggle = "modal";
this.#summaryControl.dataset.bsTarget = "#accounting-summary-editor-" + this.#side.entryType + "-modal";
this.#summaryControl.classList.remove("accounting-disabled");
this.#summaryControl.classList.add("accounting-clickable");
this.#accountControl.dataset.bsToggle = "modal";
this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + this.#side.entryType + "-modal";
this.#accountControl.classList.remove("accounting-disabled");
this.#accountControl.classList.add("accounting-clickable");
} else {
this.#summaryControl.dataset.bsToggle = "";
this.#summaryControl.dataset.bsTarget = "";
this.#summaryControl.classList.add("accounting-disabled");
this.#summaryControl.classList.remove("accounting-clickable");
this.#accountControl.dataset.bsToggle = "";
this.#accountControl.dataset.bsTarget = "";
this.#accountControl.classList.add("accounting-disabled");
this.#accountControl.classList.remove("accounting-clickable");
}
}
}

View File

@ -20,6 +20,7 @@
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/25
*/
"use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {

View File

@ -0,0 +1,417 @@
/* The Mia! Accounting Flask Project
* original-entry-selector.js: The JavaScript for the original entry 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/3/10
*/
"use strict";
/**
* The original entry selector.
*
*/
class OriginalEntrySelector {
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix = "accounting-original-entry-selector";
/**
* The modal of the original entry editor
* @type {HTMLDivElement}
*/
#modal;
/**
* 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 {OriginalEntry[]}
*/
#options;
/**
* The options by their ID
* @type {Object.<string, OriginalEntry>}
*/
#optionById;
/**
* The journal entry editor
* @type {JournalEntryEditor}
*/
entryEditor;
/**
* The currency code
* @type {string}
*/
#currencyCode;
/**
* The entry
*/
#entryType;
/**
* Constructs an original entry selector.
*
*/
constructor() {
this.#modal = document.getElementById(this.#prefix + "-modal");
this.#query = document.getElementById(this.#prefix + "-query");
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
this.#optionList = document.getElementById(this.#prefix + "-option-list");
this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option")).map((element) => new OriginalEntry(this, element));
this.#optionById = {};
for (const option of this.#options) {
this.#optionById[option.id] = option;
}
this.#query.addEventListener("input", () => {
this.#filterOptions();
});
}
/**
* Returns the net balance for an original entry.
*
* @param currentEntry {JournalEntrySubForm} the journal entry sub-form that is currently editing
* @param form {TransactionForm} the transaction form
* @param originalEntryId {string} the ID of the original entry
* @return {Decimal} the net balance of the original entry
*/
getNetBalance(currentEntry, form, originalEntryId) {
const otherEntries = form.getEntries().filter((entry) => entry !== currentEntry);
let otherOffset = new Decimal(0);
for (const otherEntry of otherEntries) {
if (otherEntry.getOriginalEntryId() === originalEntryId) {
const amount = otherEntry.getAmount();
if (amount !== null) {
otherOffset = otherOffset.plus(amount);
}
}
}
return this.#optionById[originalEntryId].bareNetBalance.minus(otherOffset);
}
/**
* Updates the net balances, subtracting the offset amounts on the form but the currently editing journal entry
*
*/
#updateNetBalances() {
const otherEntries = this.entryEditor.form.getEntries().filter((entry) => entry !== this.entryEditor.entry);
const otherOffsets = {}
for (const otherEntry of otherEntries) {
const otherOriginalEntryId = otherEntry.getOriginalEntryId();
const amount = otherEntry.getAmount();
if (otherOriginalEntryId === null || amount === null) {
continue;
}
if (!(otherOriginalEntryId in otherOffsets)) {
otherOffsets[otherOriginalEntryId] = new Decimal("0");
}
otherOffsets[otherOriginalEntryId] = otherOffsets[otherOriginalEntryId].plus(amount);
}
for (const option of this.#options) {
if (option.id in otherOffsets) {
option.updateNetBalance(otherOffsets[option.id]);
} else {
option.resetNetBalance();
}
}
}
/**
* Filters the options.
*
*/
#filterOptions() {
let hasAnyMatched = false;
for (const option of this.#options) {
if (option.isMatched(this.#entryType, this.#currencyCode, this.#query.value)) {
option.setShown(true);
hasAnyMatched = true;
} else {
option.setShown(false);
}
}
if (!hasAnyMatched) {
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 original entry selector is shown.
*
* @param entryEditor {JournalEntryEditor} the journal entry editor
* @param originalEntryId {string|null} the ID of the original entry
*/
onOpen(entryEditor, originalEntryId = null) {
this.entryEditor = entryEditor
this.#currencyCode = entryEditor.getCurrencyCode();
this.#entryType = entryEditor.entryType;
for (const option of this.#options) {
option.setActive(option.id === originalEntryId);
}
this.#query.value = "";
this.#updateNetBalances();
this.#filterOptions();
}
}
/**
* An original entry.
*
*/
class OriginalEntry {
/**
* The original entry selector
* @type {OriginalEntrySelector}
*/
#selector;
/**
* The element
* @type {HTMLLIElement}
*/
#element;
/**
* The ID
* @type {string}
*/
id;
/**
* The date
* @type {string}
*/
date;
/**
* The entry type, either "debit" or "credit"
* @type {string}
*/
#entryType;
/**
* The currency code
* @type {string}
*/
#currencyCode;
/**
* The account code
* @type {string}
*/
accountCode;
/**
* The account text
* @type {string}
*/
accountText;
/**
* The summary
* @type {string}
*/
summary;
/**
* The net balance, without the offset amounts on the form
* @type {Decimal}
*/
bareNetBalance;
/**
* The net balance
* @type {Decimal}
*/
netBalance;
/**
* The text of the net balance
* @type {HTMLSpanElement}
*/
netBalanceText;
/**
* The text representation of the original entry
* @type {string}
*/
text;
/**
* The values to query against
* @type {string[][]}
*/
#queryValues;
/**
* Constructs an original entry.
*
* @param selector {OriginalEntrySelector} the original entry selector
* @param element {HTMLLIElement} the element
*/
constructor(selector, element) {
this.#selector = selector;
this.#element = element;
this.id = element.dataset.id;
this.date = element.dataset.date;
this.#entryType = element.dataset.entryType;
this.#currencyCode = element.dataset.currencyCode;
this.accountCode = element.dataset.accountCode;
this.accountText = element.dataset.accountText;
this.summary = element.dataset.summary;
this.bareNetBalance = new Decimal(element.dataset.netBalance);
this.netBalance = this.bareNetBalance;
this.netBalanceText = document.getElementById("accounting-original-entry-selector-option-" + this.id + "-net-balance");
this.text = element.dataset.text;
this.#queryValues = JSON.parse(element.dataset.queryValues);
this.#element.onclick = () => this.#selector.entryEditor.saveOriginalEntry(this);
}
/**
* Resets the net balance to its initial value, without the offset amounts on the form.
*
*/
resetNetBalance() {
if (this.netBalance !== this.bareNetBalance) {
this.netBalance = this.bareNetBalance;
this.#updateNetBalanceText();
}
}
/**
* Updates the net balance with an offset.
*
* @param offset {Decimal} the offset to be added to the net balance
*/
updateNetBalance(offset) {
this.netBalance = this.bareNetBalance.minus(offset);
this.#updateNetBalanceText();
}
/**
* Updates the text display of the net balance.
*
*/
#updateNetBalanceText() {
this.netBalanceText.innerText = formatDecimal(this.netBalance);
}
/**
* Returns whether the original matches.
*
* @param entryType {string} the entry type, either "debit" or "credit"
* @param currencyCode {string} the currency code
* @param query {string|null} the query term
*/
isMatched(entryType, currencyCode, query = null) {
return this.netBalance.greaterThan(0)
&& this.date <= this.#selector.entryEditor.form.getDate()
&& this.#isEntryTypeMatches(entryType)
&& this.#currencyCode === currencyCode
&& this.#isQueryMatches(query);
}
/**
* Returns whether the original entry matches the entry type.
*
* @param entryType {string} the entry type, either "debit" or credit
* @return {boolean} true if the option matches, or false otherwise
*/
#isEntryTypeMatches(entryType) {
return (entryType === "debit" && this.#entryType === "credit")
|| (entryType === "credit" && this.#entryType === "debit");
}
/**
* Returns whether the original entry matches the query.
*
* @param query {string|null} the query term
* @return {boolean} true if the option matches, or false otherwise
*/
#isQueryMatches(query) {
if (query === "") {
return true;
}
for (const queryValue of this.#queryValues[0]) {
if (queryValue.toLowerCase().includes(query.toLowerCase())) {
return true;
}
}
for (const queryValue of this.#queryValues[1]) {
if (queryValue === query) {
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");
}
}
}

View File

@ -20,10 +20,11 @@
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/3/4
*/
"use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
new PeriodChooser();
PeriodChooser.initialize();
});
/**
@ -62,6 +63,20 @@ class PeriodChooser {
this.tabPlanes[tab.tabId()] = tab;
}
}
/**
* The period chooser.
* @type {PeriodChooser}
*/
static #chooser;
/**
* Initializes the period chooser.
*
*/
static initialize() {
this.#chooser = new PeriodChooser();
}
}
/**
@ -142,6 +157,12 @@ class TabPlane {
*/
class MonthTab extends TabPlane {
/**
* The month chooser.
* @type {tempusDominus.TempusDominus}
*/
#monthChooser
/**
* Constructs a tab plane.
*
@ -150,8 +171,9 @@ class MonthTab extends TabPlane {
constructor(chooser) {
super(chooser);
const monthChooser = document.getElementById(this.prefix + "-chooser");
if (monthChooser !== null) {
let start = monthChooser.dataset.start;
new tempusDominus.TempusDominus(monthChooser, {
this.#monthChooser = new tempusDominus.TempusDominus(monthChooser, {
restrictions: {
minDate: start,
},
@ -173,6 +195,7 @@ class MonthTab extends TabPlane {
.replaceAll("PERIOD", period);
});
}
}
/**
* The tab ID
@ -229,6 +252,7 @@ class DayTab extends TabPlane {
super(chooser);
this.#date = document.getElementById(this.prefix + "-date");
this.#dateError = document.getElementById(this.prefix + "-date-error");
if (this.#date !== null) {
this.#date.onchange = () => {
if (this.#validateDate()) {
window.location = chooser.modal.dataset.urlTemplate
@ -236,6 +260,7 @@ class DayTab extends TabPlane {
}
};
}
}
/**
* Validates the date.
@ -317,6 +342,7 @@ class CustomTab extends TabPlane {
this.#end = document.getElementById(this.prefix + "-end");
this.#endError = document.getElementById(this.prefix + "-end-error");
this.#conform = document.getElementById(this.prefix + "-confirm");
if (this.#start !== null) {
this.#start.onchange = () => {
if (this.#validateStart()) {
this.#end.min = this.#start.value;
@ -337,6 +363,7 @@ class CustomTab extends TabPlane {
}
};
}
}
/**
* Validates the start of the period.

View File

@ -20,11 +20,7 @@
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/28
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
SummaryEditor.initialize();
});
"use strict";
/**
* A summary editor.
@ -32,6 +28,12 @@ document.addEventListener("DOMContentLoaded", () => {
*/
class SummaryEditor {
/**
* The journal entry editor
* @type {JournalEntryEditor}
*/
#entryEditor;
/**
* The summary editor form
* @type {HTMLFormElement}
@ -54,28 +56,34 @@ class SummaryEditor {
* The entry type, either "debit" or "credit"
* @type {string}
*/
#entryType;
entryType;
/**
* The current tab.
* The current tab
* @type {TabPlane}
*/
currentTab;
/**
* The summary input.
* The summary input
* @type {HTMLInputElement}
*/
summary;
/**
* The number input.
* The button to the original entry selector
* @type {HTMLButtonElement}
*/
#offsetButton;
/**
* The number input
* @type {HTMLInputElement}
*/
number;
/**
* The note.
* The note
* @type {HTMLInputElement}
*/
note;
@ -92,36 +100,6 @@ class SummaryEditor {
*/
#selectedAccount = null;
/**
* The modal of the journal entry form
* @type {HTMLDivElement}
*/
#entryFormModal;
/**
* The control of the account on the journal entry form
* @type {HTMLDivElement}
*/
#formAccountControl;
/**
* The account on the journal entry form
* @type {HTMLDivElement}
*/
#formAccount;
/**
* The control of the summary on the journal entry form
* @type {HTMLDivElement}
*/
#formSummaryControl;
/**
* The summary on the journal entry form
* @type {HTMLDivElement}
*/
#formSummary;
/**
* The tab planes
* @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, regular: RegularPaymentTab, annotation: AnnotationTab}}
@ -131,26 +109,22 @@ class SummaryEditor {
/**
* Constructs a summary editor.
*
* @param form {HTMLFormElement} the summary editor form
* @param entryEditor {JournalEntryEditor} the journal entry editor
* @param entryType {string} the entry type, either "debit" or "credit"
*/
constructor(form) {
this.#form = form;
this.#entryType = form.dataset.entryType;
this.prefix = "accounting-summary-editor-" + form.dataset.entryType;
constructor(entryEditor, entryType) {
this.#entryEditor = entryEditor;
this.entryType = entryType;
this.prefix = "accounting-summary-editor-" + entryType;
this.#form = document.getElementById(this.prefix);
this.#modal = document.getElementById(this.prefix + "-modal");
this.summary = document.getElementById(this.prefix + "-summary");
this.#offsetButton = document.getElementById(this.prefix + "-offset");
this.number = document.getElementById(this.prefix + "-annotation-number");
this.note = document.getElementById(this.prefix + "-annotation-note");
// noinspection JSValidateTypes
this.#accountButtons = Array.from(document.getElementsByClassName(this.prefix + "-account"));
// Things from the entry form
this.#entryFormModal = document.getElementById("accounting-entry-form-modal");
this.#formAccountControl = document.getElementById("accounting-entry-form-account-control");
this.#formAccount = document.getElementById("accounting-entry-form-account");
this.#formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
this.#formSummary = document.getElementById("accounting-entry-form-summary");
for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RegularPaymentTab, AnnotationTab]) {
const tab = new cls(this);
this.tabPlanes[tab.tabId()] = tab;
@ -158,6 +132,7 @@ class SummaryEditor {
this.currentTab = this.tabPlanes.general;
this.#initializeSuggestedAccounts();
this.summary.onchange = () => this.#onSummaryChange();
this.#offsetButton.onclick = () => this.#entryEditor.originalEntrySelector.onOpen(this.#entryEditor);
this.#form.onsubmit = () => {
if (this.currentTab.validate()) {
this.#submit();
@ -238,30 +213,21 @@ class SummaryEditor {
*
*/
#submit() {
if (this.summary.value === "") {
this.#formSummaryControl.classList.remove("accounting-not-empty");
} else {
this.#formSummaryControl.classList.add("accounting-not-empty");
}
if (this.#selectedAccount !== null) {
this.#formAccountControl.classList.add("accounting-not-empty");
this.#formAccount.dataset.code = this.#selectedAccount.dataset.code;
this.#formAccount.dataset.text = this.#selectedAccount.dataset.text;
this.#formAccount.innerText = this.#selectedAccount.dataset.text;
}
this.#formSummary.dataset.value = this.summary.value;
this.#formSummary.innerText = this.summary.value;
bootstrap.Modal.getOrCreateInstance(this.#modal).hide();
bootstrap.Modal.getOrCreateInstance(this.#entryFormModal).show();
if (this.#selectedAccount !== null) {
this.#entryEditor.saveSummaryWithAccount(this.summary.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text, this.#selectedAccount.classList.contains("accounting-account-is-need-offset"));
} else {
this.#entryEditor.saveSummary(this.summary.value);
}
}
/**
* The callback when the summary editor is shown.
*
*/
#onOpen() {
onOpen() {
this.#reset();
this.summary.value = this.#formSummary.dataset.value;
this.summary.value = this.#entryEditor.summary === null? "": this.#entryEditor.summary;
this.#onSummaryChange();
}
@ -278,33 +244,18 @@ class SummaryEditor {
}
/**
* The summary editors.
* @type {{debit: SummaryEditor, credit: SummaryEditor}}
*/
static #editors = {}
/**
* Initializes the summary editors.
* Returns the summary editor instances.
*
* @param entryEditor {JournalEntryEditor} the journal entry editor
* @return {{debit: SummaryEditor, credit: SummaryEditor}}
*/
static initialize() {
static getInstances(entryEditor) {
const editors = {}
const forms = Array.from(document.getElementsByClassName("accounting-summary-editor"));
const entryForm = document.getElementById("accounting-entry-form");
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
for (const form of forms) {
const editor = new SummaryEditor(form);
this.#editors[editor.#entryType] = editor;
editors[form.dataset.entryType] = new SummaryEditor(entryEditor, form.dataset.entryType);
}
formSummaryControl.onclick = () => this.#editors[entryForm.dataset.entryType].#onOpen()
}
/**
* Initializes the summary editor for a new journal entry.
*
* @param entryType {string} the entry type, either "debit" or "credit"
*/
static initializeNewJournalEntry(entryType) {
this.#editors[entryType].#onOpen();
return editors;
}
}
@ -762,7 +713,7 @@ class GeneralTripTab extends TagTabPlane {
* @override
*/
populate() {
const found = this.editor.summary.value.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:[*×]\d+)?(?:\([^)]+\))?$/);
const found = this.editor.summary.value.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
if (found === null) {
return false;
}
@ -955,7 +906,7 @@ class BusTripTab extends TagTabPlane {
* @override
*/
populate() {
const found = this.editor.summary.value.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:[*×]\d+)?(?:\([^)]+\))?$/);
const found = this.editor.summary.value.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
if (found === null) {
return false;
}
@ -1140,7 +1091,7 @@ class AnnotationTab extends TabPlane {
* @override
*/
updateSummary() {
const found = this.editor.summary.value.match(/^(.*?)(?:[*×]\d+)?(?:\([^)]+\))?$/);
const found = this.editor.summary.value.match(/^(.*?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
if (found !== null) {
this.editor.summary.value = found[1];
}
@ -1169,7 +1120,7 @@ class AnnotationTab extends TabPlane {
* @override
*/
populate() {
const found = this.editor.summary.value.match(/^(.*?)(?:[*×](\d+))?(?:\(([^)]+)\))?$/);
const found = this.editor.summary.value.match(/^(.*?)(?:[*×](\d+))?(?:\(([^()]+)\))?$/);
this.editor.summary.value = found[1];
if (found[2] === undefined || parseInt(found[2]) === 1) {
this.editor.number.value = "";

View File

@ -1,41 +0,0 @@
/* The Mia! Accounting Flask Project
* table-row-link.js: The JavaScript for table rows as links.
*/
/* Copyright (c) 2023 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/3/4
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
initializeTableRowLinks();
});
/**
* Initializes the table rows as links.
*
* @private
*/
function initializeTableRowLinks() {
const rows = Array.from(document.getElementsByClassName("accounting-clickable accounting-table-row-link"));
for (const row of rows) {
row.onclick = () => {
window.location = row.dataset.href;
};
}
}

File diff suppressed because it is too large Load Diff

View File

@ -20,6 +20,7 @@
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/26
*/
"use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {

View File

@ -85,9 +85,9 @@ First written: 2023/1/31
<div class="accounting-card col-sm-6">
<div class="accounting-card-title">{{ obj.title }}</div>
<div class="accounting-card-code">{{ obj.code }}</div>
{% if obj.is_pay_off_needed %}
{% if obj.is_need_offset %}
<div>
<span class="badge rounded-pill bg-info">{{ A_("Pay-off needed") }}</span>
<span class="badge rounded-pill bg-info">{{ A_("Need offset") }}</span>
</div>
{% endif %}
<div class="small text-secondary fst-italic">

View File

@ -27,7 +27,7 @@ First written: 2023/2/1
{% block content %}
<div class="btn-group btn-actions mb-3">
<div class="btn-group mb-3">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
@ -41,9 +41,9 @@ First written: 2023/2/1
{% endif %}
<div class="form-floating mb-3">
<input id="accounting-base-code" type="hidden" name="base_code" value="{{ form.base_code.data|accounting_default }}">
<div id="accounting-base" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-modal">
<div id="accounting-base-control" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-modal">
<label class="form-label" for="accounting-base">{{ A_("Base account") }}</label>
<div id="accounting-base-content">
<div id="accounting-base">
{% if form.base_code.data %}
{% if form.base_code.errors %}
{{ A_("(Unknown)") }}
@ -53,7 +53,7 @@ First written: 2023/2/1
{% endif %}
</div>
</div>
<div id="accounting-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
<div id="accounting-base-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
</div>
<div class="form-floating mb-3">
@ -62,10 +62,10 @@ First written: 2023/2/1
<div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
</div>
<div class="form-check form-switch mb-3">
<input id="accounting-is-pay-off-needed" class="form-check-input" type="checkbox" name="is_pay_off_needed" value="1" {% if form.is_pay_off_needed.data %} checked="checked" {% endif %}>
<label class="form-check-label" for="accounting-is-pay-off-needed">
{{ A_("The entries in the account need pay-off.") }}
<div id="accounting-is-need-offset-control" class="form-check form-switch mb-3 {% if form.base_code.data[0] not in ["1", "2", "3"] %} d-none {% endif %}">
<input id="accounting-is-need-offset" class="form-check-input" type="checkbox" name="is_need_offset" value="1" {% if form.is_need_offset.data %} checked="checked" {% endif %}>
<label class="form-check-label" for="accounting-is-need-offset">
{{ A_("The entries in the account need offset.") }}
</label>
</div>
@ -99,21 +99,21 @@ First written: 2023/2/1
</label>
</div>
<ul id="accounting-base-option-list" class="list-group accounting-selector-list">
<ul id="accounting-base-selector-option-list" class="list-group accounting-selector-list">
{% for base in form.base_options %}
<li id="accounting-base-option-{{ base.code }}" class="list-group-item accounting-base-option accounting-clickable" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}">
<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">
{{ base }}
</li>
{% endfor %}
</ul>
<p id="accounting-base-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
<p id="accounting-base-selector-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-dismiss="modal">{{ A_("Cancel") }}</button>
{% if form.base_code.data %}
<button id="accounting-btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button>
<button id="accounting-base-selector-clear" type="button" class="btn btn-danger" data-bs-dismiss="modal">{{ A_("Clear") }}</button>
{% else %}
<button id="accounting-btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button>
<button id="accounting-base-selector-clear" type="button" class="btn btn-secondary" disabled="disabled" data-bs-dismiss="modal">{{ A_("Clear") }}</button>
{% endif %}
</div>
</div>

View File

@ -25,31 +25,19 @@ First written: 2023/1/30
{% block content %}
<div class="btn-group mb-2 d-none d-md-inline-flex">
<div class="mb-2 accounting-toolbar">
{% if accounting_can_edit() %}
<a class="btn btn-primary text-nowrap" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</a>
{% endif %}
<form class="btn btn-primary d-flex input-group accounting-search-desktop-form" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-label="{{ A_("Search for Desktop") }}">
<input id="accounting-search-desktop" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label for="accounting-search-desktop" class="accounting-search-label">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</button>
</label>
</form>
</div>
<div class="btn-group mb-2 d-md-none">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-label="{{ A_("Search for Mobile") }}">
<input id="accounting-search-mobile" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label for="accounting-search-mobile" class="accounting-search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
<span class="d-none d-md-inline">{{ A_("Search") }}</span>
</button>
</label>
</form>
@ -70,8 +58,8 @@ First written: 2023/1/30
{% for item in list %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}">
{{ item }}
{% if item.is_pay_off_needed %}
<span class="badge rounded-pill bg-info">{{ A_("Pay-off needed") }}</span>
{% if item.is_need_offset %}
<span class="badge rounded-pill bg-info">{{ A_("Need offset") }}</span>
{% endif %}
</a>
{% endfor %}

View File

@ -25,13 +25,13 @@ First written: 2023/1/26
{% block content %}
<div class="btn-group mb-2">
<form class="btn btn-primary d-flex input-group accounting-search-desktop-form" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-label="{{ A_("Search") }}">
<input id="accounting-search" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label for="accounting-search" class="accounting-search-label">
<div class="mb-2 accounting-toolbar">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
<span class="d-none d-md-inline">{{ A_("Search") }}</span>
</button>
</label>
</form>

View File

@ -27,7 +27,7 @@ First written: 2023/2/6
{% block content %}
<div class="btn-group btn-actions mb-3">
<div class="btn-group mb-3">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}

View File

@ -25,31 +25,19 @@ First written: 2023/2/6
{% block content %}
<div class="btn-group mb-2 d-none d-md-inline-flex">
<div class="mb-2 accounting-toolbar">
{% if accounting_can_edit() %}
<a class="btn btn-primary text-nowrap" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</a>
{% endif %}
<form class="btn btn-primary d-flex input-group accounting-search-desktop-form" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-label="{{ A_("Search for Desktop") }}">
<input id="accounting-search-desktop" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label for="accounting-search-desktop" class="accounting-search-label">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</button>
</label>
</form>
</div>
<div class="btn-group mb-2 d-md-none">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-label="{{ A_("Search for Mobile") }}">
<input id="accounting-search-mobile" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label for="accounting-search-mobile" class="accounting-search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
<span class="d-none d-md-inline">{{ A_("Search") }}</span>
</button>
</label>
</form>

View File

@ -28,7 +28,7 @@ First written: 2023/1/26
</span>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item {% if request.endpoint.startswith("accounting.report.") %} active {% endif %}" href="{{ url_for("accounting.report.journal-default") }}">
<a class="dropdown-item {% if request.endpoint.startswith("accounting.report.") %} active {% endif %}" href="{{ url_for("accounting.report.default") }}">
<i class="fa-solid fa-book"></i>
{{ A_("Reports") }}
</a>

View File

@ -26,129 +26,35 @@ First written: 2023/3/7
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ _("Balance Sheet of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
{% block header %}{% block title %}{{ A_("Balance Sheet of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-2 d-none d-md-inline-flex">
{% if accounting_can_edit() %}
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
{{ A_("Cash Expense") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
{{ A_("Cash Income") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</li>
</ul>
</div>
{% endif %}
{% with report_chooser = report.report_chooser %}
{% include "accounting/report/include/report-chooser.html" %}
<div class="mb-3 accounting-toolbar">
{% with use_currency_chooser = true,
use_period_chooser = true %}
{% include "accounting/report/include/toolbar-buttons.html" %}
{% endwith %}
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-money-bill-wave"></i>
{{ report.currency.name|title }}
</button>
<ul class="dropdown-menu">
{% for currency in report.currency_options %}
<li>
<a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
{{ currency.title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<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>
{{ report.period.desc|title }}
</button>
<a class="btn btn-primary" role="button" href="{{ report.csv_uri }}">
<i class="fa-solid fa-download"></i>
{{ A_("Download") }}
</a>
</div>
{% with txn_types = report.txn_types %}
{% include "accounting/include/add-txn-material-fab.html" %}
{% endwith %}
{% include "accounting/report/include/add-txn-material-fab.html" %}
<div class="btn-group btn-actions mb-3 d-md-none">
{% with report_chooser = report.report_chooser %}
{% include "accounting/report/include/report-chooser.html" %}
{% endwith %}
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-money-bill-wave"></i>
</button>
<ul class="dropdown-menu">
{% for currency in report.currency_options %}
<li>
<a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
{{ currency.title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<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>
{{ A_("Period") }}
</button>
</div>
{% with period = report.period, period_chooser = report.period_chooser %}
{% include "accounting/report/include/period-chooser.html" %}
{% endwith %}
{% include "accounting/report/include/search-modal.html" %}
{% if report.has_data %}
<div class="accounting-sheet">
<div class="d-none d-sm-flex justify-content-center mb-3">
<h2 class="text-center">{{ _("Balance Sheet of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}</h2>
<h2 class="text-center">{{ A_("Balance Sheet of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}</h2>
</div>
<div class="row accounting-report-table accounting-balance-sheet-table">
<div class="col-sm-6">
{% if report.assets.subsections %}
<div class="accounting-report-table-row accounting-balance-sheet-section">
<div>{{ report.assets.title.title|title }}</div>
</div>
<div class="accounting-report-table-body">
{% for subsection in report.assets.subsections %}
<div class="accounting-report-table-row accounting-balance-sheet-subsection">
<div>
<span class="d-none d-md-inline">{{ subsection.title.code }}</span>
{{ subsection.title.title|title }}
</div>
</div>
{% for account in subsection.accounts %}
<a class="d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-account" href="{{ account.url }}">
<div>
<span class="d-none d-md-inline">{{ account.account.code }}</span>
{{ account.account.title|title }}
</div>
<div class="accounting-amount {% if account.amount < 0 %} text-danger {% endif %}">{{ account.amount|accounting_report_format_amount }}</div>
</a>
{% endfor %}
{% endfor %}
</div>
{% with section = report.assets %}
{% include "accounting/report/include/balance-sheet-section.html" %}
{% endwith %}
<div class="d-md-none d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-total">
<div>{{ A_("Total") }}</div>
<div class="accounting-amount {% if report.assets.total < 0 %} text-danger {% endif %}">{{ report.assets.total|accounting_report_format_amount }}</div>
@ -158,28 +64,9 @@ First written: 2023/3/7
<div class="col-sm-6">
{% if report.liabilities.subsections %}
<div class="accounting-report-table-row accounting-balance-sheet-section">
<div>{{ report.liabilities.title.title|title }}</div>
</div>
<div class="accounting-report-table-body">
{% for subsection in report.liabilities.subsections %}
<div class="accounting-report-table-row accounting-balance-sheet-subsection">
<div>
<span class="d-none d-md-inline">{{ subsection.title.code }}</span>
{{ subsection.title.title|title }}
</div>
</div>
{% for account in subsection.accounts %}
<a class="d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-account" href="{{ account.url }}">
<div>
<span class="d-none d-md-inline">{{ account.account.code }}</span>
{{ account.account.title|title }}
</div>
<div class="accounting-amount {% if account.amount < 0 %} text-danger {% endif %}">{{ account.amount|accounting_report_format_amount }}</div>
</a>
{% endfor %}
{% endfor %}
</div>
{% with section = report.liabilities %}
{% include "accounting/report/include/balance-sheet-section.html" %}
{% endwith %}
<div class="d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-subtotal">
<div>{{ A_("Total") }}</div>
<div class="accounting-amount {% if report.liabilities.total < 0 %} text-danger {% endif %}">{{ report.liabilities.total|accounting_report_format_amount }}</div>
@ -187,28 +74,9 @@ First written: 2023/3/7
{% endif %}
{% if report.owner_s_equity.subsections %}
<div class="accounting-report-table-row accounting-balance-sheet-section">
<div>{{ report.owner_s_equity.title.title|title }}</div>
</div>
<div class="accounting-report-table-body">
{% for subsection in report.owner_s_equity.subsections %}
<div class="accounting-report-table-row accounting-balance-sheet-subsection">
<div>
<span class="d-none d-md-inline">{{ subsection.title.code }}</span>
{{ subsection.title.title|title }}
</div>
</div>
{% for account in subsection.accounts %}
<a class="d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-account" href="{{ account.url }}">
<div>
<span class="d-none d-md-inline">{{ account.account.code }}</span>
{{ account.account.title|title }}
</div>
<div class="accounting-amount {% if account.amount < 0 %} text-danger {% endif %}">{{ account.amount|accounting_report_format_amount }}</div>
</a>
{% endfor %}
{% endfor %}
</div>
{% with section = report.owner_s_equity %}
{% include "accounting/report/include/balance-sheet-section.html" %}
{% endwith %}
<div class="d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-subtotal">
<div>{{ A_("Total") }}</div>
<div class="accounting-amount {% if report.owner_s_equity.total < 0 %} text-danger {% endif %}">{{ report.owner_s_equity.total|accounting_report_format_amount }}</div>

View File

@ -22,13 +22,13 @@ First written: 2023/2/25
{% if accounting_can_edit() %}
<div id="accounting-material-fab-speed-dial" class="d-md-none accounting-material-fab">
<div id="accounting-material-fab-speed-dial-actions" class="d-md-none accounting-material-fab-speed-dial-group">
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=txn_types.CASH_EXPENSE)|accounting_append_next }}">
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
{{ A_("Cash expense") }}
</a>
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=txn_types.CASH_INCOME)|accounting_append_next }}">
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
{{ A_("Cash income") }}
</a>
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=txn_types.TRANSFER)|accounting_append_next }}">
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</div>

View File

@ -0,0 +1,43 @@
{#
The Mia! Accounting Flask Project
balance-sheet-section.html: A section in the balance sheet.
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/8
#}
<div class="accounting-report-table-row accounting-balance-sheet-section">
<div>{{ section.title.title|title }}</div>
</div>
<div class="accounting-report-table-body">
{% for subsection in section.subsections %}
<div class="accounting-report-table-row accounting-balance-sheet-subsection">
<div>
<span class="d-none d-md-inline">{{ subsection.title.code }}</span>
{{ subsection.title.title|title }}
</div>
</div>
{% for account in subsection.accounts %}
<a class="d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-account" href="{{ account.url }}">
<div>
<span class="d-none d-md-inline">{{ account.account.code }}</span>
{{ account.account.title|title }}
</div>
<div class="accounting-amount {% if account.amount < 0 %} text-danger {% endif %}">{{ account.amount|accounting_report_format_amount }}</div>
</a>
{% endfor %}
{% endfor %}
</div>

View File

@ -0,0 +1,27 @@
{#
The Mia! Accounting Flask Project
income-expenses-row-desktop.html: The row in the income and expenses log for the desktop computers
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/8
#}
<div>{{ entry.date|accounting_format_date }}</div>
<div>{{ entry.account.title|title }}</div>
<div>{{ entry.summary|accounting_default }}</div>
<div class="accounting-amount">{{ entry.income|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.expense|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
income-expenses-mobile-row.html: The row in the income and expenses for the mobile devices
income-expenses-row-mobile.html: The row in the income and expenses log for the mobile devices
Copyright (c) 2023 imacat.

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
ledger-row-desktop.html: The row in the ledger for the desktop computers
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/8
#}
<div>{{ entry.date|accounting_format_date }}</div>
<div>{{ entry.summary|accounting_default }}</div>
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
{% if report.account.is_real %}
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
{% endif %}

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
ledger-mobile-row.html: The row in the ledger for the mobile devices
ledger-row-mobile.html: The row in the ledger for the mobile devices
Copyright (c) 2023 imacat.
@ -37,5 +37,7 @@ First written: 2023/3/5
{% if entry.credit %}
<span class="badge rounded-pill bg-warning">-{{ entry.credit|accounting_format_amount }}</span>
{% endif %}
{% if report.account.is_real %}
<span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span>
{% endif %}
</div>

View File

@ -19,7 +19,7 @@ period-chooser.html: The period chooser
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/4
#}
<div id="accounting-period-chooser-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-period-chooser-modal-label" aria-hidden="true" data-url-template="{{ period_chooser.url_template }}">
<div id="accounting-period-chooser-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-period-chooser-modal-label" aria-hidden="true" data-url-template="{{ report.period_chooser.url_template }}">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
@ -30,62 +30,62 @@ First written: 2023/3/4
{# Tab navigation #}
<ul class="nav nav-tabs mb-2">
<li class="nav-item">
<span id="accounting-period-chooser-month-tab" class="nav-link {% if period.is_type_month %} active {% endif %} accounting-clickable" aria-current="{% if period.is_type_month %} page {% else %} false {% endif %}" data-tab-id="month">
<span id="accounting-period-chooser-month-tab" class="nav-link {% if report.period.is_type_month %} active {% endif %} accounting-clickable" aria-current="{% if report.period.is_type_month %} page {% else %} false {% endif %}" data-tab-id="month">
{{ A_("Month") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-period-chooser-year-tab" class="nav-link {% if period.is_a_year %} active {% endif %} accounting-clickable" aria-current="{% if period.is_a_year %} page {% else %} false {% endif %}" data-tab-id="year">
<span id="accounting-period-chooser-year-tab" class="nav-link {% if report.period.is_a_year %} active {% endif %} accounting-clickable" aria-current="{% if report.period.is_a_year %} page {% else %} false {% endif %}" data-tab-id="year">
{{ A_("Year") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-period-chooser-day-tab" class="nav-link {% if period.is_a_day %} active {% endif %} accounting-clickable" aria-current="{% if period.is_a_day %} page {% else %} false {% endif %}" data-tab-id="day">
<span id="accounting-period-chooser-day-tab" class="nav-link {% if report.period.is_a_day %} active {% endif %} accounting-clickable" aria-current="{% if report.period.is_a_day %} page {% else %} false {% endif %}" data-tab-id="day">
{{ A_("Day") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-period-chooser-custom-tab" class="nav-link {% if period.is_type_arbitrary %} active {% endif %} accounting-clickable" aria-current="{% if period.is_type_arbitrary %} page {% else %} false {% endif %}" data-tab-id="custom">
<span id="accounting-period-chooser-custom-tab" class="nav-link {% if report.period.is_type_arbitrary %} active {% endif %} accounting-clickable" aria-current="{% if report.period.is_type_arbitrary %} page {% else %} false {% endif %}" data-tab-id="custom">
{{ A_("Custom") }}
</span>
</li>
</ul>
{# The month periods #}
<div id="accounting-period-chooser-month-page" {% if period.is_type_month %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-month-tab">
<div id="accounting-period-chooser-month-page" {% if report.period.is_type_month %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-month-tab">
<div>
<a class="btn {% if period.is_this_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.this_month_url }}">
<a class="btn {% if report.period.is_this_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.this_month_url }}">
{{ A_("This month") }}
</a>
{% if period_chooser.has_last_month %}
<a class="btn {% if period.is_last_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.last_month_url }}">
{% if report.period_chooser.has_last_month %}
<a class="btn {% if report.period.is_last_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.last_month_url }}">
{{ A_("Last month") }}
</a>
<a class="btn {% if period.is_since_last_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.since_last_month_url }}">
<a class="btn {% if report.period.is_since_last_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.since_last_month_url }}">
{{ A_("Since last month") }}
</a>
{% endif %}
</div>
{% if period_chooser.has_data %}
<div id="accounting-period-chooser-month-chooser" class="mt-3" data-start="{{ period_chooser.data_start }}" data-default="{{ period.start }}"></div>
{% if report.period_chooser.has_data %}
<div id="accounting-period-chooser-month-chooser" class="mt-3" data-start="{{ report.period_chooser.data_start }}" data-default="{{ report.period.start|accounting_default(report.period_chooser.data_start) }}"></div>
{% endif %}
</div>
{# The year periods #}
<div id="accounting-period-chooser-year-page" {% if period.is_a_year %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-year-tab">
<a class="btn {% if period.is_this_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.this_year_url }}">
<div id="accounting-period-chooser-year-page" {% if report.period.is_a_year %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-year-tab">
<a class="btn {% if report.period.is_this_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.this_year_url }}">
{{ A_("This year") }}
</a>
{% if period_chooser.has_last_year %}
<a class="btn {% if period.is_last_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.last_year_url }}">
{% if report.period_chooser.has_last_year %}
<a class="btn {% if report.period.is_last_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.last_year_url }}">
{{ A_("Last year") }}
</a>
{% endif %}
{% if period_chooser.available_years %}
{% if report.period_chooser.available_years %}
<ul class="nav nav-pills mt-3">
{% for year in period_chooser.available_years %}
{% for year in report.period_chooser.available_years %}
<li class="nav-item">
<a class="nav-link {% if period.is_year(year) %} active {% endif %}" href="{{ period_chooser.year_url(year) }}">{{ year }}</a>
<a class="nav-link {% if report.period.is_year(year) %} active {% endif %}" href="{{ report.period_chooser.year_url(year) }}">{{ year }}</a>
</li>
{% endfor %}
</ul>
@ -93,21 +93,21 @@ First written: 2023/3/4
</div>
{# The day periods #}
<div id="accounting-period-chooser-day-page" {% if period.is_a_day %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-day-tab">
<div id="accounting-period-chooser-day-page" {% if report.period.is_a_day %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-day-tab">
<div>
<a class="btn {% if period.is_today %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.today_url }}">
<a class="btn {% if report.period.is_today %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.today_url }}">
{{ A_("Today") }}
</a>
{% if period_chooser.has_yesterday %}
<a class="btn {% if period.is_yesterday %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.yesterday_url }}">
{% if report.period_chooser.has_yesterday %}
<a class="btn {% if report.period.is_yesterday %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.yesterday_url }}">
{{ A_("Yesterday") }}
</a>
{% endif %}
</div>
{% if period_chooser.has_data %}
{% if report.period_chooser.has_data %}
<div class="mt-3">
<div class="form-floating mb-3">
<input id="accounting-period-chooser-day-date" class="form-control" type="date" value="{{ period.start|accounting_default }}" min="{{ period_chooser.data_start }}" required="required">
<input id="accounting-period-chooser-day-date" class="form-control" type="date" value="{{ report.period.start|accounting_default }}" min="{{ report.period_chooser.data_start }}" required="required">
<label for="accounting-period-chooser-day-date" class="form-label">{{ A_("Date") }}</label>
<div id="accounting-period-chooser-day-date-error" class="invalid-feedback"></div>
</div>
@ -116,22 +116,22 @@ First written: 2023/3/4
</div>
{# The custom periods #}
<div id="accounting-period-chooser-custom-page" {% if period.is_type_arbitrary %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-custom-tab">
<div id="accounting-period-chooser-custom-page" {% if report.period.is_type_arbitrary %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-custom-tab">
<div>
<a class="btn {% if period.is_all %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.all_url }}">
<a class="btn {% if report.period.is_all %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.all_url }}">
{{ A_("All") }}
</a>
</div>
{% if period_chooser.has_data %}
{% if report.period_chooser.has_data %}
<div class="mt-3">
<div class="form-floating mb-3">
<input id="accounting-period-chooser-custom-start" class="form-control" type="date" value="{{ period.start|accounting_default }}" min="{{ period_chooser.data_start }}" max="{{ period.end }}" required="required">
<input id="accounting-period-chooser-custom-start" class="form-control" type="date" value="{{ report.period.start|accounting_default }}" min="{{ report.period_chooser.data_start }}" max="{{ report.period.end }}" required="required">
<label for="accounting-period-chooser-custom-start" class="form-label">{{ A_("From") }}</label>
<div id="accounting-period-chooser-custom-start-error" class="invalid-feedback"></div>
</div>
<div class="form-floating mb-3">
<input id="accounting-period-chooser-custom-end" class="form-control" type="date" value="{{ period.end|accounting_default }}" min="{{ period.start }}" required="required">
<input id="accounting-period-chooser-custom-end" class="form-control" type="date" value="{{ report.period.end|accounting_default }}" min="{{ report.period.start }}" required="required">
<label for="accounting-period-chooser-custom-end" class="form-label">{{ A_("To") }}</label>
<div id="accounting-period-chooser-custom-end-error" class="invalid-feedback"></div>
</div>

View File

@ -1,38 +0,0 @@
{#
The Mia! Accounting Flask Project
report-chooser.html: The report chooser
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/4
#}
<div class="btn-group" role="group">
<button id="accounting-report-chooser" 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_chooser.current_report }}</span>
<span class="d-md-none">{{ A_("Report") }}</span>
</button>
<ul class="dropdown-menu" aria-labelledby="accounting-report-chooser">
{% for report in report_chooser %}
<li><a class="dropdown-item {% if report.is_active %} active {% endif %}" href="{{ report.url }}">{{ report.title }}</a></li>
{% endfor %}
<li>
<span class="dropdown-item accounting-clickable" data-bs-toggle="modal" data-bs-target="#accounting-search-modal">
{{ A_("Search") }}
</span>
</li>
</ul>
</div>

View File

@ -19,7 +19,7 @@ search-modal.html: The search modal
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/8
#}
<form action="{{ url_for("accounting.report.search") }}" method="get" role="search" aria-label="{{ A_("Search the Accounting Data") }}">
<form action="{{ url_for("accounting.report.search") }}" method="get" role="search" aria-labelledby="accounting-search-modal-label">
<div class="modal fade" id="accounting-search-modal" tabindex="-1" aria-labelledby="accounting-search-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">

View File

@ -0,0 +1,130 @@
{#
The Mia! Accounting Flask Project
toolbar-buttons.html: The toolbar buttons on the report
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/8
#}
{% if accounting_can_edit() %}
<div class="btn-group d-none d-md-flex" role="group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-plus"></i>
<span class="d-none d-md-inline">{{ A_("New") }}</span>
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
{{ A_("Cash Expense") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
{{ A_("Cash Income") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</li>
</ul>
</div>
{% endif %}
<div class="btn-group" role="group">
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-book"></i>
<span class="d-none d-md-inline">{{ report.report_chooser.current_report }}</span>
</button>
<ul class="dropdown-menu" aria-label="{{ A_("Report") }}">
{% for report in report.report_chooser %}
<li>
<a class="dropdown-item {% if report.is_active %} active {% endif %}" href="{{ report.url }}">
<i class="{{ report.fa_icon }}"></i>
{{ report.title }}
</a>
</li>
{% endfor %}
<li>
<span class="dropdown-item {% if report.report_chooser.is_search %} active {% endif %} accounting-clickable" data-bs-toggle="modal" data-bs-target="#accounting-search-modal">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</span>
</li>
</ul>
</div>
{% if use_currency_chooser %}
<div class="btn-group" role="group">
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-money-bill-wave"></i>
<span class="d-none d-md-inline">{{ report.currency.name|title }}</span>
</button>
<ul class="dropdown-menu" aria-label="{{ A_("Currency") }}">
{% for currency in report.currency_options %}
<li>
<a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
{{ currency.title }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if use_account_chooser %}
<div class="btn-group" role="group">
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-clipboard"></i>
<span class="d-none d-md-inline">{{ report.account.title|title }}</span>
</button>
<ul class="dropdown-menu" aria-label="{{ A_("Account") }}">
{% for account in report.account_options %}
<li>
<a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}">
{{ account.title|title }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if use_period_chooser %}
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-period-chooser-modal">
<i class="fa-solid fa-calendar-day"></i>
<span class="d-none d-md-inline">{{ report.period.desc|title }}</span>
</button>
{% endif %}
{% if report.has_data %}
<a class="btn btn-primary" role="button" href="{{ report.csv_uri }}">
<i class="fa-solid fa-download"></i>
<span class="d-none d-md-inline">{{ A_("Download") }}</span>
</a>
{% else %}
<button class="btn btn-secondary" type="button" disabled="disabled">
<i class="fa-solid fa-download"></i>
<span class="d-none d-md-inline">{{ A_("Download") }}</span>
</button>
{% endif %}
{% if use_search %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.report.search") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
<span class="d-none d-md-inline">{{ A_("Search") }}</span>
</button>
</label>
</form>
{% endif %}

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
income-expenses.html: The income and expenses
income-expenses.html: The income and expenses log
Copyright (c) 2023 imacat.
@ -24,127 +24,23 @@ First written: 2023/3/5
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/table-row-link.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ _("Income and Expenses of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
{% block header %}{% block title %}{{ A_("Income and Expenses Log of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-2 d-none d-md-inline-flex">
{% if accounting_can_edit() %}
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
{{ A_("Cash Expense") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
{{ A_("Cash Income") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</li>
</ul>
</div>
{% endif %}
{% with report_chooser = report.report_chooser %}
{% include "accounting/report/include/report-chooser.html" %}
<div class="mb-3 accounting-toolbar">
{% with use_currency_chooser = true,
use_account_chooser = true,
use_period_chooser = true %}
{% include "accounting/report/include/toolbar-buttons.html" %}
{% endwith %}
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-money-bill-wave"></i>
{{ report.currency.name|title }}
</button>
<ul class="dropdown-menu">
{% for currency in report.currency_options %}
<li>
<a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
{{ currency.title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-clipboard"></i>
{{ report.account.title|title }}
</button>
<ul class="dropdown-menu">
{% for account in report.account_options %}
<li>
<a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}">
{{ account.title|title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<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>
{{ report.period.desc|title }}
</button>
<a class="btn btn-primary" role="button" href="{{ report.csv_uri }}">
<i class="fa-solid fa-download"></i>
{{ A_("Download") }}
</a>
</div>
{% with txn_types = report.txn_types %}
{% include "accounting/include/add-txn-material-fab.html" %}
{% endwith %}
{% include "accounting/report/include/add-txn-material-fab.html" %}
<div class="btn-group btn-actions mb-3 d-md-none">
{% with report_chooser = report.report_chooser %}
{% include "accounting/report/include/report-chooser.html" %}
{% endwith %}
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-money-bill-wave"></i>
</button>
<ul class="dropdown-menu">
{% for currency in report.currency_options %}
<li>
<a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
{{ currency.title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-clipboard"></i>
</button>
<ul class="dropdown-menu">
{% for account in report.account_options %}
<li>
<a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}">
{{ account.title|title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<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>
{{ A_("Period") }}
</button>
</div>
{% with period = report.period, period_chooser = report.period_chooser %}
{% include "accounting/report/include/period-chooser.html" %}
{% endwith %}
{% include "accounting/report/include/search-modal.html" %}
@ -168,23 +64,13 @@ First written: 2023/3/5
{% if report.brought_forward %}
{% with entry = report.brought_forward %}
<div class="accounting-report-table-row">
<div>{{ entry.date|accounting_format_date }}</div>
<div>{{ entry.account.title|title }}</div>
<div>{{ entry.summary|accounting_default }}</div>
<div class="accounting-amount">{{ entry.income|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.expense|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
{% include "accounting/report/include/income-expenses-row-desktop.html" %}
</div>
{% endwith %}
{% endif %}
{% for entry in report.entries %}
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
<div>{{ entry.date|accounting_format_date }}</div>
<div>{{ entry.account.title|title }}</div>
<div>{{ entry.summary|accounting_default }}</div>
<div class="accounting-amount">{{ entry.income|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.expense|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
<a class="accounting-report-table-row" href="{{ entry.url|accounting_append_next }}">
{% include "accounting/report/include/income-expenses-row-desktop.html" %}
</a>
{% endfor %}
</div>
@ -206,19 +92,19 @@ First written: 2023/3/5
{% if report.brought_forward %}
{% with entry = report.brought_forward %}
<div class="list-group-item list-group-item-action d-flex justify-content-between">
{% include "accounting/report/include/income-expenses-mobile-row.html" %}
{% include "accounting/report/include/income-expenses-row-mobile.html" %}
</div>
{% endwith %}
{% endif %}
{% for entry in report.entries %}
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
{% include "accounting/report/include/income-expenses-mobile-row.html" %}
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ entry.url|accounting_append_next }}">
{% include "accounting/report/include/income-expenses-row-mobile.html" %}
</a>
{% endfor %}
{% if report.total %}
{% with entry = report.total %}
<div class="list-group-item list-group-item-action d-flex justify-content-between">
{% include "accounting/report/include/income-expenses-mobile-row.html" %}
{% include "accounting/report/include/income-expenses-row-mobile.html" %}
</div>
{% endwith %}
{% endif %}

View File

@ -26,102 +26,27 @@ First written: 2023/3/7
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ _("Income Statement of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
{% block header %}{% block title %}{{ A_("Income Statement of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-2 d-none d-md-inline-flex">
{% if accounting_can_edit() %}
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
{{ A_("Cash Expense") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
{{ A_("Cash Income") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</li>
</ul>
</div>
{% endif %}
{% with report_chooser = report.report_chooser %}
{% include "accounting/report/include/report-chooser.html" %}
<div class="mb-3 accounting-toolbar">
{% with use_currency_chooser = true,
use_period_chooser = true %}
{% include "accounting/report/include/toolbar-buttons.html" %}
{% endwith %}
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-money-bill-wave"></i>
{{ report.currency.name|title }}
</button>
<ul class="dropdown-menu">
{% for currency in report.currency_options %}
<li>
<a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
{{ currency.title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<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>
{{ report.period.desc|title }}
</button>
<a class="btn btn-primary" role="button" href="{{ report.csv_uri }}">
<i class="fa-solid fa-download"></i>
{{ A_("Download") }}
</a>
</div>
{% with txn_types = report.txn_types %}
{% include "accounting/include/add-txn-material-fab.html" %}
{% endwith %}
{% include "accounting/report/include/add-txn-material-fab.html" %}
<div class="btn-group btn-actions mb-3 d-md-none">
{% with report_chooser = report.report_chooser %}
{% include "accounting/report/include/report-chooser.html" %}
{% endwith %}
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-money-bill-wave"></i>
</button>
<ul class="dropdown-menu">
{% for currency in report.currency_options %}
<li>
<a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
{{ currency.title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<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>
{{ A_("Period") }}
</button>
</div>
{% with period = report.period, period_chooser = report.period_chooser %}
{% include "accounting/report/include/period-chooser.html" %}
{% endwith %}
{% include "accounting/report/include/search-modal.html" %}
{% if report.has_data %}
<div class="accounting-sheet">
<div class="d-none d-sm-flex justify-content-center mb-3">
<h2 class="text-center">{{ _("Income Statement of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}</h2>
<h2 class="text-center">{{ A_("Income Statement of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}</h2>
</div>
<div class="accounting-report-table accounting-income-statement-table">

View File

@ -24,69 +24,21 @@ First written: 2023/3/4
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{# <script src="{{ url_for("accounting.static", filename="js/table-row-link.js") }}"></script> #}
{% endblock %}
{% block header %}{% block title %}{{ _("Journal %(period)s", period=report.period.desc|title) }}{% endblock %}{% endblock %}
{% block header %}{% block title %}{{ A_("Journal %(period)s", period=report.period.desc|title) }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-2 d-none d-md-inline-flex">
{% if accounting_can_edit() %}
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
{{ A_("Cash Expense") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
{{ A_("Cash Income") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</li>
</ul>
</div>
{% endif %}
{% with report_chooser = report.report_chooser %}
{% include "accounting/report/include/report-chooser.html" %}
<div class="mb-3 accounting-toolbar">
{% with use_period_chooser = true %}
{% include "accounting/report/include/toolbar-buttons.html" %}
{% endwith %}
<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>
{{ report.period.desc|title }}
</button>
<a class="btn btn-primary" role="button" href="{{ report.csv_uri }}">
<i class="fa-solid fa-download"></i>
{{ A_("Download") }}
</a>
</div>
{% with txn_types = report.txn_types %}
{% include "accounting/include/add-txn-material-fab.html" %}
{% endwith %}
{% include "accounting/report/include/add-txn-material-fab.html" %}
<div class="btn-group btn-actions mb-3 d-md-none">
{% with report_chooser = report.report_chooser %}
{% include "accounting/report/include/report-chooser.html" %}
{% endwith %}
<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>
{{ A_("Period") }}
</button>
</div>
{% with period = report.period, period_chooser = report.period_chooser %}
{% include "accounting/report/include/period-chooser.html" %}
{% endwith %}
{% include "accounting/report/include/search-modal.html" %}

View File

@ -24,127 +24,23 @@ First written: 2023/3/5
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/table-row-link.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ _("Ledger of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
{% block header %}{% block title %}{{ A_("Ledger of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-2 d-none d-md-inline-flex">
{% if accounting_can_edit() %}
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
{{ A_("Cash Expense") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
{{ A_("Cash Income") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</li>
</ul>
</div>
{% endif %}
{% with report_chooser = report.report_chooser %}
{% include "accounting/report/include/report-chooser.html" %}
<div class="mb-3 accounting-toolbar">
{% with use_currency_chooser = true,
use_account_chooser = true,
use_period_chooser = true %}
{% include "accounting/report/include/toolbar-buttons.html" %}
{% endwith %}
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-money-bill-wave"></i>
{{ report.currency.name|title }}
</button>
<ul class="dropdown-menu">
{% for currency in report.currency_options %}
<li>
<a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
{{ currency.title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-clipboard"></i>
{{ report.account.title|title }}
</button>
<ul class="dropdown-menu">
{% for account in report.account_options %}
<li>
<a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}">
{{ account.title|title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<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>
{{ report.period.desc|title }}
</button>
<a class="btn btn-primary" role="button" href="{{ report.csv_uri }}">
<i class="fa-solid fa-download"></i>
{{ A_("Download") }}
</a>
</div>
{% with txn_types = report.txn_types %}
{% include "accounting/include/add-txn-material-fab.html" %}
{% endwith %}
{% include "accounting/report/include/add-txn-material-fab.html" %}
<div class="btn-group btn-actions mb-3 d-md-none">
{% with report_chooser = report.report_chooser %}
{% include "accounting/report/include/report-chooser.html" %}
{% endwith %}
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-money-bill-wave"></i>
</button>
<ul class="dropdown-menu">
{% for currency in report.currency_options %}
<li>
<a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
{{ currency.title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-clipboard"></i>
</button>
<ul class="dropdown-menu">
{% for account in report.account_options %}
<li>
<a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}">
{{ account.title|title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<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>
{{ A_("Period") }}
</button>
</div>
{% with period = report.period, period_chooser = report.period_chooser %}
{% include "accounting/report/include/period-chooser.html" %}
{% endwith %}
{% include "accounting/report/include/search-modal.html" %}
@ -153,35 +49,29 @@ First written: 2023/3/5
{% include "accounting/include/pagination.html" %}
{% endwith %}
<div class="d-none d-md-block accounting-report-table accounting-ledger-table">
<div class="d-none d-md-block accounting-report-table {% if report.account.is_real %} accounting-ledger-real-table {% else %} accounting-ledger-nominal-table {% endif %}">
<div class="accounting-report-table-header">
<div class="accounting-report-table-row">
<div>{{ A_("Date") }}</div>
<div>{{ A_("Summary") }}</div>
<div class="accounting-amount">{{ A_("Debit") }}</div>
<div class="accounting-amount">{{ A_("Credit") }}</div>
{% if report.account.is_real %}
<div class="accounting-amount">{{ A_("Balance") }}</div>
{% endif %}
</div>
</div>
<div class="accounting-report-table-body">
{% if report.brought_forward %}
{% with entry = report.brought_forward %}
<div class="accounting-report-table-row">
<div>{{ entry.date|accounting_format_date }}</div>
<div>{{ entry.summary|accounting_default }}</div>
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
{% include "accounting/report/include/ledger-row-desktop.html" %}
</div>
{% endwith %}
{% endif %}
{% for entry in report.entries %}
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
<div>{{ entry.date|accounting_format_date }}</div>
<div>{{ entry.summary|accounting_default }}</div>
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
<a class="accounting-report-table-row" href="{{ entry.url|accounting_append_next }}">
{% include "accounting/report/include/ledger-row-desktop.html" %}
</a>
{% endfor %}
</div>
@ -192,7 +82,9 @@ First written: 2023/3/5
<div>{{ A_("Total") }}</div>
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
{% if report.account.is_real %}
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
{% endif %}
</div>
</div>
{% endwith %}
@ -203,19 +95,19 @@ First written: 2023/3/5
{% if report.brought_forward %}
{% with entry = report.brought_forward %}
<div class="list-group-item list-group-item-action d-flex justify-content-between">
{% include "accounting/report/include/ledger-mobile-row.html" %}
{% include "accounting/report/include/ledger-row-mobile.html" %}
</div>
{% endwith %}
{% endif %}
{% for entry in report.entries %}
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
{% include "accounting/report/include/ledger-mobile-row.html" %}
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ entry.url|accounting_append_next }}">
{% include "accounting/report/include/ledger-row-mobile.html" %}
</a>
{% endfor %}
{% if report.total %}
{% with entry = report.total %}
<div class="list-group-item list-group-item-action d-flex justify-content-between">
{% include "accounting/report/include/ledger-mobile-row.html" %}
{% include "accounting/report/include/ledger-row-mobile.html" %}
</div>
{% endwith %}
{% endif %}

View File

@ -23,75 +23,19 @@ First written: 2023/3/8
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ A_("Search Result for \"%(query)s\"", query=request.args.q) }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-2 d-none d-md-inline-flex">
{% if accounting_can_edit() %}
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
{{ A_("Cash Expense") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
{{ A_("Cash Income") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</li>
</ul>
</div>
{% endif %}
{% with report_chooser = report.report_chooser %}
{% include "accounting/report/include/report-chooser.html" %}
<div class="mb-3 accounting-toolbar">
{% with use_search = true %}
{% include "accounting/report/include/toolbar-buttons.html" %}
{% endwith %}
<form class="btn btn-primary d-flex input-group accounting-search-desktop-form" action="{{ url_for("accounting.report.search") }}" method="get" role="search" aria-label="{{ A_("Search for Desktop") }}">
<input id="accounting-search-desktop" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label for="accounting-search-desktop" class="accounting-search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</button>
</label>
</form>
<a class="btn btn-primary" role="button" href="{{ report.csv_uri }}">
<i class="fa-solid fa-download"></i>
{{ A_("Download") }}
</a>
</div>
{% with txn_types = report.txn_types %}
{% include "accounting/include/add-txn-material-fab.html" %}
{% endwith %}
<div class="btn-group btn-actions mb-3 d-md-none">
{% with report_chooser = report.report_chooser %}
{% include "accounting/report/include/report-chooser.html" %}
{% endwith %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.report.search") }}" method="get" role="search" aria-label="{{ A_("Search for Mobile") }}">
<input id="accounting-search-mobile" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label for="accounting-search-mobile" class="accounting-search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</button>
</label>
</form>
</div>
{% include "accounting/report/include/add-txn-material-fab.html" %}
{% include "accounting/report/include/search-modal.html" %}

View File

@ -26,102 +26,27 @@ First written: 2023/3/5
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ _("Trial Balance of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
{% block header %}{% block title %}{{ A_("Trial Balance of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-2 d-none d-md-inline-flex">
{% if accounting_can_edit() %}
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
{{ A_("Cash Expense") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
{{ A_("Cash Income") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</li>
</ul>
</div>
{% endif %}
{% with report_chooser = report.report_chooser %}
{% include "accounting/report/include/report-chooser.html" %}
<div class="mb-3 accounting-toolbar">
{% with use_currency_chooser = true,
use_period_chooser = true %}
{% include "accounting/report/include/toolbar-buttons.html" %}
{% endwith %}
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-money-bill-wave"></i>
{{ report.currency.name|title }}
</button>
<ul class="dropdown-menu">
{% for currency in report.currency_options %}
<li>
<a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
{{ currency.title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<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>
{{ report.period.desc|title }}
</button>
<a class="btn btn-primary" role="button" href="{{ report.csv_uri }}">
<i class="fa-solid fa-download"></i>
{{ A_("Download") }}
</a>
</div>
{% with txn_types = report.txn_types %}
{% include "accounting/include/add-txn-material-fab.html" %}
{% endwith %}
{% include "accounting/report/include/add-txn-material-fab.html" %}
<div class="btn-group btn-actions mb-3 d-md-none">
{% with report_chooser = report.report_chooser %}
{% include "accounting/report/include/report-chooser.html" %}
{% endwith %}
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-money-bill-wave"></i>
</button>
<ul class="dropdown-menu">
{% for currency in report.currency_options %}
<li>
<a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
{{ currency.title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<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>
{{ A_("Period") }}
</button>
</div>
{% with period = report.period, period_chooser = report.period_chooser %}
{% include "accounting/report/include/period-chooser.html" %}
{% endwith %}
{% include "accounting/report/include/search-modal.html" %}
{% if report.has_data %}
<div class="accounting-sheet">
<div class="d-none d-sm-flex justify-content-center mb-3">
<h2 class="text-center">{{ _("Trial Balance of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}</h2>
<h2 class="text-center">{{ A_("Trial Balance of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}</h2>
</div>
<div class="accounting-report-table accounting-trial-balance-table">
@ -152,6 +77,7 @@ First written: 2023/3/5
</div>
</div>
</div>
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}

View File

@ -23,6 +23,6 @@ First written: 2023/2/25
{% block header %}{% block title %}{{ A_("Add a New Cash Expense Transaction") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.transaction.list") }}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}

View File

@ -35,19 +35,9 @@ First written: 2023/2/26
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li>
{% for entry in currency.debit %}
<li class="list-group-item accounting-transaction-entry">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_format_amount }}</div>
</div>
</li>
{% endfor %}
{% with entries = currency.debit %}
{% include "accounting/transaction/include/detail-entries.html" %}
{% endwith %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div>

View File

@ -19,22 +19,23 @@ currency-sub-form.html: The currency sub-form in the cash expense transaction fo
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}">
<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" class="form-select" name="currency-{{ currency_index }}-code">
<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">{{ A_("Currency") }}</label>
<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-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
<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>
@ -55,9 +56,17 @@ First written: 2023/2/25
account_text = entry_form.account_text,
summary_data = entry_form.summary.data|accounting_default,
summary_errors = entry_form.summary.errors,
original_entry_id_data = entry_form.original_entry_id.data|accounting_default,
original_entry_date = entry_form.original_entry_date|accounting_default,
original_entry_text = entry_form.original_entry_text|accounting_default,
is_need_offset = entry_form.is_need_offset,
offset_entries = entry_form.offsets,
offset_total = entry_form.offset_total|accounting_default("0"),
net_balance_data = entry_form.net_balance,
net_balance_text = entry_form.net_balance|accounting_format_amount,
amount_data = entry_form.amount.data|accounting_txn_format_amount_input,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"),
amount_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %}
@ -70,7 +79,7 @@ First written: 2023/2/25
</div>
<div>
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<button id="accounting-currency-{{ currency_index }}-debit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>

View File

@ -29,6 +29,7 @@ First written: 2023/2/25
currency_errors = currency_form.whole_form.errors,
currency_code_data = currency_form.code.data,
currency_code_errors = currency_form.code.errors,
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 %}

View File

@ -19,12 +19,12 @@ account-selector-modal.html: The modal for the account selector
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-account-selector-{{ entry_type }}-modal" class="modal fade accounting-account-selector-modal" data-entry-type="{{ entry_type }}" tabindex="-1" aria-labelledby="accounting-account-selector-{{ entry_type }}-modal-label" aria-hidden="true">
<div id="accounting-account-selector-{{ entry_type }}-modal" class="modal fade accounting-account-selector" data-entry-type="{{ entry_type }}" tabindex="-1" aria-labelledby="accounting-account-selector-{{ entry_type }}-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-account-selector-{{ entry_type }}-modal-label">{{ A_("Select Account") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
@ -37,7 +37,7 @@ First written: 2023/2/25
<ul id="accounting-account-selector-{{ entry_type }}-option-list" class="list-group accounting-selector-list">
{% for account in account_options %}
<li id="accounting-account-selector-{{ entry_type }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ entry_type }}-option {% if account.is_in_use %} accounting-account-in-use {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<li id="accounting-account-selector-{{ entry_type }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ entry_type }}-option {% if account.is_in_use %} accounting-account-in-use {% endif %} {% 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-entry-editor-modal">
{{ account }}
</li>
{% endfor %}
@ -46,8 +46,8 @@ First written: 2023/2/25
<p id="accounting-account-selector-{{ entry_type }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button>
<button id="accounting-account-selector-{{ entry_type }}-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button>
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">{{ A_("Cancel") }}</button>
<button id="accounting-account-selector-{{ entry_type }}-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">{{ A_("Clear") }}</button>
</div>
</div>
</div>

View File

@ -0,0 +1,75 @@
{#
The Mia! Accounting Flask Project
detail-entries-item: The journal entries in the transaction 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/14
#}
{# <ul> For SonarQube not to complain about incorrect HTML #}
{% for entry in entries %}
<li class="list-group-item accounting-transaction-entry">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
{% if entry.original_entry %}
<div class="fst-italic small accounting-original-entry">
<a href="{{ url_for("accounting.transaction.detail", txn=entry.original_entry.transaction)|accounting_append_next }}">
{{ A_("Offset %(entry)s", entry=entry.original_entry) }}
</a>
</div>
{% endif %}
{% if entry.is_need_offset %}
<div class="fst-italic small accounting-offset-entries">
{% if entry.offsets %}
<div class="d-flex justify-content-between">
<div>{{ A_("Offsets") }}</div>
<ul class="ms-2 ps-0">
{% for offset in entry.offsets %}
<li>
<a href="{{ url_for("accounting.transaction.detail", txn=offset.transaction)|accounting_append_next }}">
{{ offset.transaction.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% if entry.balance %}
<div class="d-flex justify-content-between">
<div>{{ A_("Net balance") }}</div>
<div>{{ entry.balance|accounting_format_amount }}</div>
</div>
{% else %}
<div class="d-flex justify-content-between">
<div>{{ A_("Fully offset") }}</div>
</div>
{% endif %}
{% else %}
<div class="d-flex justify-content-between">
{{ A_("Unmatched") }}
</div>
{% endif %}
</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_format_amount }}</div>
</div>
</li>
{% endfor %}
{# </ul> For SonarQube not to complain about incorrect HTML #}

View File

@ -26,7 +26,7 @@ First written: 2023/2/26
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.list")|accounting_or_next }}">
<a class="btn btn-primary" href="{{ url_for("accounting.report.default")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
@ -42,10 +42,17 @@ First written: 2023/2/26
</a>
{% if accounting_can_edit() %}
{% block to_transfer %}{% endblock %}
{% if obj.can_delete %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i>
{{ A_("Delete") }}
</button>
{% else %}
<button class="btn btn-secondary" type="button" disabled="disabled">
<i class="fa-solid fa-trash"></i>
{{ A_("Delete") }}
</button>
{% endif %}
{% endif %}
</div>
@ -57,7 +64,7 @@ First written: 2023/2/26
</div>
{% endif %}
{% if accounting_can_edit() %}
{% if accounting_can_edit() and obj.can_delete %}
<form action="{{ url_for("accounting.transaction.delete", txn=obj) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if request.args.next %}

View File

@ -1,60 +0,0 @@
{#
The Mia! Accounting Flask Project
entry-form-modal.html: The modal of the journal entry sub-form
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<form id="accounting-entry-form" data-currency-index="" data-entry-type="" data-entry-index="">
<div id="accounting-entry-form-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-entry-form-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-entry-form-modal-label">{{ A_("Journal Entry Content") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<div id="accounting-entry-form-account-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
<label class="form-label" for="accounting-entry-form-account">{{ A_("Account") }}</label>
<div id="accounting-entry-form-account" data-code="" data-text=""></div>
</div>
<div id="accounting-entry-form-account-error" class="invalid-feedback"></div>
</div>
<div class="mb-3">
<div id="accounting-entry-form-summary-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
<label class="form-label" for="accounting-entry-form-summary">{{ A_("Summary") }}</label>
<div id="accounting-entry-form-summary" data-value=""></div>
</div>
<div id="accounting-entry-form-summary-error" class="invalid-feedback"></div>
</div>
<div class="form-floating mb-3">
<input id="accounting-entry-form-amount" class="form-control" type="number" value="" min="0.01" max="" step="0.01" placeholder=" " required="required">
<label for="accounting-entry-form-amount">{{ A_("Amount") }}</label>
<div id="accounting-entry-form-amount-error" class="invalid-feedback"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
<button id="accounting-entry-form-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
</div>
</div>
</div>
</div>
</form>

View File

@ -20,19 +20,45 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{# <ul> For SonarQube not to complain about incorrect HTML #}
<li id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-entry accounting-currency-{{ currency_index }}-{{ entry_type }}" data-currency-index="{{ currency_index }}" data-entry-type="{{ entry_type }}" data-entry-index="{{ entry_index }}" data-prefix="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}">
<li id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-currency-{{ currency_index }}-{{ entry_type }} {% if offset_entries %} accounting-matched-entry {% endif %}" data-currency-index="{{ currency_index }}" data-entry-type="{{ entry_type }}" data-entry-index="{{ entry_index }}" {% if is_need_offset %} data-is-need-offset="true" {% endif %}>
{% if entry_id %}
<input type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-eid" value="{{ entry_id }}">
{% endif %}
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" value="{{ entry_index }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-code" class="accounting-{{ entry_type }}-account-code" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account_code" value="{{ account_code_data }}" data-text="{{ account_text }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-original-entry-id" class="accounting-original-entry-id" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-original_entry_id" value="{{ original_entry_id_data }}" data-date="{{ original_entry_date }}" data-text="{{ original_entry_text }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-code" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account_code" value="{{ account_code_data }}" data-text="{{ account_text }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" value="{{ summary_data }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" class="accounting-currency-{{ currency_index }}-{{ entry_type }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" value="{{ amount_data }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" value="{{ amount_data }}" data-min="{{ offset_total }}">
<div class="accounting-entry-content">
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-control" class="form-control clickable d-flex justify-content-between accounting-entry-control {% if entry_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-control" class="form-control clickable d-flex justify-content-between {% if entry_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
<div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-text" class="small">{{ account_text }}</div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary-text">{{ summary_data }}</div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-original-entry-text" class="fst-italic small accounting-original-entry {% if not original_entry_text %} d-none {% endif %}">
{% if original_entry_text %}{{ A_("Offset %(entry)s", entry=original_entry_text) }}{% endif %}
</div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-offsets" class="fst-italic small accounting-offset-entries {% if not is_need_offset %} d-none {% endif %}">
{% if offset_entries %}
<div class="d-flex justify-content-between {% if not offset_entries %} d-none {% endif %}">
<div>{{ A_("Offsets") }}</div>
<ul class="ms-2 ps-0">
{% for offset in offset_entries %}
<li>{{ offset.transaction.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}</li>
{% endfor %}
</ul>
</div>
{% if net_balance_data == 0 %}
<div>{{ A_("Fully offset") }}</div>
{% else %}
<div class="d-flex justify-content-between">
<div>{{ A_("Net balance") }}</div>
<div>{{ net_balance_text }}</div>
</div>
{% endif %}
{% else %}
{{ A_("Unmatched") }}
{% endif %}
</div>
</div>
<div><span id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount-text" class="badge rounded-pill bg-primary">{{ amount_text }}</span></div>
</div>
@ -40,7 +66,7 @@ First written: 2023/2/25
</div>
<div>
<button id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-btn-delete" class="btn btn-danger rounded-circle accounting-btn-delete-entry accounting-currency-{{ currency_index }}-{{ entry_type }}-btn-delete-entry {% if only_one_entry_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" data-same-class="accounting-currency-{{ currency_index }}-{{ entry_type }}-btn-delete-entry">
<button id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_entry_form or offset_entries %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}">
<i class="fas fa-minus"></i>
</button>
</div>

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