Compare commits
	
		
			323 Commits
		
	
	
		
			v0.4.0
			...
			61fd1849ed
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 61fd1849ed | |||
| a67158f8f6 | |||
| 5c6bfd8b49 | |||
| d9ecf51c6d | |||
| 5d31eb9172 | |||
| fadce244c5 | |||
| cbe7c6ca6d | |||
| b03938fb2e | |||
| 8061a23fdc | |||
| cd8a480cd0 | |||
| b8b87714eb | |||
| bf2f96621d | |||
| 2d771f04be | |||
| 3a12472d4b | |||
| d5a686a5d8 | |||
| 690f89e29a | |||
| 82a6a53dc4 | |||
| cdd31b1047 | |||
| 5bad949cfa | |||
| 3826646d06 | |||
| 74071e8997 | |||
| 3ce34803f3 | |||
| 232f73172f | |||
| ff1bb7142b | |||
| 7155bf635a | |||
| c306ff8009 | |||
| b344abce06 | |||
| b3c666c872 | |||
| 6a671cac84 | |||
| fe87c3a7de | |||
| 2013f8cbd9 | |||
| 2325842471 | |||
| c80e58b049 | |||
| be0ae5eba4 | |||
| 2b84f64554 | |||
| 0a658a76e8 | |||
| 50dc79d865 | |||
| 8e5377a416 | |||
| 4299fd6fbd | |||
| 1d6a53f7cd | |||
| bb2993b0c0 | |||
| f6946c1165 | |||
| 8e219d8006 | |||
| 53565eb9e6 | |||
| 965e78d8ad | |||
| 74b81d3e23 | |||
| a0fba6387f | |||
| d28bdf2064 | |||
| edf0c00e34 | |||
| 107d161379 | |||
| f2c184f769 | |||
| b45986ecfc | |||
| a2c2452ec5 | |||
| 5194258b48 | |||
| 3fe7eb41ac | |||
| 7fb9e2f0a1 | |||
| 1d443f7b76 | |||
| 6ad4fba9cd | |||
| 3dda6531b5 | |||
| 4d11517e21 | |||
| 308e4ac69d | |||
| de09e1498b | |||
| c26c4686c5 | |||
| c95f4fcc47 | |||
| 71af74fc8a | |||
| 56e972c371 | |||
| 7feb6da062 | |||
| af71874f9d | |||
| 3fa8818a27 | |||
| be46d8aa14 | |||
| 20f55058ac | |||
| e9d1a53e03 | |||
| 38141759fd | |||
| 7fb3e3bc2c | |||
| 05ac5158f8 | |||
| ec257a4b57 | |||
| 5ebb89a6d5 | |||
| 900d60d1ae | |||
| bc792c145f | |||
| 4432484acd | |||
| 7ad3f9e0cb | |||
| 060a52f7a2 | |||
| c17430d211 | |||
| 8fd99bb617 | |||
| ce388eb6c8 | |||
| 1850f9787e | |||
| c6d55fad1c | |||
| 0c647d8f21 | |||
| 5d1f87582e | |||
| ef086b3f81 | |||
| b4be1db712 | |||
| 5d44ebdfd8 | |||
| 9859604c81 | |||
| d31e495f6b | |||
| 7c4102be44 | |||
| 1fd50e23d9 | |||
| 9635448f18 | |||
| e7f1ca332e | |||
| 3d2e40865e | |||
| 5132141c68 | |||
| e37f6792c9 | |||
| e6b1136a14 | |||
| d7bc01ccb4 | |||
| 27beff3f8f | |||
| c6c545b99f | |||
| 6d5a2fae6a | |||
| 8819eabcd0 | |||
| 3582d960ca | |||
| 02e10a301a | |||
| f0187434d2 | |||
| 34af52e3c3 | |||
| 965df82c1c | |||
| df53f06094 | |||
| 140d3c6010 | |||
| a65dccac92 | |||
| 740e1cfac1 | |||
| b62f31d385 | |||
| 1c740b9bbc | |||
| 380256eda7 | |||
| 74b695c089 | |||
| 6d1e705e4b | |||
| 8abe20dba5 | |||
| ed7a8ac0fd | |||
| 74eee034d0 | |||
| d19d23fe37 | |||
| 4ce577d7d8 | |||
| a340fad109 | |||
| 555ad388bc | |||
| 2f27ad5bef | |||
| c6487bf9d4 | |||
| ff3dd28cd7 | |||
| a14ffa93ed | |||
| 672fcbcbdf | |||
| cb4258dd6d | |||
| 6fc21f82af | |||
| 13e3ef5875 | |||
| 21b3320e66 | |||
| 5c47e63ae3 | |||
| f59378002e | |||
| 531e90e8ad | |||
| 8fc33131dd | |||
| 62716eb545 | |||
| 14d5d1e8d6 | |||
| 4306ed739f | |||
| 1f87bc00e8 | |||
| ff9ff4bdcf | |||
| 578233d66d | |||
| 5e7f790f87 | |||
| d64f354ee0 | |||
| ba3d8c6d4e | |||
| 4f7f87b10d | |||
| 4273f99644 | |||
| ffe834bedd | |||
| e448e009c9 | |||
| b6802c51bb | |||
| 2515c1ea1f | |||
| 0ef6409f75 | |||
| ed18b81ad8 | |||
| b46cec6fab | |||
| 6c122666a0 | |||
| 7ddc9ececf | |||
| 4eebbd9692 | |||
| 338b49c965 | |||
| f438f97571 | |||
| 9b273115a0 | |||
| 58d1add810 | |||
| c189615ca4 | |||
| 5687852dfb | |||
| d74c62dbb7 | |||
| 987e98ebc0 | |||
| 7083f22577 | |||
| 7b10eb68bc | |||
| f277010991 | |||
| 729a7fd107 | |||
| c8230c949d | |||
| 3c98960efe | |||
| c5d0d91a7d | |||
| fb06e9db44 | |||
| d47e2e231b | |||
| cb89f34455 | |||
| 11ab4a4ba6 | |||
| 5dc8387ad9 | |||
| 26b70bb625 | |||
| f30a96d7e9 | |||
| a1627b7fbf | |||
| 7c3b8c8f44 | |||
| b19f4fa939 | |||
| 41c3e06ce4 | |||
| 8a3df7a689 | |||
| 196a115c99 | |||
| 005f9083aa | |||
| 12dbae56c4 | |||
| a98723c57b | |||
| d5bd3b8383 | |||
| 617dd29f23 | |||
| b0a4a735f3 | |||
| 41770e38b8 | |||
| d8a6614543 | |||
| 8d76b5130e | |||
| 43fc4b9b8d | |||
| 3ed8d7f1d2 | |||
| ea7c194d7e | |||
| 041a905fc0 | |||
| 10d1be8bd1 | |||
| 6e1d35eda4 | |||
| 52b5151fe0 | |||
| f9fc033de6 | |||
| 116d00a557 | |||
| 329e3d5362 | |||
| 47e8944f06 | |||
| e7c43ae390 | |||
| b8b51b34d3 | |||
| d083036719 | |||
| 7fe81c710b | |||
| 9993f65627 | |||
| fe01d5418d | |||
| 2f7b9932a0 | |||
| 1eed16b732 | |||
| ede1160943 | |||
| 3814f0cb18 | |||
| 24315b8203 | |||
| 3c200d0dc6 | |||
| 9f1e724875 | |||
| f838e7f893 | |||
| edb893ecd3 | |||
| 436a4c367f | |||
| 1813ce0cfa | |||
| 7683347997 | |||
| 46ffc7a73d | |||
| e0a807d625 | |||
| dffcf6d2ce | |||
| 84d239e4b1 | |||
| fcefc64117 | |||
| 81fbb380b4 | |||
| d7ac8a3dcf | |||
| bcd3418e2c | |||
| ef9e5cb5b3 | |||
| e797cfeb8c | |||
| 22bae7f766 | |||
| aa669e9f53 | |||
| 898a1af7b5 | |||
| f762bcf48f | |||
| 5d4effa360 | |||
| dd05478bf3 | |||
| 9450915404 | |||
| 8d126e183f | |||
| bfb08cf5fc | |||
| a7bcf4b5c1 | |||
| cd49ca44b1 | |||
| 734362396f | |||
| 88147bea66 | |||
| cca43c68a6 | |||
| 480e2d2d8f | |||
| be100ce7ec | |||
| eca91d32ed | |||
| 1f95212494 | |||
| 0173104c84 | |||
| 6e33fa775d | |||
| e244ff70e6 | |||
| ace782a26b | |||
| 90289a0db2 | |||
| 7e7e1a2844 | |||
| ddd028736c | |||
| e1d35a64da | |||
| 39807ef480 | |||
| 39723b1299 | |||
| 8cd004bede | |||
| 4f112dd386 | |||
| b806b1ed1f | |||
| 1d0a79e33c | |||
| d4a690ebbc | |||
| 68687897f3 | |||
| a7250fd9bf | |||
| eabe80b790 | |||
| fe77f87110 | |||
| 32c27d7c07 | |||
| 14b871b57a | |||
| 9d5fce2752 | |||
| d333151731 | |||
| b2e500a714 | |||
| b705795b44 | |||
| 250f4ff1ae | |||
| 6bed180790 | |||
| 10fbc3f638 | |||
| f65dc6fc42 | |||
| 9833bac6e4 | |||
| 7d412b20d7 | |||
| 9bfcd3c50c | |||
| 55c2ce6695 | |||
| 493677e0aa | |||
| 710c26d016 | |||
| 24415018b7 | |||
| c50b9a2000 | |||
| af9bd14eed | |||
| 9e1ff16e96 | |||
| f7c1fd77f2 | |||
| 641315537d | |||
| a895bd8560 | |||
| ca86a08f3e | |||
| e118422441 | |||
| b3777cffbf | |||
| 39c9c17007 | |||
| 3ab4eacf9f | |||
| cff3d1b6bd | |||
| f41db78831 | |||
| 73f7d14e7b | |||
| f6ed6b10a7 | |||
| b5aaee4d15 | |||
| c849d6b3d4 | |||
| a9908a7df4 | |||
| 063c769158 | |||
| f8e9871300 | |||
| 78a62a9575 | |||
| 85fde6219e | |||
| 4eb9346d8d | |||
| 11966a52ba | |||
| 8cf81b5459 | |||
| cc958a39b3 | |||
| 9065686cc5 | |||
| 9a41cb10a1 | |||
| 6957e52d0d | |||
| 9cd9e90be0 | |||
| 2839dc60b4 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -38,3 +38,4 @@ excludes
 | 
			
		||||
*.mo
 | 
			
		||||
zh_Hans
 | 
			
		||||
test_temp.py
 | 
			
		||||
dummy.js
 | 
			
		||||
 
 | 
			
		||||
@@ -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/*
 | 
			
		||||
 
 | 
			
		||||
@@ -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:
 | 
			
		||||
 
 | 
			
		||||
@@ -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:
 | 
			
		||||
 
 | 
			
		||||
@@ -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:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										69
									
								
								docs/source/accounting.report.period.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								docs/source/accounting.report.period.rst
									
									
									
									
									
										Normal 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:
 | 
			
		||||
							
								
								
									
										69
									
								
								docs/source/accounting.report.reports.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								docs/source/accounting.report.reports.rst
									
									
									
									
									
										Normal 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:
 | 
			
		||||
							
								
								
									
										47
									
								
								docs/source/accounting.report.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								docs/source/accounting.report.rst
									
									
									
									
									
										Normal 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:
 | 
			
		||||
							
								
								
									
										77
									
								
								docs/source/accounting.report.utils.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								docs/source/accounting.report.utils.rst
									
									
									
									
									
										Normal 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:
 | 
			
		||||
@@ -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
 | 
			
		||||
---------------
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -12,14 +12,6 @@ accounting.transaction.converters module
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.transaction.dispatcher module
 | 
			
		||||
----------------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.transaction.dispatcher
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.transaction.forms module
 | 
			
		||||
-----------------------------------
 | 
			
		||||
 | 
			
		||||
@@ -28,26 +20,26 @@ accounting.transaction.forms module
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.transaction.query module
 | 
			
		||||
-----------------------------------
 | 
			
		||||
accounting.transaction.operators module
 | 
			
		||||
---------------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.transaction.query
 | 
			
		||||
.. automodule:: accounting.transaction.operators
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.transaction.summary\_helper module
 | 
			
		||||
accounting.transaction.summary\_editor module
 | 
			
		||||
---------------------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.transaction.summary_helper
 | 
			
		||||
.. automodule:: accounting.transaction.summary_editor
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.transaction.template module
 | 
			
		||||
--------------------------------------
 | 
			
		||||
accounting.transaction.template\_filters module
 | 
			
		||||
-----------------------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.transaction.template
 | 
			
		||||
.. automodule:: accounting.transaction.template_filters
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 
 | 
			
		||||
@@ -60,6 +60,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
 | 
			
		||||
----------------------------
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@
 | 
			
		||||
 | 
			
		||||
[metadata]
 | 
			
		||||
name = mia-accounting-flask
 | 
			
		||||
version = 0.4.0
 | 
			
		||||
version = 0.5.0
 | 
			
		||||
author = imacat
 | 
			
		||||
author_email = imacat@mail.imacat.idv.tw
 | 
			
		||||
description = The Mia! Accounting Flask project.
 | 
			
		||||
 
 | 
			
		||||
@@ -23,7 +23,7 @@ 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 +31,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
 | 
			
		||||
@@ -58,11 +52,25 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
 | 
			
		||||
                              template_folder="templates",
 | 
			
		||||
                              static_folder="static")
 | 
			
		||||
 | 
			
		||||
    from .template_filters import format_amount, format_date, default
 | 
			
		||||
    bp.add_app_template_filter(format_amount, "accounting_format_amount")
 | 
			
		||||
    bp.add_app_template_filter(format_date, "accounting_format_date")
 | 
			
		||||
    bp.add_app_template_filter(default, "accounting_default")
 | 
			
		||||
 | 
			
		||||
    from .template_globals import currency_options, default_currency_code
 | 
			
		||||
    bp.add_app_template_global(currency_options,
 | 
			
		||||
                               "accounting_currency_options")
 | 
			
		||||
    bp.add_app_template_global(default_currency_code,
 | 
			
		||||
                               "accounting_default_currency_code")
 | 
			
		||||
 | 
			
		||||
    from . import locale
 | 
			
		||||
    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)
 | 
			
		||||
 | 
			
		||||
    from . import base_account
 | 
			
		||||
    base_account.init_app(app, bp)
 | 
			
		||||
@@ -76,7 +84,7 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
 | 
			
		||||
    from . import transaction
 | 
			
		||||
    transaction.init_app(app, bp)
 | 
			
		||||
 | 
			
		||||
    from .utils import next_uri
 | 
			
		||||
    next_uri.init_app(bp)
 | 
			
		||||
    from . import report
 | 
			
		||||
    report.init_app(app, bp)
 | 
			
		||||
 | 
			
		||||
    app.register_blueprint(bp)
 | 
			
		||||
 
 | 
			
		||||
@@ -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-offset-needed) 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_offset_needed: bool = __is_offset_needed(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_offset_needed))
 | 
			
		||||
    __add_accounting_accounts(data, creator_pk)
 | 
			
		||||
    click.echo(F"{len(data)} added.  Accounting accounts initialized.")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __is_offset_needed(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_offset_needed=x[6],
 | 
			
		||||
                                       created_by_id=creator_pk,
 | 
			
		||||
                                       updated_by_id=creator_pk)
 | 
			
		||||
                               for x in data]
 | 
			
		||||
 
 | 
			
		||||
@@ -53,6 +53,21 @@ 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:
 | 
			
		||||
        if not field.data:
 | 
			
		||||
            return
 | 
			
		||||
        if not isinstance(form, AccountForm):
 | 
			
		||||
            return
 | 
			
		||||
        if form.base_code.data is None:
 | 
			
		||||
            return
 | 
			
		||||
        if form.base_code.data[0] not in {"1", "2"}:
 | 
			
		||||
            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 +81,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_offset_needed = 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 +103,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"}:
 | 
			
		||||
            obj.is_offset_needed = self.is_offset_needed.data
 | 
			
		||||
        else:
 | 
			
		||||
            obj.is_offset_needed = False
 | 
			
		||||
        if is_new:
 | 
			
		||||
            current_user_pk: int = get_current_user_pk()
 | 
			
		||||
            obj.created_by_id = current_user_pk
 | 
			
		||||
 
 | 
			
		||||
@@ -14,7 +14,7 @@
 | 
			
		||||
#  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 account query.
 | 
			
		||||
"""The queries for the account management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
@@ -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_offset_needed)
 | 
			
		||||
        conditions.append(sa.or_(*sub_conditions))
 | 
			
		||||
 | 
			
		||||
    return Account.query.filter(*conditions)\
 | 
			
		||||
@@ -33,7 +33,7 @@ from accounting.utils.pagination import Pagination
 | 
			
		||||
from accounting.utils.permission import can_view, has_permission, can_edit
 | 
			
		||||
from accounting.utils.user import get_current_user_pk
 | 
			
		||||
from .forms import AccountForm, sort_accounts_in, AccountReorderForm
 | 
			
		||||
from .query import get_account_query
 | 
			
		||||
from .queries import get_account_query
 | 
			
		||||
 | 
			
		||||
bp: Blueprint = Blueprint("account", __name__)
 | 
			
		||||
"""The view blueprint for the account management."""
 | 
			
		||||
 
 | 
			
		||||
@@ -14,7 +14,7 @@
 | 
			
		||||
#  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 base account query.
 | 
			
		||||
"""The queries for the base account management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
@@ -34,7 +34,7 @@ def list_accounts() -> str:
 | 
			
		||||
 | 
			
		||||
    :return: The account list.
 | 
			
		||||
    """
 | 
			
		||||
    from .query import get_base_account_query
 | 
			
		||||
    from .queries import get_base_account_query
 | 
			
		||||
    accounts: list[BaseAccount] = get_base_account_query()
 | 
			
		||||
    pagination: Pagination = Pagination[BaseAccount](accounts)
 | 
			
		||||
    return render_template("accounting/base-account/list.html",
 | 
			
		||||
 
 | 
			
		||||
@@ -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,22 +28,25 @@ from accounting.utils.strip_text import strip_text
 | 
			
		||||
from accounting.utils.user import get_current_user_pk
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CodeUnique:
 | 
			
		||||
    """The validator to check if the code is unique."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data == "":
 | 
			
		||||
            return
 | 
			
		||||
        if not isinstance(form, CurrencyForm):
 | 
			
		||||
            return
 | 
			
		||||
        if form.obj_code is not None and form.obj_code == field.data:
 | 
			
		||||
            return
 | 
			
		||||
        if db.session.get(Currency, field.data) is not None:
 | 
			
		||||
            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."""
 | 
			
		||||
 | 
			
		||||
    class CodeUnique:
 | 
			
		||||
        """The validator to check if the code is unique."""
 | 
			
		||||
        def __call__(self, form: CurrencyForm, field: StringField) -> None:
 | 
			
		||||
            if field.data == "":
 | 
			
		||||
                return
 | 
			
		||||
            if form.obj_code is not None and form.obj_code == field.data:
 | 
			
		||||
                return
 | 
			
		||||
            if db.session.get(Currency, field.data) is not None:
 | 
			
		||||
                raise ValidationError(lazy_gettext(
 | 
			
		||||
                    "Code conflicts with another currency."))
 | 
			
		||||
 | 
			
		||||
    code = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[DataRequired(lazy_gettext("Please fill in the code.")),
 | 
			
		||||
 
 | 
			
		||||
@@ -14,7 +14,7 @@
 | 
			
		||||
#  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 currency query.
 | 
			
		||||
"""The queries for the currency management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
@@ -47,7 +47,7 @@ def list_currencies() -> str:
 | 
			
		||||
 | 
			
		||||
    :return: The currency list.
 | 
			
		||||
    """
 | 
			
		||||
    from .query import get_currency_query
 | 
			
		||||
    from .queries import get_currency_query
 | 
			
		||||
    currencies: list[Currency] = get_currency_query()
 | 
			
		||||
    pagination: Pagination = Pagination[Currency](currencies)
 | 
			
		||||
    return render_template("accounting/currency/list.html",
 | 
			
		||||
 
 | 
			
		||||
@@ -52,7 +52,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 +113,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_offset_needed = 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 +141,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 +153,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 +161,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:
 | 
			
		||||
@@ -271,23 +265,7 @@ class Account(db.Model):
 | 
			
		||||
 | 
			
		||||
        :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,23 +273,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)
 | 
			
		||||
        return cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def is_modified(self) -> bool:
 | 
			
		||||
@@ -392,7 +354,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 +550,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,7 +564,7 @@ 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
 | 
			
		||||
 | 
			
		||||
@@ -635,15 +597,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)
 | 
			
		||||
@@ -678,3 +640,19 @@ 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 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
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										35
									
								
								src/accounting/report/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/accounting/report/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,35 @@
 | 
			
		||||
# The Mia! Accounting Flask Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
 | 
			
		||||
 | 
			
		||||
#  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 report management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from flask import Flask, Blueprint
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def init_app(app: Flask, bp: Blueprint) -> None:
 | 
			
		||||
    """Initialize the application.
 | 
			
		||||
 | 
			
		||||
    :param app: The Flask application.
 | 
			
		||||
    :param bp: The blueprint of the accounting application.
 | 
			
		||||
    :return: None.
 | 
			
		||||
    """
 | 
			
		||||
    from .converters import PeriodConverter, IncomeExpensesAccountConverter
 | 
			
		||||
    app.url_map.converters["period"] = PeriodConverter
 | 
			
		||||
    app.url_map.converters["ieAccount"] = IncomeExpensesAccountConverter
 | 
			
		||||
 | 
			
		||||
    from .views import bp as report_bp
 | 
			
		||||
    bp.register_blueprint(report_bp, url_prefix="/reports")
 | 
			
		||||
							
								
								
									
										79
									
								
								src/accounting/report/converters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								src/accounting/report/converters.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,79 @@
 | 
			
		||||
# The Mia! Accounting Flask Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
#  You may obtain a copy of the License at
 | 
			
		||||
#
 | 
			
		||||
#      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
#
 | 
			
		||||
#  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
"""The path converters for the report management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import re
 | 
			
		||||
 | 
			
		||||
from flask import abort
 | 
			
		||||
from werkzeug.routing import BaseConverter
 | 
			
		||||
 | 
			
		||||
from accounting.models import Account
 | 
			
		||||
from .period import Period, get_period
 | 
			
		||||
from .utils.ie_account import IncomeExpensesAccount
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PeriodConverter(BaseConverter):
 | 
			
		||||
    """The supplier converter to convert the period specification from and to
 | 
			
		||||
    the corresponding period in the routes."""
 | 
			
		||||
 | 
			
		||||
    def to_python(self, value: str) -> Period:
 | 
			
		||||
        """Converts a period specification to a period.
 | 
			
		||||
 | 
			
		||||
        :param value: The period specification.
 | 
			
		||||
        :return: The corresponding period.
 | 
			
		||||
        """
 | 
			
		||||
        try:
 | 
			
		||||
            return get_period(value)
 | 
			
		||||
        except ValueError:
 | 
			
		||||
            abort(404)
 | 
			
		||||
 | 
			
		||||
    def to_url(self, value: Period) -> str:
 | 
			
		||||
        """Converts a period to its specification.
 | 
			
		||||
 | 
			
		||||
        :param value: The period.
 | 
			
		||||
        :return: Its specification.
 | 
			
		||||
        """
 | 
			
		||||
        return value.spec
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IncomeExpensesAccountConverter(BaseConverter):
 | 
			
		||||
    """The supplier converter to convert the income and expenses log pseudo
 | 
			
		||||
    account code from and to the corresponding pseudo account in the routes."""
 | 
			
		||||
 | 
			
		||||
    def to_python(self, value: str) -> IncomeExpensesAccount:
 | 
			
		||||
        """Converts an account code to an account.
 | 
			
		||||
 | 
			
		||||
        :param value: The account code.
 | 
			
		||||
        :return: The corresponding account.
 | 
			
		||||
        """
 | 
			
		||||
        if value == IncomeExpensesAccount.CURRENT_AL_CODE:
 | 
			
		||||
            return IncomeExpensesAccount.current_assets_and_liabilities()
 | 
			
		||||
        if not re.match("^[12][12]", value):
 | 
			
		||||
            abort(404)
 | 
			
		||||
        account: Account | None = Account.find_by_code(value)
 | 
			
		||||
        if account is None:
 | 
			
		||||
            abort(404)
 | 
			
		||||
        return IncomeExpensesAccount(account)
 | 
			
		||||
 | 
			
		||||
    def to_url(self, value: IncomeExpensesAccount) -> str:
 | 
			
		||||
        """Converts an account to account code.
 | 
			
		||||
 | 
			
		||||
        :param value: The account.
 | 
			
		||||
        :return: Its code.
 | 
			
		||||
        """
 | 
			
		||||
        return value.code
 | 
			
		||||
							
								
								
									
										22
									
								
								src/accounting/report/period/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/accounting/report/period/__init__.py
									
									
									
									
									
										Normal 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
 | 
			
		||||
							
								
								
									
										97
									
								
								src/accounting/report/period/chooser.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								src/accounting/report/period/chooser.py
									
									
									
									
									
										Normal 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))
 | 
			
		||||
							
								
								
									
										179
									
								
								src/accounting/report/period/description.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								src/accounting/report/period/description.py
									
									
									
									
									
										Normal 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)
 | 
			
		||||
							
								
								
									
										31
									
								
								src/accounting/report/period/month_end.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/accounting/report/period/month_end.py
									
									
									
									
									
										Normal 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)
 | 
			
		||||
							
								
								
									
										119
									
								
								src/accounting/report/period/parser.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								src/accounting/report/period/parser.py
									
									
									
									
									
										Normal 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)
 | 
			
		||||
							
								
								
									
										129
									
								
								src/accounting/report/period/period.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								src/accounting/report/period/period.py
									
									
									
									
									
										Normal 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))
 | 
			
		||||
							
								
								
									
										168
									
								
								src/accounting/report/period/shortcuts.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										168
									
								
								src/accounting/report/period/shortcuts.py
									
									
									
									
									
										Normal 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)
 | 
			
		||||
							
								
								
									
										120
									
								
								src/accounting/report/period/specification.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								src/accounting/report/period/specification.py
									
									
									
									
									
										Normal 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}"
 | 
			
		||||
							
								
								
									
										26
									
								
								src/accounting/report/reports/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/accounting/report/reports/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
# 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 reports.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from .balance_sheet import BalanceSheet
 | 
			
		||||
from .income_expenses import IncomeExpenses
 | 
			
		||||
from .income_statement import IncomeStatement
 | 
			
		||||
from .journal import Journal
 | 
			
		||||
from .ledger import Ledger
 | 
			
		||||
from .search import Search
 | 
			
		||||
from .trial_balance import TrialBalance
 | 
			
		||||
							
								
								
									
										474
									
								
								src/accounting/report/reports/balance_sheet.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										474
									
								
								src/accounting/report/reports/balance_sheet.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,474 @@
 | 
			
		||||
# 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 balance sheet.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
from flask import render_template, Response
 | 
			
		||||
 | 
			
		||||
from accounting import db
 | 
			
		||||
from accounting.locale import gettext
 | 
			
		||||
from accounting.models import Currency, BaseAccount, Account, Transaction, \
 | 
			
		||||
    JournalEntry
 | 
			
		||||
from accounting.report.period import Period, PeriodChooser
 | 
			
		||||
from accounting.report.utils.base_page_params import BasePageParams
 | 
			
		||||
from accounting.report.utils.base_report import BaseReport
 | 
			
		||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
 | 
			
		||||
    period_spec
 | 
			
		||||
from accounting.report.utils.option_link import OptionLink
 | 
			
		||||
from accounting.report.utils.report_chooser import ReportChooser
 | 
			
		||||
from accounting.report.utils.report_type import ReportType
 | 
			
		||||
from accounting.report.utils.urls import ledger_url, balance_sheet_url, \
 | 
			
		||||
    income_statement_url
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ReportAccount:
 | 
			
		||||
    """An account in the report."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, account: Account, amount: Decimal, url: str):
 | 
			
		||||
        """Constructs an account in the report.
 | 
			
		||||
 | 
			
		||||
        :param account: The account.
 | 
			
		||||
        :param amount: The amount.
 | 
			
		||||
        :param url: The URL to the ledger of the account.
 | 
			
		||||
        """
 | 
			
		||||
        self.account: Account = account
 | 
			
		||||
        """The account."""
 | 
			
		||||
        self.amount: Decimal = amount
 | 
			
		||||
        """The amount of the account."""
 | 
			
		||||
        self.url: str = url
 | 
			
		||||
        """The URL to the ledger of the account."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Subsection:
 | 
			
		||||
    """A subsection."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, title: BaseAccount):
 | 
			
		||||
        """Constructs a subsection.
 | 
			
		||||
 | 
			
		||||
        :param title: The title account.
 | 
			
		||||
        """
 | 
			
		||||
        self.title: BaseAccount = title
 | 
			
		||||
        """The title account."""
 | 
			
		||||
        self.accounts: list[ReportAccount] = []
 | 
			
		||||
        """The accounts in the subsection."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def total(self) -> Decimal:
 | 
			
		||||
        """Returns the total of the subsection.
 | 
			
		||||
 | 
			
		||||
        :return: The total of the subsection.
 | 
			
		||||
        """
 | 
			
		||||
        return sum([x.amount for x in self.accounts])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Section:
 | 
			
		||||
    """A section."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, title: BaseAccount):
 | 
			
		||||
        """Constructs a section.
 | 
			
		||||
 | 
			
		||||
        :param title: The title account.
 | 
			
		||||
        """
 | 
			
		||||
        self.title: BaseAccount = title
 | 
			
		||||
        """The title account."""
 | 
			
		||||
        self.subsections: list[Subsection] = []
 | 
			
		||||
        """The subsections in the section."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def total(self) -> Decimal:
 | 
			
		||||
        """Returns the total of the section.
 | 
			
		||||
 | 
			
		||||
        :return: The total of the section.
 | 
			
		||||
        """
 | 
			
		||||
        return sum([x.total for x in self.subsections])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AccountCollector:
 | 
			
		||||
    """The balance sheet account collector."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, currency: Currency, period: Period):
 | 
			
		||||
        """Constructs the balance sheet account collector.
 | 
			
		||||
 | 
			
		||||
        :param currency: The currency.
 | 
			
		||||
        :param period: The period.
 | 
			
		||||
        """
 | 
			
		||||
        self.__currency: Currency = currency
 | 
			
		||||
        """The currency."""
 | 
			
		||||
        self.__period: Period = period
 | 
			
		||||
        """The period."""
 | 
			
		||||
        self.accounts: list[ReportAccount] = self.__query_balances()
 | 
			
		||||
        """The balance sheet accounts."""
 | 
			
		||||
 | 
			
		||||
    def __query_balances(self) -> list[ReportAccount]:
 | 
			
		||||
        """Queries and returns the balances.
 | 
			
		||||
 | 
			
		||||
        :return: The balances.
 | 
			
		||||
        """
 | 
			
		||||
        sub_conditions: list[sa.BinaryExpression] \
 | 
			
		||||
            = [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
 | 
			
		||||
        conditions: list[sa.BinaryExpression] \
 | 
			
		||||
            = [JournalEntry.currency_code == self.__currency.code,
 | 
			
		||||
               sa.or_(*sub_conditions)]
 | 
			
		||||
        if self.__period.end is not None:
 | 
			
		||||
            conditions.append(Transaction.date <= self.__period.end)
 | 
			
		||||
        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(Account.id, Account.base_code, Account.no,
 | 
			
		||||
                        balance_func)\
 | 
			
		||||
            .join(Transaction).join(Account)\
 | 
			
		||||
            .filter(*conditions)\
 | 
			
		||||
            .group_by(Account.id, Account.base_code, Account.no)\
 | 
			
		||||
            .order_by(Account.base_code, Account.no)
 | 
			
		||||
        account_balances: list[sa.Row] \
 | 
			
		||||
            = db.session.execute(select_balance).all()
 | 
			
		||||
        self.__all_accounts: list[Account] = Account.query\
 | 
			
		||||
            .filter(sa.or_(Account.id.in_({x.id for x in account_balances}),
 | 
			
		||||
                           Account.base_code == "3351",
 | 
			
		||||
                           Account.base_code == "3353")).all()
 | 
			
		||||
        account_by_id: dict[int, Account] \
 | 
			
		||||
            = {x.id: x for x in self.__all_accounts}
 | 
			
		||||
        self.accounts: list[ReportAccount] \
 | 
			
		||||
            = [ReportAccount(account=account_by_id[x.id],
 | 
			
		||||
                             amount=x.balance,
 | 
			
		||||
                             url=ledger_url(self.__currency,
 | 
			
		||||
                                            account_by_id[x.id],
 | 
			
		||||
                                            self.__period))
 | 
			
		||||
               for x in account_balances]
 | 
			
		||||
        self.__add_accumulated()
 | 
			
		||||
        self.__add_current_period()
 | 
			
		||||
        self.accounts.sort(key=lambda x: (x.account.base_code, x.account.no))
 | 
			
		||||
        for balance in self.accounts:
 | 
			
		||||
            if not balance.account.base_code.startswith("1"):
 | 
			
		||||
                balance.amount = -balance.amount
 | 
			
		||||
        return self.accounts
 | 
			
		||||
 | 
			
		||||
    def __add_accumulated(self) -> None:
 | 
			
		||||
        """Adds the accumulated profit or loss to the balances.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        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.
 | 
			
		||||
 | 
			
		||||
        :return: The accumulated profit or loss.
 | 
			
		||||
        """
 | 
			
		||||
        if self.__period.start is None:
 | 
			
		||||
            return None
 | 
			
		||||
        conditions: list[sa.BinaryExpression] \
 | 
			
		||||
            = [JournalEntry.currency_code == self.__currency.code,
 | 
			
		||||
               Transaction.date < self.__period.start]
 | 
			
		||||
        return self.__query_balance(conditions)
 | 
			
		||||
 | 
			
		||||
    def __add_current_period(self) -> None:
 | 
			
		||||
        """Adds the accumulated profit or loss to the balances.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        self.__add_owner_s_equity(Account.NET_CHANGE_CODE,
 | 
			
		||||
                                  self.__query_currency_period(),
 | 
			
		||||
                                  self.__period)
 | 
			
		||||
 | 
			
		||||
    def __query_currency_period(self) -> Decimal | None:
 | 
			
		||||
        """Queries and returns the net income or loss for current period.
 | 
			
		||||
 | 
			
		||||
        :return: The net income or loss for current period.
 | 
			
		||||
        """
 | 
			
		||||
        conditions: list[sa.BinaryExpression] \
 | 
			
		||||
            = [JournalEntry.currency_code == self.__currency.code]
 | 
			
		||||
        if self.__period.start is not None:
 | 
			
		||||
            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"}])
 | 
			
		||||
        balance_func: sa.Function = sa.func.sum(sa.case(
 | 
			
		||||
            (JournalEntry.is_debit, JournalEntry.amount),
 | 
			
		||||
            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,
 | 
			
		||||
                             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.
 | 
			
		||||
        """
 | 
			
		||||
        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(ReportAccount(account=account_by_code[code],
 | 
			
		||||
                                           amount=amount,
 | 
			
		||||
                                           url=url))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CSVHalfRow:
 | 
			
		||||
    """A half row in the CSV."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, title: str | None, amount: Decimal | None):
 | 
			
		||||
        """The constructs a half row in the CSV.
 | 
			
		||||
 | 
			
		||||
        :param title: The title.
 | 
			
		||||
        :param amount: The amount.
 | 
			
		||||
        """
 | 
			
		||||
        self.title: str | None = title
 | 
			
		||||
        """The title."""
 | 
			
		||||
        self.amount: Decimal | None = amount
 | 
			
		||||
        """The amount."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CSVRow(BaseCSVRow):
 | 
			
		||||
    """A row in the CSV."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        """Constructs a row in the CSV."""
 | 
			
		||||
        self.asset_title: str | None = None
 | 
			
		||||
        """The title of the asset."""
 | 
			
		||||
        self.asset_amount: Decimal | None = None
 | 
			
		||||
        """The amount of the asset."""
 | 
			
		||||
        self.liability_title: str | None = None
 | 
			
		||||
        """The title of the liability."""
 | 
			
		||||
        self.liability_amount: Decimal | None = None
 | 
			
		||||
        """The amount of the liability."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def values(self) -> list[str | Decimal | None]:
 | 
			
		||||
        """Returns the values of the row.
 | 
			
		||||
 | 
			
		||||
        :return: The values of the row.
 | 
			
		||||
        """
 | 
			
		||||
        return [self.asset_title, self.asset_amount,
 | 
			
		||||
                self.liability_title, self.liability_amount]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PageParams(BasePageParams):
 | 
			
		||||
    """The HTML page parameters."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, currency: Currency,
 | 
			
		||||
                 period: Period,
 | 
			
		||||
                 has_data: bool,
 | 
			
		||||
                 assets: Section,
 | 
			
		||||
                 liabilities: Section,
 | 
			
		||||
                 owner_s_equity: Section):
 | 
			
		||||
        """Constructs the HTML page parameters.
 | 
			
		||||
 | 
			
		||||
        :param currency: The currency.
 | 
			
		||||
        :param period: The period.
 | 
			
		||||
        :param has_data: True if there is any data, or False otherwise.
 | 
			
		||||
        :param assets: The assets.
 | 
			
		||||
        :param liabilities: The liabilities.
 | 
			
		||||
        :param owner_s_equity: The owner's equity.
 | 
			
		||||
        """
 | 
			
		||||
        self.currency: Currency = currency
 | 
			
		||||
        """The currency."""
 | 
			
		||||
        self.period: Period = period
 | 
			
		||||
        """The period."""
 | 
			
		||||
        self.__has_data: bool = has_data
 | 
			
		||||
        """True if there is any data, or False otherwise."""
 | 
			
		||||
        self.assets: Section = assets
 | 
			
		||||
        """The assets."""
 | 
			
		||||
        self.liabilities: Section = liabilities
 | 
			
		||||
        """The liabilities."""
 | 
			
		||||
        self.owner_s_equity: Section = owner_s_equity
 | 
			
		||||
        """The owner's equity."""
 | 
			
		||||
        self.period_chooser: PeriodChooser = PeriodChooser(
 | 
			
		||||
            lambda x: balance_sheet_url(currency, x))
 | 
			
		||||
        """The period chooser."""
 | 
			
		||||
 | 
			
		||||
    @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 self.__has_data
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def report_chooser(self) -> ReportChooser:
 | 
			
		||||
        """Returns the report chooser.
 | 
			
		||||
 | 
			
		||||
        :return: The report chooser.
 | 
			
		||||
        """
 | 
			
		||||
        return ReportChooser(ReportType.BALANCE_SHEET,
 | 
			
		||||
                             currency=self.currency,
 | 
			
		||||
                             period=self.period)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def currency_options(self) -> list[OptionLink]:
 | 
			
		||||
        """Returns the currency options.
 | 
			
		||||
 | 
			
		||||
        :return: The currency options.
 | 
			
		||||
        """
 | 
			
		||||
        return self._get_currency_options(
 | 
			
		||||
            lambda x: balance_sheet_url(x, self.period), self.currency)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BalanceSheet(BaseReport):
 | 
			
		||||
    """The balance sheet."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, currency: Currency, period: Period):
 | 
			
		||||
        """Constructs a balance sheet.
 | 
			
		||||
 | 
			
		||||
        :param currency: The currency.
 | 
			
		||||
        :param period: The period.
 | 
			
		||||
        """
 | 
			
		||||
        self.__currency: Currency = currency
 | 
			
		||||
        """The currency."""
 | 
			
		||||
        self.__period: Period = period
 | 
			
		||||
        """The period."""
 | 
			
		||||
        self.__has_data: bool
 | 
			
		||||
        """True if there is any data, or False otherwise."""
 | 
			
		||||
        self.__assets: Section
 | 
			
		||||
        """The assets."""
 | 
			
		||||
        self.__liabilities: Section
 | 
			
		||||
        """The liabilities."""
 | 
			
		||||
        self.__owner_s_equity: Section
 | 
			
		||||
        """The owner's equity."""
 | 
			
		||||
        self.__set_data()
 | 
			
		||||
 | 
			
		||||
    def __set_data(self) -> None:
 | 
			
		||||
        """Queries and sets assets, the liabilities, and the owner's equity
 | 
			
		||||
        sections in the balance sheet.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        balances: list[ReportAccount] = AccountCollector(
 | 
			
		||||
            self.__currency, self.__period).accounts
 | 
			
		||||
 | 
			
		||||
        titles: list[BaseAccount] = BaseAccount.query\
 | 
			
		||||
            .filter(BaseAccount.code.in_({"1", "2", "3"})).all()
 | 
			
		||||
        subtitles: list[BaseAccount] = BaseAccount.query\
 | 
			
		||||
            .filter(BaseAccount.code.in_({x.account.base_code[:2]
 | 
			
		||||
                                          for x in balances})).all()
 | 
			
		||||
 | 
			
		||||
        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:
 | 
			
		||||
            subsections[balance.account.base_code[:2]].accounts.append(balance)
 | 
			
		||||
 | 
			
		||||
        self.__has_data = len(balances) > 0
 | 
			
		||||
        self.__assets = sections["1"]
 | 
			
		||||
        self.__liabilities = sections["2"]
 | 
			
		||||
        self.__owner_s_equity = sections["3"]
 | 
			
		||||
 | 
			
		||||
    def csv(self) -> Response:
 | 
			
		||||
        """Returns the report as CSV for download.
 | 
			
		||||
 | 
			
		||||
        :return: The response of the report for download.
 | 
			
		||||
        """
 | 
			
		||||
        filename: str = "balance-sheet-{currency}-{period}.csv"\
 | 
			
		||||
            .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]:
 | 
			
		||||
        """Composes and returns the CSV rows.
 | 
			
		||||
 | 
			
		||||
        :return: The CSV rows.
 | 
			
		||||
        """
 | 
			
		||||
        asset_rows: list[CSVHalfRow] = self.__section_csv_rows(self.__assets)
 | 
			
		||||
        liability_rows: list[CSVHalfRow] = []
 | 
			
		||||
        liability_rows.extend(self.__section_csv_rows(self.__liabilities))
 | 
			
		||||
        liability_rows.append(CSVHalfRow(gettext("Total"),
 | 
			
		||||
                                         self.__liabilities.total))
 | 
			
		||||
        liability_rows.append(CSVHalfRow(None, None))
 | 
			
		||||
        liability_rows.extend(self.__section_csv_rows(self.__owner_s_equity))
 | 
			
		||||
        liability_rows.append(CSVHalfRow(gettext("Total"),
 | 
			
		||||
                                         self.__owner_s_equity.total))
 | 
			
		||||
        rows: list[CSVRow] = [CSVRow() for _ in
 | 
			
		||||
                              range(max(len(asset_rows), len(liability_rows)))]
 | 
			
		||||
        for i in range(len(rows)):
 | 
			
		||||
            if i < len(asset_rows):
 | 
			
		||||
                rows[i].asset_title = asset_rows[i].title
 | 
			
		||||
                rows[i].asset_amount = asset_rows[i].amount
 | 
			
		||||
            if i < len(liability_rows) and liability_rows[i].title is not None:
 | 
			
		||||
                rows[i].liability_title = liability_rows[i].title
 | 
			
		||||
                rows[i].liability_amount = liability_rows[i].amount
 | 
			
		||||
        total: CSVRow = CSVRow()
 | 
			
		||||
        total.asset_title = gettext("Total")
 | 
			
		||||
        total.asset_amount = self.__assets.total
 | 
			
		||||
        total.liability_title = gettext("Total")
 | 
			
		||||
        total.liability_amount \
 | 
			
		||||
            = self.__liabilities.total + self.__owner_s_equity.total
 | 
			
		||||
        rows.append(total)
 | 
			
		||||
        return rows
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def __section_csv_rows(section: Section) -> list[CSVHalfRow]:
 | 
			
		||||
        """Gathers the CSV rows for a section.
 | 
			
		||||
 | 
			
		||||
        :param section: The section.
 | 
			
		||||
        :return: The CSV rows for the section.
 | 
			
		||||
        """
 | 
			
		||||
        rows: list[CSVHalfRow] \
 | 
			
		||||
            = [CSVHalfRow(section.title.title.title(), None)]
 | 
			
		||||
        for subsection in section.subsections:
 | 
			
		||||
            rows.append(CSVHalfRow(f" {subsection.title.title.title()}", None))
 | 
			
		||||
            for account in subsection.accounts:
 | 
			
		||||
                rows.append(CSVHalfRow(f"  {str(account.account).title()}",
 | 
			
		||||
                                       account.amount))
 | 
			
		||||
        return rows
 | 
			
		||||
 | 
			
		||||
    def html(self) -> str:
 | 
			
		||||
        """Composes and returns the report as HTML.
 | 
			
		||||
 | 
			
		||||
        :return: The report as HTML.
 | 
			
		||||
        """
 | 
			
		||||
        params: PageParams = PageParams(currency=self.__currency,
 | 
			
		||||
                                        period=self.__period,
 | 
			
		||||
                                        has_data=self.__has_data,
 | 
			
		||||
                                        assets=self.__assets,
 | 
			
		||||
                                        liabilities=self.__liabilities,
 | 
			
		||||
                                        owner_s_equity=self.__owner_s_equity)
 | 
			
		||||
        return render_template("accounting/report/balance-sheet.html",
 | 
			
		||||
                               report=params)
 | 
			
		||||
							
								
								
									
										456
									
								
								src/accounting/report/reports/income_expenses.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										456
									
								
								src/accounting/report/reports/income_expenses.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,456 @@
 | 
			
		||||
# 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 income and expenses log.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from datetime import date
 | 
			
		||||
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, 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.pagination import Pagination
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ReportEntry:
 | 
			
		||||
    """An entry in the report."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, entry: JournalEntry | None = None):
 | 
			
		||||
        """Constructs the entry in the report.
 | 
			
		||||
 | 
			
		||||
        :param entry: The journal entry.
 | 
			
		||||
        """
 | 
			
		||||
        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.income: Decimal | None = None
 | 
			
		||||
        """The income amount."""
 | 
			
		||||
        self.expense: Decimal | None = None
 | 
			
		||||
        """The expense amount."""
 | 
			
		||||
        self.balance: Decimal | None = None
 | 
			
		||||
        """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.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 report entry collector."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, currency: Currency, account: IncomeExpensesAccount,
 | 
			
		||||
                 period: Period):
 | 
			
		||||
        """Constructs the report entry collector.
 | 
			
		||||
 | 
			
		||||
        :param currency: The currency.
 | 
			
		||||
        :param account: The account.
 | 
			
		||||
        :param period: The period.
 | 
			
		||||
        """
 | 
			
		||||
        self.__currency: Currency = currency
 | 
			
		||||
        """The currency."""
 | 
			
		||||
        self.__account: IncomeExpensesAccount = account
 | 
			
		||||
        """The account."""
 | 
			
		||||
        self.__period: Period = period
 | 
			
		||||
        """The period"""
 | 
			
		||||
        self.brought_forward: ReportEntry | None
 | 
			
		||||
        """The brought-forward entry."""
 | 
			
		||||
        self.entries: list[ReportEntry]
 | 
			
		||||
        """The log 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) -> ReportEntry | None:
 | 
			
		||||
        """Queries, composes and returns the brought-forward entry.
 | 
			
		||||
 | 
			
		||||
        :return: The brought-forward entry, or None if the period starts from
 | 
			
		||||
            the beginning.
 | 
			
		||||
        """
 | 
			
		||||
        if self.__period.start is None:
 | 
			
		||||
            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).join(Account)\
 | 
			
		||||
            .filter(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: ReportEntry = ReportEntry()
 | 
			
		||||
        entry.is_brought_forward = True
 | 
			
		||||
        entry.date = self.__period.start
 | 
			
		||||
        entry.account = Account.accumulated_change()
 | 
			
		||||
        entry.summary = gettext("Brought forward")
 | 
			
		||||
        if balance > 0:
 | 
			
		||||
            entry.income = balance
 | 
			
		||||
        elif balance < 0:
 | 
			
		||||
            entry.expense = -balance
 | 
			
		||||
        entry.balance = balance
 | 
			
		||||
        return entry
 | 
			
		||||
 | 
			
		||||
    def __query_entries(self) -> list[ReportEntry]:
 | 
			
		||||
        """Queries and returns the log entries.
 | 
			
		||||
 | 
			
		||||
        :return: The log entries.
 | 
			
		||||
        """
 | 
			
		||||
        conditions: list[sa.BinaryExpression] \
 | 
			
		||||
            = [JournalEntry.currency_code == self.__currency.code,
 | 
			
		||||
               self.__account_condition]
 | 
			
		||||
        if self.__period.start is not None:
 | 
			
		||||
            conditions.append(Transaction.date >= self.__period.start)
 | 
			
		||||
        if self.__period.end is not None:
 | 
			
		||||
            conditions.append(Transaction.date <= self.__period.end)
 | 
			
		||||
        txn_with_account: sa.Select = sa.Select(Transaction.id).\
 | 
			
		||||
            join(JournalEntry).join(Account).filter(*conditions)
 | 
			
		||||
 | 
			
		||||
        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,
 | 
			
		||||
                          JournalEntry.is_debit,
 | 
			
		||||
                          JournalEntry.no)
 | 
			
		||||
                .options(selectinload(JournalEntry.account),
 | 
			
		||||
                         selectinload(JournalEntry.transaction))]
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def __account_condition(self) -> sa.BinaryExpression:
 | 
			
		||||
        if self.__account.code == IncomeExpensesAccount.CURRENT_AL_CODE:
 | 
			
		||||
            return sa.or_(Account.base_code.startswith("11"),
 | 
			
		||||
                          Account.base_code.startswith("12"),
 | 
			
		||||
                          Account.base_code.startswith("21"),
 | 
			
		||||
                          Account.base_code.startswith("22"))
 | 
			
		||||
        return Account.id == self.__account.id
 | 
			
		||||
 | 
			
		||||
    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: ReportEntry = ReportEntry()
 | 
			
		||||
        entry.is_total = True
 | 
			
		||||
        entry.summary = gettext("Total")
 | 
			
		||||
        entry.income = sum([x.income for x in self.entries
 | 
			
		||||
                            if x.income is not None])
 | 
			
		||||
        entry.expense = sum([x.expense for x in self.entries
 | 
			
		||||
                             if x.expense is not None])
 | 
			
		||||
        entry.balance = entry.income - entry.expense
 | 
			
		||||
        if self.brought_forward is not None:
 | 
			
		||||
            entry.balance = self.brought_forward.balance + entry.balance
 | 
			
		||||
        return entry
 | 
			
		||||
 | 
			
		||||
    def __populate_balance(self) -> None:
 | 
			
		||||
        """Populates the balance of the entries.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        balance: Decimal = 0 if self.brought_forward is None \
 | 
			
		||||
            else self.brought_forward.balance
 | 
			
		||||
        for entry in self.entries:
 | 
			
		||||
            if entry.income is not None:
 | 
			
		||||
                balance = balance + entry.income
 | 
			
		||||
            if entry.expense is not None:
 | 
			
		||||
                balance = balance - entry.expense
 | 
			
		||||
            entry.balance = balance
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CSVRow(BaseCSVRow):
 | 
			
		||||
    """A row in the CSV."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, txn_date: date | str | None,
 | 
			
		||||
                 account: str | None,
 | 
			
		||||
                 summary: str | None,
 | 
			
		||||
                 income: str | Decimal | None,
 | 
			
		||||
                 expense: str | Decimal | None,
 | 
			
		||||
                 balance: str | Decimal | None,
 | 
			
		||||
                 note: str | None):
 | 
			
		||||
        """Constructs a row in the CSV.
 | 
			
		||||
 | 
			
		||||
        :param txn_date: The transaction date.
 | 
			
		||||
        :param account: The account.
 | 
			
		||||
        :param summary: The summary.
 | 
			
		||||
        :param income: The income.
 | 
			
		||||
        :param expense: The expense.
 | 
			
		||||
        :param balance: The balance.
 | 
			
		||||
        :param note: The note.
 | 
			
		||||
        """
 | 
			
		||||
        self.date: date | str | None = txn_date
 | 
			
		||||
        """The date."""
 | 
			
		||||
        self.account: str | None = account
 | 
			
		||||
        """The account."""
 | 
			
		||||
        self.summary: str | None = summary
 | 
			
		||||
        """The summary."""
 | 
			
		||||
        self.income: str | Decimal | None = income
 | 
			
		||||
        """The income."""
 | 
			
		||||
        self.expense: str | Decimal | None = expense
 | 
			
		||||
        """The expense."""
 | 
			
		||||
        self.balance: str | Decimal | None = balance
 | 
			
		||||
        """The balance."""
 | 
			
		||||
        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.account, self.summary,
 | 
			
		||||
                self.income, self.expense, self.balance, self.note]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PageParams(BasePageParams):
 | 
			
		||||
    """The HTML page parameters."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, currency: Currency,
 | 
			
		||||
                 account: IncomeExpensesAccount,
 | 
			
		||||
                 period: Period,
 | 
			
		||||
                 has_data: bool,
 | 
			
		||||
                 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 log entries.
 | 
			
		||||
        :param total: The total entry.
 | 
			
		||||
        """
 | 
			
		||||
        self.currency: Currency = currency
 | 
			
		||||
        """The currency."""
 | 
			
		||||
        self.account: IncomeExpensesAccount = account
 | 
			
		||||
        """The account."""
 | 
			
		||||
        self.period: Period = period
 | 
			
		||||
        """The period."""
 | 
			
		||||
        self.__has_data: bool = has_data
 | 
			
		||||
        """True if there is any data, or False otherwise."""
 | 
			
		||||
        self.pagination: Pagination[ReportEntry] = pagination
 | 
			
		||||
        """The pagination."""
 | 
			
		||||
        self.brought_forward: ReportEntry | None = brought_forward
 | 
			
		||||
        """The brought-forward entry."""
 | 
			
		||||
        self.entries: list[ReportEntry] = entries
 | 
			
		||||
        """The report entries."""
 | 
			
		||||
        self.total: ReportEntry | None = total
 | 
			
		||||
        """The total entry."""
 | 
			
		||||
        self.period_chooser: PeriodChooser = PeriodChooser(
 | 
			
		||||
            lambda x: income_expenses_url(currency, account, x))
 | 
			
		||||
        """The period chooser."""
 | 
			
		||||
 | 
			
		||||
    @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 self.__has_data
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def report_chooser(self) -> ReportChooser:
 | 
			
		||||
        """Returns the report chooser.
 | 
			
		||||
 | 
			
		||||
        :return: The report chooser.
 | 
			
		||||
        """
 | 
			
		||||
        if self.account.account is None:
 | 
			
		||||
            return ReportChooser(ReportType.INCOME_EXPENSES,
 | 
			
		||||
                                 currency=self.currency,
 | 
			
		||||
                                 account=Account.cash(),
 | 
			
		||||
                                 period=self.period)
 | 
			
		||||
        return ReportChooser(ReportType.INCOME_EXPENSES,
 | 
			
		||||
                             currency=self.currency,
 | 
			
		||||
                             account=self.account.account,
 | 
			
		||||
                             period=self.period)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def currency_options(self) -> list[OptionLink]:
 | 
			
		||||
        """Returns the currency options.
 | 
			
		||||
 | 
			
		||||
        :return: The currency options.
 | 
			
		||||
        """
 | 
			
		||||
        return self._get_currency_options(
 | 
			
		||||
            lambda x: income_expenses_url(x, self.account, self.period),
 | 
			
		||||
            self.currency)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def account_options(self) -> list[OptionLink]:
 | 
			
		||||
        """Returns the account options.
 | 
			
		||||
 | 
			
		||||
        :return: The account options.
 | 
			
		||||
        """
 | 
			
		||||
        current_al: IncomeExpensesAccount \
 | 
			
		||||
            = IncomeExpensesAccount.current_assets_and_liabilities()
 | 
			
		||||
        options: list[OptionLink] \
 | 
			
		||||
            = [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,
 | 
			
		||||
                    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),
 | 
			
		||||
                                   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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IncomeExpenses(BaseReport):
 | 
			
		||||
    """The income and expenses log."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, currency: Currency, account: IncomeExpensesAccount,
 | 
			
		||||
                 period: Period):
 | 
			
		||||
        """Constructs an income and expenses log.
 | 
			
		||||
 | 
			
		||||
        :param currency: The currency.
 | 
			
		||||
        :param account: The account.
 | 
			
		||||
        :param period: The period.
 | 
			
		||||
        """
 | 
			
		||||
        self.__currency: Currency = currency
 | 
			
		||||
        """The currency."""
 | 
			
		||||
        self.__account: IncomeExpensesAccount = account
 | 
			
		||||
        """The account."""
 | 
			
		||||
        self.__period: Period = period
 | 
			
		||||
        """The period."""
 | 
			
		||||
        collector: EntryCollector = EntryCollector(
 | 
			
		||||
            self.__currency, self.__account, self.__period)
 | 
			
		||||
        self.__brought_forward: ReportEntry | None = collector.brought_forward
 | 
			
		||||
        """The brought-forward entry."""
 | 
			
		||||
        self.__entries: list[ReportEntry] = collector.entries
 | 
			
		||||
        """The report entries."""
 | 
			
		||||
        self.__total: ReportEntry | None = collector.total
 | 
			
		||||
        """The total entry."""
 | 
			
		||||
 | 
			
		||||
    def csv(self) -> Response:
 | 
			
		||||
        """Returns the report as CSV for download.
 | 
			
		||||
 | 
			
		||||
        :return: The response of the report for download.
 | 
			
		||||
        """
 | 
			
		||||
        filename: str = "income-expenses-{currency}-{account}-{period}.csv"\
 | 
			
		||||
            .format(currency=self.__currency.code, account=self.__account.code,
 | 
			
		||||
                    period=period_spec(self.__period))
 | 
			
		||||
        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.
 | 
			
		||||
        """
 | 
			
		||||
        rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Account"),
 | 
			
		||||
                                     gettext("Summary"), gettext("Income"),
 | 
			
		||||
                                     gettext("Expense"), gettext("Balance"),
 | 
			
		||||
                                     gettext("Note"))]
 | 
			
		||||
        if self.__brought_forward is not None:
 | 
			
		||||
            rows.append(CSVRow(self.__brought_forward.date,
 | 
			
		||||
                               str(self.__brought_forward.account).title(),
 | 
			
		||||
                               self.__brought_forward.summary,
 | 
			
		||||
                               self.__brought_forward.income,
 | 
			
		||||
                               self.__brought_forward.expense,
 | 
			
		||||
                               self.__brought_forward.balance,
 | 
			
		||||
                               None))
 | 
			
		||||
        rows.extend([CSVRow(x.date, str(x.account).title(), x.summary,
 | 
			
		||||
                            x.income, x.expense, x.balance, x.note)
 | 
			
		||||
                     for x in self.__entries])
 | 
			
		||||
        if self.__total is not None:
 | 
			
		||||
            rows.append(CSVRow(gettext("Total"), None, None,
 | 
			
		||||
                               self.__total.income, self.__total.expense,
 | 
			
		||||
                               self.__total.balance, None))
 | 
			
		||||
        return rows
 | 
			
		||||
 | 
			
		||||
    def html(self) -> str:
 | 
			
		||||
        """Composes and returns the report as HTML.
 | 
			
		||||
 | 
			
		||||
        :return: The report as HTML.
 | 
			
		||||
        """
 | 
			
		||||
        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[ReportEntry] \
 | 
			
		||||
            = Pagination[ReportEntry](all_entries)
 | 
			
		||||
        page_entries: list[ReportEntry] = pagination.list
 | 
			
		||||
        has_data: bool = len(page_entries) > 0
 | 
			
		||||
        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: ReportEntry | None = None
 | 
			
		||||
        if len(page_entries) > 0 and page_entries[-1].is_total:
 | 
			
		||||
            total = page_entries[-1]
 | 
			
		||||
            page_entries = page_entries[:-1]
 | 
			
		||||
        params: PageParams = PageParams(currency=self.__currency,
 | 
			
		||||
                                        account=self.__account,
 | 
			
		||||
                                        period=self.__period,
 | 
			
		||||
                                        has_data=has_data,
 | 
			
		||||
                                        pagination=pagination,
 | 
			
		||||
                                        brought_forward=brought_forward,
 | 
			
		||||
                                        entries=page_entries,
 | 
			
		||||
                                        total=total)
 | 
			
		||||
        return render_template("accounting/report/income-expenses.html",
 | 
			
		||||
                               report=params)
 | 
			
		||||
							
								
								
									
										325
									
								
								src/accounting/report/reports/income_statement.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										325
									
								
								src/accounting/report/reports/income_statement.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,325 @@
 | 
			
		||||
# 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 income statement.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
from flask import render_template, Response
 | 
			
		||||
 | 
			
		||||
from accounting import db
 | 
			
		||||
from accounting.locale import gettext
 | 
			
		||||
from accounting.models import Currency, BaseAccount, Account, Transaction, \
 | 
			
		||||
    JournalEntry
 | 
			
		||||
from accounting.report.period import Period, PeriodChooser
 | 
			
		||||
from accounting.report.utils.base_page_params import BasePageParams
 | 
			
		||||
from accounting.report.utils.base_report import BaseReport
 | 
			
		||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
 | 
			
		||||
    period_spec
 | 
			
		||||
from accounting.report.utils.option_link import OptionLink
 | 
			
		||||
from accounting.report.utils.report_chooser import ReportChooser
 | 
			
		||||
from accounting.report.utils.report_type import ReportType
 | 
			
		||||
from accounting.report.utils.urls import ledger_url, income_statement_url
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ReportAccount:
 | 
			
		||||
    """An account in the report."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, account: Account, amount: Decimal, url: str):
 | 
			
		||||
        """Constructs an account in the report.
 | 
			
		||||
 | 
			
		||||
        :param account: The account.
 | 
			
		||||
        :param amount: The amount.
 | 
			
		||||
        :param url: The URL to the ledger of the account.
 | 
			
		||||
        """
 | 
			
		||||
        self.account: Account = account
 | 
			
		||||
        """The account."""
 | 
			
		||||
        self.amount: Decimal = amount
 | 
			
		||||
        """The amount of the account."""
 | 
			
		||||
        self.url: str = url
 | 
			
		||||
        """The URL to the ledger of the account."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AccumulatedTotal:
 | 
			
		||||
    """An accumulated total."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, title: str):
 | 
			
		||||
        """Constructs an accumulated total.
 | 
			
		||||
 | 
			
		||||
        :param title: The title.
 | 
			
		||||
        """
 | 
			
		||||
        self.title: str = title
 | 
			
		||||
        """The account."""
 | 
			
		||||
        self.amount: Decimal = Decimal("0")
 | 
			
		||||
        """The amount of the account."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Subsection:
 | 
			
		||||
    """A subsection."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, title: BaseAccount):
 | 
			
		||||
        """Constructs a subsection.
 | 
			
		||||
 | 
			
		||||
        :param title: The title account.
 | 
			
		||||
        """
 | 
			
		||||
        self.title: BaseAccount = title
 | 
			
		||||
        """The title account."""
 | 
			
		||||
        self.accounts: list[ReportAccount] = []
 | 
			
		||||
        """The accounts in the subsection."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def total(self) -> Decimal:
 | 
			
		||||
        """Returns the total of the subsection.
 | 
			
		||||
 | 
			
		||||
        :return: The total of the subsection.
 | 
			
		||||
        """
 | 
			
		||||
        return sum([x.amount for x in self.accounts])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Section:
 | 
			
		||||
    """A section."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, title: BaseAccount, accumulated_title: str):
 | 
			
		||||
        """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[Subsection] = []
 | 
			
		||||
        """The subsections in the section."""
 | 
			
		||||
        self.accumulated: AccumulatedTotal \
 | 
			
		||||
            = AccumulatedTotal(accumulated_title)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def total(self) -> Decimal:
 | 
			
		||||
        """Returns the total of the section.
 | 
			
		||||
 | 
			
		||||
        :return: The total of the section.
 | 
			
		||||
        """
 | 
			
		||||
        return sum([x.total for x in self.subsections])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CSVRow(BaseCSVRow):
 | 
			
		||||
    """A row in the CSV."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, text: str | None, amount: str | Decimal | None):
 | 
			
		||||
        """Constructs a row in the CSV.
 | 
			
		||||
 | 
			
		||||
        :param text: The text.
 | 
			
		||||
        :param amount: The amount.
 | 
			
		||||
        """
 | 
			
		||||
        self.text: str | None = text
 | 
			
		||||
        """The text."""
 | 
			
		||||
        self.amount: str | Decimal | None = amount
 | 
			
		||||
        """The amount."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def values(self) -> list[str | Decimal | None]:
 | 
			
		||||
        """Returns the values of the row.
 | 
			
		||||
 | 
			
		||||
        :return: The values of the row.
 | 
			
		||||
        """
 | 
			
		||||
        return [self.text, self.amount]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PageParams(BasePageParams):
 | 
			
		||||
    """The HTML page parameters."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, currency: Currency,
 | 
			
		||||
                 period: Period,
 | 
			
		||||
                 has_data: bool,
 | 
			
		||||
                 sections: list[Section], ):
 | 
			
		||||
        """Constructs the HTML page parameters.
 | 
			
		||||
 | 
			
		||||
        :param currency: The currency.
 | 
			
		||||
        :param period: The period.
 | 
			
		||||
        :param has_data: True if there is any data, or False otherwise.
 | 
			
		||||
        """
 | 
			
		||||
        self.currency: Currency = currency
 | 
			
		||||
        """The currency."""
 | 
			
		||||
        self.period: Period = period
 | 
			
		||||
        """The period."""
 | 
			
		||||
        self.__has_data: bool = has_data
 | 
			
		||||
        """True if there is any data, or False otherwise."""
 | 
			
		||||
        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
 | 
			
		||||
    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 self.__has_data
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def report_chooser(self) -> ReportChooser:
 | 
			
		||||
        """Returns the report chooser.
 | 
			
		||||
 | 
			
		||||
        :return: The report chooser.
 | 
			
		||||
        """
 | 
			
		||||
        return ReportChooser(ReportType.INCOME_STATEMENT,
 | 
			
		||||
                             currency=self.currency,
 | 
			
		||||
                             period=self.period)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def currency_options(self) -> list[OptionLink]:
 | 
			
		||||
        """Returns the currency options.
 | 
			
		||||
 | 
			
		||||
        :return: The currency options.
 | 
			
		||||
        """
 | 
			
		||||
        return self._get_currency_options(
 | 
			
		||||
            lambda x: income_statement_url(x, self.period), self.currency)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IncomeStatement(BaseReport):
 | 
			
		||||
    """The income statement."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, currency: Currency, period: Period):
 | 
			
		||||
        """Constructs an income statement.
 | 
			
		||||
 | 
			
		||||
        :param currency: The currency.
 | 
			
		||||
        :param period: The period.
 | 
			
		||||
        """
 | 
			
		||||
        self.__currency: Currency = currency
 | 
			
		||||
        """The currency."""
 | 
			
		||||
        self.__period: Period = period
 | 
			
		||||
        """The period."""
 | 
			
		||||
        self.__has_data: bool
 | 
			
		||||
        """True if there is any data, or False otherwise."""
 | 
			
		||||
        self.__sections: list[Section]
 | 
			
		||||
        """The sections."""
 | 
			
		||||
        self.__set_data()
 | 
			
		||||
 | 
			
		||||
    def __set_data(self) -> None:
 | 
			
		||||
        """Queries and sets data sections in the income statement.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        balances: list[ReportAccount] = self.__query_balances()
 | 
			
		||||
 | 
			
		||||
        titles: list[BaseAccount] = BaseAccount.query\
 | 
			
		||||
            .filter(BaseAccount.code.in_({"4", "5", "6", "7", "8", "9"})).all()
 | 
			
		||||
        subtitles: list[BaseAccount] = BaseAccount.query\
 | 
			
		||||
            .filter(BaseAccount.code.in_({x.account.base_code[:2]
 | 
			
		||||
                                          for x in balances})).all()
 | 
			
		||||
 | 
			
		||||
        total_titles: dict[str, str] \
 | 
			
		||||
            = {"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, 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:
 | 
			
		||||
            subsections[balance.account.base_code[:2]].accounts.append(balance)
 | 
			
		||||
 | 
			
		||||
        self.__has_data = len(balances) > 0
 | 
			
		||||
        self.__sections = sorted(sections.values(), key=lambda x: x.title.code)
 | 
			
		||||
        total: Decimal = Decimal("0")
 | 
			
		||||
        for section in self.__sections:
 | 
			
		||||
            total = total + section.total
 | 
			
		||||
            section.accumulated.amount = total
 | 
			
		||||
 | 
			
		||||
    def __query_balances(self) -> list[ReportAccount]:
 | 
			
		||||
        """Queries and returns the balances.
 | 
			
		||||
 | 
			
		||||
        :return: The balances.
 | 
			
		||||
        """
 | 
			
		||||
        sub_conditions: list[sa.BinaryExpression] \
 | 
			
		||||
            = [Account.base_code.startswith(str(x)) for x in range(4, 10)]
 | 
			
		||||
        conditions: list[sa.BinaryExpression] \
 | 
			
		||||
            = [JournalEntry.currency_code == self.__currency.code,
 | 
			
		||||
               sa.or_(*sub_conditions)]
 | 
			
		||||
        if self.__period.start is not None:
 | 
			
		||||
            conditions.append(Transaction.date >= self.__period.start)
 | 
			
		||||
        if self.__period.end is not None:
 | 
			
		||||
            conditions.append(Transaction.date <= self.__period.end)
 | 
			
		||||
        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)\
 | 
			
		||||
            .join(Transaction).join(Account)\
 | 
			
		||||
            .filter(*conditions)\
 | 
			
		||||
            .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()}
 | 
			
		||||
        return [ReportAccount(account=accounts[x.id],
 | 
			
		||||
                              amount=x.balance,
 | 
			
		||||
                              url=ledger_url(self.__currency,
 | 
			
		||||
                                             accounts[x.id],
 | 
			
		||||
                                             self.__period))
 | 
			
		||||
                for x in balances]
 | 
			
		||||
 | 
			
		||||
    def csv(self) -> Response:
 | 
			
		||||
        """Returns the report as CSV for download.
 | 
			
		||||
 | 
			
		||||
        :return: The response of the report for download.
 | 
			
		||||
        """
 | 
			
		||||
        filename: str = "income-statement-{currency}-{period}.csv"\
 | 
			
		||||
            .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]:
 | 
			
		||||
        """Composes and returns the CSV rows.
 | 
			
		||||
 | 
			
		||||
        :return: The CSV rows.
 | 
			
		||||
        """
 | 
			
		||||
        total_str: str = gettext("Total")
 | 
			
		||||
        rows: list[CSVRow] = [CSVRow(None, gettext("Amount"))]
 | 
			
		||||
        for section in self.__sections:
 | 
			
		||||
            rows.append(CSVRow(str(section.title).title(), None))
 | 
			
		||||
            for subsection in section.subsections:
 | 
			
		||||
                rows.append(CSVRow(f" {str(subsection.title).title()}", None))
 | 
			
		||||
                for account in subsection.accounts:
 | 
			
		||||
                    rows.append(CSVRow(f"  {str(account.account).title()}",
 | 
			
		||||
                                       account.amount))
 | 
			
		||||
                rows.append(CSVRow(f" {total_str}", subsection.total))
 | 
			
		||||
            rows.append(CSVRow(section.accumulated.title.title(),
 | 
			
		||||
                               section.accumulated.amount))
 | 
			
		||||
            rows.append(CSVRow(None, None))
 | 
			
		||||
        rows = rows[:-1]
 | 
			
		||||
        return rows
 | 
			
		||||
 | 
			
		||||
    def html(self) -> str:
 | 
			
		||||
        """Composes and returns the report as HTML.
 | 
			
		||||
 | 
			
		||||
        :return: The report as HTML.
 | 
			
		||||
        """
 | 
			
		||||
        params: PageParams = PageParams(currency=self.__currency,
 | 
			
		||||
                                        period=self.__period,
 | 
			
		||||
                                        has_data=self.__has_data,
 | 
			
		||||
                                        sections=self.__sections)
 | 
			
		||||
        return render_template("accounting/report/income-statement.html",
 | 
			
		||||
                               report=params)
 | 
			
		||||
							
								
								
									
										216
									
								
								src/accounting/report/reports/journal.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										216
									
								
								src/accounting/report/reports/journal.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,216 @@
 | 
			
		||||
# 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 journal.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from datetime import date
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
from flask import render_template, Response
 | 
			
		||||
from sqlalchemy.orm import selectinload
 | 
			
		||||
 | 
			
		||||
from accounting.locale import gettext
 | 
			
		||||
from accounting.models import Currency, Account, Transaction, JournalEntry
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ReportEntry:
 | 
			
		||||
    """An entry in the report."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, entry: JournalEntry):
 | 
			
		||||
        """Constructs the entry in the report.
 | 
			
		||||
 | 
			
		||||
        :param entry: The journal entry.
 | 
			
		||||
        """
 | 
			
		||||
        self.entry: JournalEntry = entry
 | 
			
		||||
        """The journal entry."""
 | 
			
		||||
        self.transaction: Transaction = entry.transaction
 | 
			
		||||
        """The transaction."""
 | 
			
		||||
        self.currency: Currency = entry.currency
 | 
			
		||||
        """The account."""
 | 
			
		||||
        self.account: Account = entry.account
 | 
			
		||||
        """The account."""
 | 
			
		||||
        self.summary: str | None = entry.summary
 | 
			
		||||
        """The summary."""
 | 
			
		||||
        self.debit: Decimal | None = entry.debit
 | 
			
		||||
        """The debit amount."""
 | 
			
		||||
        self.credit: Decimal | None = entry.credit
 | 
			
		||||
        """The credit amount."""
 | 
			
		||||
        self.amount: Decimal = entry.amount
 | 
			
		||||
        """The amount."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CSVRow(BaseCSVRow):
 | 
			
		||||
    """A row in the CSV."""
 | 
			
		||||
 | 
			
		||||
    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.
 | 
			
		||||
 | 
			
		||||
        :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 PageParams(BasePageParams):
 | 
			
		||||
    """The HTML page parameters."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, period: Period,
 | 
			
		||||
                 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[JournalEntry] = pagination
 | 
			
		||||
        """The pagination."""
 | 
			
		||||
        self.entries: list[JournalEntry] = entries
 | 
			
		||||
        """The entries."""
 | 
			
		||||
        self.period_chooser: PeriodChooser = PeriodChooser(
 | 
			
		||||
            lambda x: journal_url(x))
 | 
			
		||||
        """The period chooser."""
 | 
			
		||||
 | 
			
		||||
    @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.JOURNAL,
 | 
			
		||||
                             period=self.period)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_csv_rows(entries: list[JournalEntry]) -> list[CSVRow]:
 | 
			
		||||
    """Composes and returns the CSV rows from the report entries.
 | 
			
		||||
 | 
			
		||||
    :param entries: The report entries.
 | 
			
		||||
    :return: The CSV rows.
 | 
			
		||||
    """
 | 
			
		||||
    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):
 | 
			
		||||
    """The journal."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, period: Period):
 | 
			
		||||
        """Constructs a journal.
 | 
			
		||||
 | 
			
		||||
        :param period: The period.
 | 
			
		||||
        """
 | 
			
		||||
        self.__period: Period = period
 | 
			
		||||
        """The period."""
 | 
			
		||||
        self.__entries: list[JournalEntry] = self.__query_entries()
 | 
			
		||||
        """The journal entries."""
 | 
			
		||||
 | 
			
		||||
    def __query_entries(self) -> list[JournalEntry]:
 | 
			
		||||
        """Queries and returns the journal entries.
 | 
			
		||||
 | 
			
		||||
        :return: The journal entries.
 | 
			
		||||
        """
 | 
			
		||||
        conditions: list[sa.BinaryExpression] = []
 | 
			
		||||
        if self.__period.start is not None:
 | 
			
		||||
            conditions.append(Transaction.date >= self.__period.start)
 | 
			
		||||
        if self.__period.end is not None:
 | 
			
		||||
            conditions.append(Transaction.date <= self.__period.end)
 | 
			
		||||
        return JournalEntry.query.join(Transaction)\
 | 
			
		||||
            .filter(*conditions)\
 | 
			
		||||
            .order_by(Transaction.date,
 | 
			
		||||
                      JournalEntry.is_debit.desc(),
 | 
			
		||||
                      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-{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[JournalEntry] \
 | 
			
		||||
            = Pagination[JournalEntry](self.__entries)
 | 
			
		||||
        params: PageParams = PageParams(period=self.__period,
 | 
			
		||||
                                        pagination=pagination,
 | 
			
		||||
                                        entries=pagination.list)
 | 
			
		||||
        return render_template("accounting/report/journal.html",
 | 
			
		||||
                               report=params)
 | 
			
		||||
							
								
								
									
										405
									
								
								src/accounting/report/reports/ledger.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										405
									
								
								src/accounting/report/reports/ledger.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,405 @@
 | 
			
		||||
# 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 ledger.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from datetime import date
 | 
			
		||||
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, PeriodChooser
 | 
			
		||||
from accounting.report.utils.base_page_params import BasePageParams
 | 
			
		||||
from accounting.report.utils.base_report import BaseReport
 | 
			
		||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
 | 
			
		||||
    period_spec
 | 
			
		||||
from accounting.report.utils.option_link import OptionLink
 | 
			
		||||
from accounting.report.utils.report_chooser import ReportChooser
 | 
			
		||||
from accounting.report.utils.report_type import ReportType
 | 
			
		||||
from accounting.report.utils.urls import ledger_url
 | 
			
		||||
from accounting.utils.pagination import Pagination
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ReportEntry:
 | 
			
		||||
    """An entry in the report."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, entry: JournalEntry | None = None):
 | 
			
		||||
        """Constructs the entry in the report.
 | 
			
		||||
 | 
			
		||||
        :param entry: The journal entry.
 | 
			
		||||
        """
 | 
			
		||||
        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.summary: str | None = None
 | 
			
		||||
        """The summary."""
 | 
			
		||||
        self.debit: Decimal | None = None
 | 
			
		||||
        """The debit amount."""
 | 
			
		||||
        self.credit: Decimal | None = None
 | 
			
		||||
        """The credit amount."""
 | 
			
		||||
        self.balance: Decimal | None = None
 | 
			
		||||
        """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.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 report entry collector."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, currency: Currency, account: Account, period: Period):
 | 
			
		||||
        """Constructs the report entry collector.
 | 
			
		||||
 | 
			
		||||
        :param currency: The currency.
 | 
			
		||||
        :param account: The account.
 | 
			
		||||
        :param period: The period.
 | 
			
		||||
        """
 | 
			
		||||
        self.__currency: Currency = currency
 | 
			
		||||
        """The currency."""
 | 
			
		||||
        self.__account: Account = account
 | 
			
		||||
        """The account."""
 | 
			
		||||
        self.__period: Period = period
 | 
			
		||||
        """The period"""
 | 
			
		||||
        self.brought_forward: ReportEntry | None
 | 
			
		||||
        """The brought-forward entry."""
 | 
			
		||||
        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) -> ReportEntry | None:
 | 
			
		||||
        """Queries, composes and returns the brought-forward entry.
 | 
			
		||||
 | 
			
		||||
        :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.base_code[0] not in {"1", "2", "3"}:
 | 
			
		||||
            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,
 | 
			
		||||
                    Transaction.date < self.__period.start)
 | 
			
		||||
        balance: int | None = db.session.scalar(select)
 | 
			
		||||
        if balance is None:
 | 
			
		||||
            return None
 | 
			
		||||
        entry: ReportEntry = ReportEntry()
 | 
			
		||||
        entry.is_brought_forward = True
 | 
			
		||||
        entry.date = self.__period.start
 | 
			
		||||
        entry.summary = gettext("Brought forward")
 | 
			
		||||
        if balance > 0:
 | 
			
		||||
            entry.debit = balance
 | 
			
		||||
        elif balance < 0:
 | 
			
		||||
            entry.credit = -balance
 | 
			
		||||
        entry.balance = balance
 | 
			
		||||
        return entry
 | 
			
		||||
 | 
			
		||||
    def __query_entries(self) -> list[ReportEntry]:
 | 
			
		||||
        """Queries and returns the report entries.
 | 
			
		||||
 | 
			
		||||
        :return: The report entries.
 | 
			
		||||
        """
 | 
			
		||||
        conditions: list[sa.BinaryExpression] \
 | 
			
		||||
            = [JournalEntry.currency_code == self.__currency.code,
 | 
			
		||||
               JournalEntry.account_id == self.__account.id]
 | 
			
		||||
        if self.__period.start is not None:
 | 
			
		||||
            conditions.append(Transaction.date >= self.__period.start)
 | 
			
		||||
        if self.__period.end is not None:
 | 
			
		||||
            conditions.append(Transaction.date <= self.__period.end)
 | 
			
		||||
        return [ReportEntry(x) for x in JournalEntry.query.join(Transaction)
 | 
			
		||||
                .filter(*conditions)
 | 
			
		||||
                .order_by(Transaction.date,
 | 
			
		||||
                          JournalEntry.is_debit.desc(),
 | 
			
		||||
                          JournalEntry.no)
 | 
			
		||||
                .options(selectinload(JournalEntry.transaction)).all()]
 | 
			
		||||
 | 
			
		||||
    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: ReportEntry = ReportEntry()
 | 
			
		||||
        entry.is_total = True
 | 
			
		||||
        entry.summary = gettext("Total")
 | 
			
		||||
        entry.debit = sum([x.debit for x in self.entries
 | 
			
		||||
                           if x.debit is not None])
 | 
			
		||||
        entry.credit = sum([x.credit for x in self.entries
 | 
			
		||||
                            if x.credit is not None])
 | 
			
		||||
        entry.balance = entry.debit - entry.credit
 | 
			
		||||
        if self.brought_forward is not None:
 | 
			
		||||
            entry.balance = self.brought_forward.balance + entry.balance
 | 
			
		||||
        return entry
 | 
			
		||||
 | 
			
		||||
    def __populate_balance(self) -> None:
 | 
			
		||||
        """Populates the balance of the entries.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        balance: Decimal = 0 if self.brought_forward is None \
 | 
			
		||||
            else self.brought_forward.balance
 | 
			
		||||
        for entry in self.entries:
 | 
			
		||||
            if entry.debit is not None:
 | 
			
		||||
                balance = balance + entry.debit
 | 
			
		||||
            if entry.credit is not None:
 | 
			
		||||
                balance = balance - entry.credit
 | 
			
		||||
            entry.balance = balance
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CSVRow(BaseCSVRow):
 | 
			
		||||
    """A row in the CSV."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, txn_date: date | str | None,
 | 
			
		||||
                 summary: str | None,
 | 
			
		||||
                 debit: str | Decimal | None,
 | 
			
		||||
                 credit: str | Decimal | None,
 | 
			
		||||
                 balance: str | Decimal | None,
 | 
			
		||||
                 note: str | None):
 | 
			
		||||
        """Constructs a row in the CSV.
 | 
			
		||||
 | 
			
		||||
        :param txn_date: The transaction date.
 | 
			
		||||
        :param summary: The summary.
 | 
			
		||||
        :param debit: The debit amount.
 | 
			
		||||
        :param credit: The credit amount.
 | 
			
		||||
        :param balance: The balance.
 | 
			
		||||
        :param note: The note.
 | 
			
		||||
        """
 | 
			
		||||
        self.date: date | str | None = txn_date
 | 
			
		||||
        """The date."""
 | 
			
		||||
        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.balance: str | Decimal | None = balance
 | 
			
		||||
        """The balance."""
 | 
			
		||||
        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.summary,
 | 
			
		||||
                self.debit, self.credit, self.balance, self.note]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PageParams(BasePageParams):
 | 
			
		||||
    """The HTML page parameters."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, currency: Currency,
 | 
			
		||||
                 account: Account,
 | 
			
		||||
                 period: Period,
 | 
			
		||||
                 has_data: bool,
 | 
			
		||||
                 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 report entries.
 | 
			
		||||
        :param total: The total entry.
 | 
			
		||||
        """
 | 
			
		||||
        self.currency: Currency = currency
 | 
			
		||||
        """The currency."""
 | 
			
		||||
        self.account: Account = account
 | 
			
		||||
        """The account."""
 | 
			
		||||
        self.period: Period = period
 | 
			
		||||
        """The period."""
 | 
			
		||||
        self.__has_data: bool = has_data
 | 
			
		||||
        """True if there is any data, or False otherwise."""
 | 
			
		||||
        self.pagination: Pagination[ReportEntry] = pagination
 | 
			
		||||
        """The pagination."""
 | 
			
		||||
        self.brought_forward: ReportEntry | None = brought_forward
 | 
			
		||||
        """The brought-forward entry."""
 | 
			
		||||
        self.entries: list[ReportEntry] = entries
 | 
			
		||||
        """The entries."""
 | 
			
		||||
        self.total: ReportEntry | None = total
 | 
			
		||||
        """The total entry."""
 | 
			
		||||
        self.period_chooser: PeriodChooser = PeriodChooser(
 | 
			
		||||
            lambda x: ledger_url(currency, account, x))
 | 
			
		||||
        """The period chooser."""
 | 
			
		||||
 | 
			
		||||
    @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 self.__has_data
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def report_chooser(self) -> ReportChooser:
 | 
			
		||||
        """Returns the report chooser.
 | 
			
		||||
 | 
			
		||||
        :return: The report chooser.
 | 
			
		||||
        """
 | 
			
		||||
        return ReportChooser(ReportType.LEDGER,
 | 
			
		||||
                             currency=self.currency,
 | 
			
		||||
                             account=self.account,
 | 
			
		||||
                             period=self.period)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def currency_options(self) -> list[OptionLink]:
 | 
			
		||||
        """Returns the currency options.
 | 
			
		||||
 | 
			
		||||
        :return: The currency options.
 | 
			
		||||
        """
 | 
			
		||||
        return self._get_currency_options(
 | 
			
		||||
            lambda x: ledger_url(x, self.account, self.period), self.currency)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def account_options(self) -> list[OptionLink]:
 | 
			
		||||
        """Returns the account options.
 | 
			
		||||
 | 
			
		||||
        :return: The account options.
 | 
			
		||||
        """
 | 
			
		||||
        in_use: sa.Select = sa.Select(JournalEntry.account_id)\
 | 
			
		||||
            .filter(JournalEntry.currency_code == self.currency.code)\
 | 
			
		||||
            .group_by(JournalEntry.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()]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Ledger(BaseReport):
 | 
			
		||||
    """The ledger."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, currency: Currency, account: Account, period: Period):
 | 
			
		||||
        """Constructs a ledger.
 | 
			
		||||
 | 
			
		||||
        :param currency: The currency.
 | 
			
		||||
        :param account: The account.
 | 
			
		||||
        :param period: The period.
 | 
			
		||||
        """
 | 
			
		||||
        self.__currency: Currency = currency
 | 
			
		||||
        """The currency."""
 | 
			
		||||
        self.__account: Account = account
 | 
			
		||||
        """The account."""
 | 
			
		||||
        self.__period: Period = period
 | 
			
		||||
        """The period."""
 | 
			
		||||
        collector: EntryCollector = EntryCollector(
 | 
			
		||||
            self.__currency, self.__account, self.__period)
 | 
			
		||||
        self.__brought_forward: ReportEntry | None = collector.brought_forward
 | 
			
		||||
        """The brought-forward entry."""
 | 
			
		||||
        self.__entries: list[ReportEntry] = collector.entries
 | 
			
		||||
        """The report entries."""
 | 
			
		||||
        self.__total: ReportEntry | None = collector.total
 | 
			
		||||
        """The total entry."""
 | 
			
		||||
 | 
			
		||||
    def csv(self) -> Response:
 | 
			
		||||
        """Returns the report as CSV for download.
 | 
			
		||||
 | 
			
		||||
        :return: The response of the report for download.
 | 
			
		||||
        """
 | 
			
		||||
        filename: str = "ledger-{currency}-{account}-{period}.csv"\
 | 
			
		||||
            .format(currency=self.__currency.code, account=self.__account.code,
 | 
			
		||||
                    period=period_spec(self.__period))
 | 
			
		||||
        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.
 | 
			
		||||
        """
 | 
			
		||||
        rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Summary"),
 | 
			
		||||
                                     gettext("Debit"), gettext("Credit"),
 | 
			
		||||
                                     gettext("Balance"), gettext("Note"))]
 | 
			
		||||
        if self.__brought_forward is not None:
 | 
			
		||||
            rows.append(CSVRow(self.__brought_forward.date,
 | 
			
		||||
                               self.__brought_forward.summary,
 | 
			
		||||
                               self.__brought_forward.debit,
 | 
			
		||||
                               self.__brought_forward.credit,
 | 
			
		||||
                               self.__brought_forward.balance,
 | 
			
		||||
                               None))
 | 
			
		||||
        rows.extend([CSVRow(x.date, x.summary,
 | 
			
		||||
                            x.debit, x.credit, x.balance, x.note)
 | 
			
		||||
                     for x in self.__entries])
 | 
			
		||||
        if self.__total is not None:
 | 
			
		||||
            rows.append(CSVRow(gettext("Total"), None,
 | 
			
		||||
                               self.__total.debit, self.__total.credit,
 | 
			
		||||
                               self.__total.balance, None))
 | 
			
		||||
        return rows
 | 
			
		||||
 | 
			
		||||
    def html(self) -> str:
 | 
			
		||||
        """Composes and returns the report as HTML.
 | 
			
		||||
 | 
			
		||||
        :return: The report as HTML.
 | 
			
		||||
        """
 | 
			
		||||
        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[ReportEntry] \
 | 
			
		||||
            = Pagination[ReportEntry](all_entries)
 | 
			
		||||
        page_entries: list[ReportEntry] = pagination.list
 | 
			
		||||
        has_data: bool = len(page_entries) > 0
 | 
			
		||||
        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: ReportEntry | None = None
 | 
			
		||||
        if len(page_entries) > 0 and page_entries[-1].is_total:
 | 
			
		||||
            total = page_entries[-1]
 | 
			
		||||
            page_entries = page_entries[:-1]
 | 
			
		||||
        params: PageParams = PageParams(currency=self.__currency,
 | 
			
		||||
                                        account=self.__account,
 | 
			
		||||
                                        period=self.__period,
 | 
			
		||||
                                        has_data=has_data,
 | 
			
		||||
                                        pagination=pagination,
 | 
			
		||||
                                        brought_forward=brought_forward,
 | 
			
		||||
                                        entries=page_entries,
 | 
			
		||||
                                        total=total)
 | 
			
		||||
        return render_template("accounting/report/ledger.html",
 | 
			
		||||
                               report=params)
 | 
			
		||||
							
								
								
									
										204
									
								
								src/accounting/report/reports/search.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										204
									
								
								src/accounting/report/reports/search.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,204 @@
 | 
			
		||||
# The Mia! Accounting Flask Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
 | 
			
		||||
 | 
			
		||||
#  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 search.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
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.pagination import Pagination
 | 
			
		||||
from accounting.utils.query import parse_query_keywords
 | 
			
		||||
from .journal import get_csv_rows
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EntryCollector:
 | 
			
		||||
    """The report entry collector."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        """Constructs the report entry collector."""
 | 
			
		||||
        self.entries: list[JournalEntry] = self.__query_entries()
 | 
			
		||||
        """The report entries."""
 | 
			
		||||
 | 
			
		||||
    def __query_entries(self) -> list[JournalEntry]:
 | 
			
		||||
        """Queries and returns the journal entries.
 | 
			
		||||
 | 
			
		||||
        :return: The journal entries.
 | 
			
		||||
        """
 | 
			
		||||
        keywords: list[str] = parse_query_keywords(request.args.get("q"))
 | 
			
		||||
        if len(keywords) == 0:
 | 
			
		||||
            return []
 | 
			
		||||
        conditions: list[sa.BinaryExpression] = []
 | 
			
		||||
        for k in keywords:
 | 
			
		||||
            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))]
 | 
			
		||||
            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,
 | 
			
		||||
                      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:
 | 
			
		||||
        """Composes and returns the condition to filter the account.
 | 
			
		||||
 | 
			
		||||
        :param k: The keyword.
 | 
			
		||||
        :return: The condition to filter the account.
 | 
			
		||||
        """
 | 
			
		||||
        code: sa.BinaryExpression = Account.base_code + "-" \
 | 
			
		||||
            + sa.func.substr("000" + sa.cast(Account.no, sa.String),
 | 
			
		||||
                             sa.func.char_length(sa.cast(Account.no,
 | 
			
		||||
                                                         sa.String)) + 1)
 | 
			
		||||
        select_l10n: sa.Select = sa.select(AccountL10n.account_id)\
 | 
			
		||||
            .filter(AccountL10n.title.contains(k))
 | 
			
		||||
        conditions: list[sa.BinaryExpression] \
 | 
			
		||||
            = [Account.base_code.contains(k),
 | 
			
		||||
               Account.title_l10n.contains(k),
 | 
			
		||||
               code.contains(k),
 | 
			
		||||
               Account.id.in_(select_l10n)]
 | 
			
		||||
        if k in gettext("Need offset"):
 | 
			
		||||
            conditions.append(Account.is_offset_needed)
 | 
			
		||||
        return sa.select(Account.id).filter(sa.or_(*conditions))
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def __get_currency_condition(k: str) -> sa.Select:
 | 
			
		||||
        """Composes and returns the condition to filter the currency.
 | 
			
		||||
 | 
			
		||||
        :param k: The keyword.
 | 
			
		||||
        :return: The condition to filter the currency.
 | 
			
		||||
        """
 | 
			
		||||
        select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\
 | 
			
		||||
            .filter(CurrencyL10n.name.contains(k))
 | 
			
		||||
        return sa.select(Currency.code).filter(
 | 
			
		||||
            sa.or_(Currency.code.contains(k),
 | 
			
		||||
                   Currency.name_l10n.contains(k),
 | 
			
		||||
                   Currency.code.in_(select_l10n)))
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def __get_transaction_condition(k: str) -> sa.Select:
 | 
			
		||||
        """Composes and returns the condition to filter the transaction.
 | 
			
		||||
 | 
			
		||||
        :param k: The keyword.
 | 
			
		||||
        :return: The condition to filter the transaction.
 | 
			
		||||
        """
 | 
			
		||||
        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)
 | 
			
		||||
        except ValueError:
 | 
			
		||||
            pass
 | 
			
		||||
        try:
 | 
			
		||||
            txn_date = datetime.strptime(k, "%Y/%m")
 | 
			
		||||
            conditions.append(sa.and_(
 | 
			
		||||
                sa.extract("year", Transaction.date) == txn_date.year,
 | 
			
		||||
                sa.extract("month", Transaction.date) == txn_date.month))
 | 
			
		||||
        except ValueError:
 | 
			
		||||
            pass
 | 
			
		||||
        try:
 | 
			
		||||
            txn_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
 | 
			
		||||
            conditions.append(sa.and_(
 | 
			
		||||
                sa.extract("month", Transaction.date) == txn_date.month,
 | 
			
		||||
                sa.extract("day", Transaction.date) == txn_date.day))
 | 
			
		||||
        except ValueError:
 | 
			
		||||
            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, get_csv_rows(self.__entries))
 | 
			
		||||
 | 
			
		||||
    def html(self) -> str:
 | 
			
		||||
        """Composes and returns the report as HTML.
 | 
			
		||||
 | 
			
		||||
        :return: The report as HTML.
 | 
			
		||||
        """
 | 
			
		||||
        pagination: Pagination[JournalEntry] \
 | 
			
		||||
            = Pagination[JournalEntry](self.__entries)
 | 
			
		||||
        params: PageParams = PageParams(pagination=pagination,
 | 
			
		||||
                                        entries=pagination.list)
 | 
			
		||||
        return render_template("accounting/report/search.html",
 | 
			
		||||
                               report=params)
 | 
			
		||||
							
								
								
									
										241
									
								
								src/accounting/report/reports/trial_balance.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										241
									
								
								src/accounting/report/reports/trial_balance.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,241 @@
 | 
			
		||||
# 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 trial balance.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
from flask import Response, render_template
 | 
			
		||||
 | 
			
		||||
from accounting import db
 | 
			
		||||
from accounting.locale import gettext
 | 
			
		||||
from accounting.models import Currency, Account, Transaction, JournalEntry
 | 
			
		||||
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 ReportAccount:
 | 
			
		||||
    """An account in the report."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, account: Account, amount: Decimal, url: str):
 | 
			
		||||
        """Constructs an account in the report.
 | 
			
		||||
 | 
			
		||||
        :param account: The account.
 | 
			
		||||
        :param amount: The amount.
 | 
			
		||||
        :param url: The URL to the ledger of the account.
 | 
			
		||||
        """
 | 
			
		||||
        self.account: Account = account
 | 
			
		||||
        """The account."""
 | 
			
		||||
        self.debit: Decimal | None = amount if amount > 0 else None
 | 
			
		||||
        """The debit amount."""
 | 
			
		||||
        self.credit: Decimal | None = -amount if amount < 0 else None
 | 
			
		||||
        """The credit amount."""
 | 
			
		||||
        self.url: str = url
 | 
			
		||||
        """The URL to the ledger of the account."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Total:
 | 
			
		||||
    """The totals."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, debit: Decimal, credit: Decimal):
 | 
			
		||||
        """Constructs the total in the trial balance.
 | 
			
		||||
 | 
			
		||||
        :param debit: The debit amount.
 | 
			
		||||
        :param credit: The credit amount.
 | 
			
		||||
        """
 | 
			
		||||
        self.debit: Decimal | None = debit
 | 
			
		||||
        """The debit amount."""
 | 
			
		||||
        self.credit: Decimal | None = credit
 | 
			
		||||
        """The credit amount."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CSVRow(BaseCSVRow):
 | 
			
		||||
    """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.
 | 
			
		||||
 | 
			
		||||
        :param text: The text.
 | 
			
		||||
        :param debit: The debit amount.
 | 
			
		||||
        :param credit: The credit amount.
 | 
			
		||||
        """
 | 
			
		||||
        self.text: str | None = text
 | 
			
		||||
        """The text."""
 | 
			
		||||
        self.debit: str | Decimal | None = debit
 | 
			
		||||
        """The debit amount."""
 | 
			
		||||
        self.credit: str | Decimal | None = credit
 | 
			
		||||
        """The credit amount."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def values(self) -> list[str | Decimal | None]:
 | 
			
		||||
        """Returns the values of the row.
 | 
			
		||||
 | 
			
		||||
        :return: The values of the row.
 | 
			
		||||
        """
 | 
			
		||||
        return [self.text, self.debit, self.credit]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PageParams(BasePageParams):
 | 
			
		||||
    """The HTML page parameters."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, currency: Currency,
 | 
			
		||||
                 period: Period,
 | 
			
		||||
                 accounts: list[ReportAccount],
 | 
			
		||||
                 total: Total):
 | 
			
		||||
        """Constructs the HTML page parameters.
 | 
			
		||||
 | 
			
		||||
        :param currency: The currency.
 | 
			
		||||
        :param period: The period.
 | 
			
		||||
        :param accounts: The accounts in the trial balance.
 | 
			
		||||
        :param total: The total of the trial balance.
 | 
			
		||||
        """
 | 
			
		||||
        self.currency: Currency = currency
 | 
			
		||||
        """The currency."""
 | 
			
		||||
        self.period: Period = period
 | 
			
		||||
        """The period."""
 | 
			
		||||
        self.accounts: list[ReportAccount] = accounts
 | 
			
		||||
        """The accounts in the trial balance."""
 | 
			
		||||
        self.total: Total = total
 | 
			
		||||
        """The total of the trial balance."""
 | 
			
		||||
        self.period_chooser: PeriodChooser = PeriodChooser(
 | 
			
		||||
            lambda x: trial_balance_url(currency, x))
 | 
			
		||||
        """The period chooser."""
 | 
			
		||||
 | 
			
		||||
    @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.accounts) > 0
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def report_chooser(self) -> ReportChooser:
 | 
			
		||||
        """Returns the report chooser.
 | 
			
		||||
 | 
			
		||||
        :return: The report chooser.
 | 
			
		||||
        """
 | 
			
		||||
        return ReportChooser(ReportType.TRIAL_BALANCE,
 | 
			
		||||
                             currency=self.currency,
 | 
			
		||||
                             period=self.period)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def currency_options(self) -> list[OptionLink]:
 | 
			
		||||
        """Returns the currency options.
 | 
			
		||||
 | 
			
		||||
        :return: The currency options.
 | 
			
		||||
        """
 | 
			
		||||
        return self._get_currency_options(
 | 
			
		||||
            lambda x: trial_balance_url(x, self.period), self.currency)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TrialBalance(BaseReport):
 | 
			
		||||
    """The trial balance."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, currency: Currency, period: Period):
 | 
			
		||||
        """Constructs a trial balance.
 | 
			
		||||
 | 
			
		||||
        :param currency: The currency.
 | 
			
		||||
        :param period: The period.
 | 
			
		||||
        """
 | 
			
		||||
        self.__currency: Currency = currency
 | 
			
		||||
        """The currency."""
 | 
			
		||||
        self.__period: Period = period
 | 
			
		||||
        """The period."""
 | 
			
		||||
        self.__accounts: list[ReportAccount]
 | 
			
		||||
        """The accounts in the trial balance."""
 | 
			
		||||
        self.__total: Total
 | 
			
		||||
        """The total of the trial balance."""
 | 
			
		||||
        self.__set_data()
 | 
			
		||||
 | 
			
		||||
    def __set_data(self) -> None:
 | 
			
		||||
        """Queries and sets data sections in the trial balance.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        conditions: list[sa.BinaryExpression] \
 | 
			
		||||
            = [JournalEntry.currency_code == self.__currency.code]
 | 
			
		||||
        if self.__period.start is not None:
 | 
			
		||||
            conditions.append(Transaction.date >= self.__period.start)
 | 
			
		||||
        if self.__period.end is not None:
 | 
			
		||||
            conditions.append(Transaction.date <= self.__period.end)
 | 
			
		||||
        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)\
 | 
			
		||||
            .join(Transaction).join(Account)\
 | 
			
		||||
            .filter(*conditions)\
 | 
			
		||||
            .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()}
 | 
			
		||||
        self.__accounts = [ReportAccount(account=accounts[x.id],
 | 
			
		||||
                                         amount=x.balance,
 | 
			
		||||
                                         url=ledger_url(self.__currency,
 | 
			
		||||
                                                        accounts[x.id],
 | 
			
		||||
                                                        self.__period))
 | 
			
		||||
                           for x in balances]
 | 
			
		||||
        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]))
 | 
			
		||||
 | 
			
		||||
    def csv(self) -> Response:
 | 
			
		||||
        """Returns the report as CSV for download.
 | 
			
		||||
 | 
			
		||||
        :return: The response of the report for download.
 | 
			
		||||
        """
 | 
			
		||||
        filename: str = "trial-balance-{currency}-{period}.csv"\
 | 
			
		||||
            .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]:
 | 
			
		||||
        """Composes and returns the CSV rows.
 | 
			
		||||
 | 
			
		||||
        :return: The CSV rows.
 | 
			
		||||
        """
 | 
			
		||||
        rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Debit"),
 | 
			
		||||
                                     gettext("Credit"))]
 | 
			
		||||
        rows.extend([CSVRow(str(x.account).title(), x.debit, x.credit)
 | 
			
		||||
                     for x in self.__accounts])
 | 
			
		||||
        rows.append(CSVRow(gettext("Total"), self.__total.debit,
 | 
			
		||||
                           self.__total.credit))
 | 
			
		||||
        return rows
 | 
			
		||||
 | 
			
		||||
    def html(self) -> str:
 | 
			
		||||
        """Composes and returns the report as HTML.
 | 
			
		||||
 | 
			
		||||
        :return: The report as HTML.
 | 
			
		||||
        """
 | 
			
		||||
        params: PageParams = PageParams(currency=self.__currency,
 | 
			
		||||
                                        period=self.__period,
 | 
			
		||||
                                        accounts=self.__accounts,
 | 
			
		||||
                                        total=self.__total)
 | 
			
		||||
        return render_template("accounting/report/trial-balance.html",
 | 
			
		||||
                               report=params)
 | 
			
		||||
							
								
								
									
										37
									
								
								src/accounting/report/template_filters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/accounting/report/template_filters.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
			
		||||
# 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 template filters for the reports.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
from accounting.template_filters import format_amount as core_format_amount
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def format_amount(value: Decimal | None) -> str | None:
 | 
			
		||||
    """Formats an amount for the report.
 | 
			
		||||
 | 
			
		||||
    :param value: The amount.
 | 
			
		||||
    :return: The formatted amount text.
 | 
			
		||||
    """
 | 
			
		||||
    if value is None:
 | 
			
		||||
        return ""
 | 
			
		||||
    is_negative: bool = value < 0
 | 
			
		||||
    formatted: str = core_format_amount(abs(value))
 | 
			
		||||
    if is_negative:
 | 
			
		||||
        formatted = f"({formatted})"
 | 
			
		||||
    return formatted
 | 
			
		||||
							
								
								
									
										19
									
								
								src/accounting/report/utils/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/accounting/report/utils/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
			
		||||
# 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 for the reports.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
							
								
								
									
										88
									
								
								src/accounting/report/utils/base_page_params.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								src/accounting/report/utils/base_page_params.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,88 @@
 | 
			
		||||
# The Mia! Accounting Flask Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/6
 | 
			
		||||
 | 
			
		||||
#  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 page parameters of a report.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import typing as t
 | 
			
		||||
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 BasePageParams(ABC):
 | 
			
		||||
    """The base HTML page parameters class."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    @abstractmethod
 | 
			
		||||
    def has_data(self) -> bool:
 | 
			
		||||
        """Returns whether there is any data on the page.
 | 
			
		||||
 | 
			
		||||
        :return: True if there is any data, or False otherwise.
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    @abstractmethod
 | 
			
		||||
    def report_chooser(self) -> ReportChooser:
 | 
			
		||||
        """Returns the report chooser.
 | 
			
		||||
 | 
			
		||||
        :return: The report chooser.
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def txn_types(self) -> t.Type[TransactionType]:
 | 
			
		||||
        """Returns the transaction types.
 | 
			
		||||
 | 
			
		||||
        :return: The transaction types.
 | 
			
		||||
        """
 | 
			
		||||
        return TransactionType
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def csv_uri(self) -> str:
 | 
			
		||||
        uri: str = request.full_path if request.query_string \
 | 
			
		||||
            else request.path
 | 
			
		||||
        uri_p: ParseResult = urlparse(uri)
 | 
			
		||||
        params: list[tuple[str, str]] = parse_qsl(uri_p.query)
 | 
			
		||||
        params = [x for x in params if x[0] != "as"]
 | 
			
		||||
        params.append(("as", "csv"))
 | 
			
		||||
        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()]
 | 
			
		||||
							
								
								
									
										40
									
								
								src/accounting/report/utils/base_report.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/accounting/report/utils/base_report.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,40 @@
 | 
			
		||||
# The Mia! Accounting Flask Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
 | 
			
		||||
 | 
			
		||||
#  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 base report.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from abc import ABC, abstractmethod
 | 
			
		||||
 | 
			
		||||
from flask import Response
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BaseReport(ABC):
 | 
			
		||||
    """The base report class."""
 | 
			
		||||
 | 
			
		||||
    @abstractmethod
 | 
			
		||||
    def csv(self) -> Response:
 | 
			
		||||
        """Returns the report as CSV for download.
 | 
			
		||||
 | 
			
		||||
        :return: The response of the report for download.
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
    @abstractmethod
 | 
			
		||||
    def html(self) -> str:
 | 
			
		||||
        """Composes and returns the report as HTML.
 | 
			
		||||
 | 
			
		||||
        :return: The report as HTML.
 | 
			
		||||
        """
 | 
			
		||||
							
								
								
									
										108
									
								
								src/accounting/report/utils/csv_export.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								src/accounting/report/utils/csv_export.py
									
									
									
									
									
										Normal 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")
 | 
			
		||||
							
								
								
									
										86
									
								
								src/accounting/report/utils/ie_account.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								src/accounting/report/utils/ie_account.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,86 @@
 | 
			
		||||
# 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 pseudo account for the income and expenses log.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import typing as t
 | 
			
		||||
 | 
			
		||||
from flask import current_app
 | 
			
		||||
 | 
			
		||||
from accounting.locale import gettext
 | 
			
		||||
from accounting.models import Account
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IncomeExpensesAccount:
 | 
			
		||||
    """The pseudo account for the income and expenses log."""
 | 
			
		||||
    CURRENT_AL_CODE: str = "0000-000"
 | 
			
		||||
    """The account code for the current assets and liabilities."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, account: Account | None = None):
 | 
			
		||||
        """Constructs the pseudo account for the income and expenses log.
 | 
			
		||||
 | 
			
		||||
        :param account: The actual account.
 | 
			
		||||
        """
 | 
			
		||||
        self.account: Account | None = account
 | 
			
		||||
        self.id: int = -1 if account is None else account.id
 | 
			
		||||
        """The ID."""
 | 
			
		||||
        self.code: str = "" if account is None else account.code
 | 
			
		||||
        """The code."""
 | 
			
		||||
        self.title: str = "" if account is None else account.title
 | 
			
		||||
        """The title."""
 | 
			
		||||
        self.str: str = "" if account is None else str(account)
 | 
			
		||||
        """The string representation of the account."""
 | 
			
		||||
 | 
			
		||||
    def __str__(self) -> str:
 | 
			
		||||
        """Returns the string representation of the account.
 | 
			
		||||
 | 
			
		||||
        :return: The string representation of the account.
 | 
			
		||||
        """
 | 
			
		||||
        return self.str
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def current_assets_and_liabilities(cls) -> t.Self:
 | 
			
		||||
        """Returns the pseudo account for current assets and liabilities.
 | 
			
		||||
 | 
			
		||||
        :return: The pseudo account for current assets and liabilities.
 | 
			
		||||
        """
 | 
			
		||||
        account: cls = cls()
 | 
			
		||||
        account.id = 0
 | 
			
		||||
        account.code = cls.CURRENT_AL_CODE
 | 
			
		||||
        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))
 | 
			
		||||
							
								
								
									
										41
									
								
								src/accounting/report/utils/option_link.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								src/accounting/report/utils/option_link.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,41 @@
 | 
			
		||||
# The Mia! Accounting Flask Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/5
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
#  You may obtain a copy of the License at
 | 
			
		||||
#
 | 
			
		||||
#      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
#
 | 
			
		||||
#  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
"""The option link.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OptionLink:
 | 
			
		||||
    """An option link."""
 | 
			
		||||
 | 
			
		||||
    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 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."""
 | 
			
		||||
							
								
								
									
										159
									
								
								src/accounting/report/utils/report_chooser.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								src/accounting/report/utils/report_chooser.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,159 @@
 | 
			
		||||
# 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 report chooser.
 | 
			
		||||
 | 
			
		||||
This file is largely taken from the NanoParma ERP project, first written in
 | 
			
		||||
2021/9/16 by imacat (imacat@nanoparma.com).
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import re
 | 
			
		||||
import typing as t
 | 
			
		||||
 | 
			
		||||
from flask_babel import LazyString
 | 
			
		||||
 | 
			
		||||
from accounting import db
 | 
			
		||||
from accounting.locale import gettext
 | 
			
		||||
from accounting.models import Currency, Account
 | 
			
		||||
from accounting.report.period import Period, get_period
 | 
			
		||||
from accounting.template_globals import default_currency_code
 | 
			
		||||
from .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:
 | 
			
		||||
    """The report chooser."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, active_report: ReportType,
 | 
			
		||||
                 period: Period | None = None,
 | 
			
		||||
                 currency: Currency | None = None,
 | 
			
		||||
                 account: Account | None = None):
 | 
			
		||||
        """Constructs the report chooser.
 | 
			
		||||
 | 
			
		||||
        :param active_report: The active report.
 | 
			
		||||
        :param period: The period.
 | 
			
		||||
        :param currency: The currency.
 | 
			
		||||
        :param account: The account.
 | 
			
		||||
        """
 | 
			
		||||
        self.__active_report: ReportType = active_report
 | 
			
		||||
        """The currently active report."""
 | 
			
		||||
        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.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)
 | 
			
		||||
        self.__reports.append(self.__trial_balance)
 | 
			
		||||
        self.__reports.append(self.__income_statement)
 | 
			
		||||
        self.__reports.append(self.__balance_sheet)
 | 
			
		||||
        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:
 | 
			
		||||
        """Returns the journal.
 | 
			
		||||
 | 
			
		||||
        :return: The 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:
 | 
			
		||||
        """Returns the ledger.
 | 
			
		||||
 | 
			
		||||
        :return: The 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 log.
 | 
			
		||||
 | 
			
		||||
        :return: The income and expenses log.
 | 
			
		||||
        """
 | 
			
		||||
        account: Account = self.__account
 | 
			
		||||
        if not re.match(r"[12][12]", account.base_code):
 | 
			
		||||
            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:
 | 
			
		||||
        """Returns the trial balance.
 | 
			
		||||
 | 
			
		||||
        :return: The 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:
 | 
			
		||||
        """Returns the income statement.
 | 
			
		||||
 | 
			
		||||
        :return: The 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:
 | 
			
		||||
        """Returns the balance sheet.
 | 
			
		||||
 | 
			
		||||
        :return: The 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.
 | 
			
		||||
 | 
			
		||||
        :return: The iteration of the reports.
 | 
			
		||||
        """
 | 
			
		||||
        return iter(self.__reports)
 | 
			
		||||
							
								
								
									
										38
									
								
								src/accounting/report/utils/report_type.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/accounting/report/utils/report_type.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,38 @@
 | 
			
		||||
# 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 report types.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from enum import Enum
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ReportType(Enum):
 | 
			
		||||
    """The report types."""
 | 
			
		||||
    JOURNAL: str = "journal"
 | 
			
		||||
    """The journal."""
 | 
			
		||||
    LEDGER: str = "ledger"
 | 
			
		||||
    """The ledger."""
 | 
			
		||||
    INCOME_EXPENSES: str = "income-expenses"
 | 
			
		||||
    """The income and expenses log."""
 | 
			
		||||
    TRIAL_BALANCE: str = "trial-balance"
 | 
			
		||||
    """The trial balance."""
 | 
			
		||||
    INCOME_STATEMENT: str = "income-statement"
 | 
			
		||||
    """The income statement."""
 | 
			
		||||
    BALANCE_SHEET: str = "balance-sheet"
 | 
			
		||||
    """The balance sheet."""
 | 
			
		||||
    SEARCH: str = "search"
 | 
			
		||||
    """The search."""
 | 
			
		||||
							
								
								
									
										117
									
								
								src/accounting/report/utils/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								src/accounting/report/utils/urls.py
									
									
									
									
									
										Normal 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)
 | 
			
		||||
							
								
								
									
										299
									
								
								src/accounting/report/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										299
									
								
								src/accounting/report/views.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,299 @@
 | 
			
		||||
# The Mia! Accounting Flask Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
#  You may obtain a copy of the License at
 | 
			
		||||
#
 | 
			
		||||
#      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
#
 | 
			
		||||
#  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
"""The views for the report management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
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 .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() -> str | Response:
 | 
			
		||||
    """Returns the journal in the default period.
 | 
			
		||||
 | 
			
		||||
    :return: The journal in the default period.
 | 
			
		||||
    """
 | 
			
		||||
    return __get_journal(get_period())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("journal/<period:period>", endpoint="journal")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def get_journal(period: Period) -> str | Response:
 | 
			
		||||
    """Returns the journal.
 | 
			
		||||
 | 
			
		||||
    :param period: The period.
 | 
			
		||||
    :return: The journal in the period.
 | 
			
		||||
    """
 | 
			
		||||
    return __get_journal(period)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __get_journal(period: Period) -> str | Response:
 | 
			
		||||
    """Returns the journal.
 | 
			
		||||
 | 
			
		||||
    :param period: The period.
 | 
			
		||||
    :return: The journal in the period.
 | 
			
		||||
    """
 | 
			
		||||
    report: Journal = Journal(period)
 | 
			
		||||
    if "as" in request.args and request.args["as"] == "csv":
 | 
			
		||||
        return report.csv()
 | 
			
		||||
    return report.html()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("ledger/<currency:currency>/<account:account>",
 | 
			
		||||
        endpoint="ledger-default")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
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(currency, account, get_period())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("ledger/<currency:currency>/<account:account>/<period:period>",
 | 
			
		||||
        endpoint="ledger")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def get_ledger(currency: Currency, account: Account, period: Period) \
 | 
			
		||||
        -> str | Response:
 | 
			
		||||
    """Returns the ledger.
 | 
			
		||||
 | 
			
		||||
    :param currency: The currency.
 | 
			
		||||
    :param account: The account.
 | 
			
		||||
    :param period: The period.
 | 
			
		||||
    :return: The ledger in the period.
 | 
			
		||||
    """
 | 
			
		||||
    return __get_ledger(currency, account, period)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __get_ledger(currency: Currency, account: Account, period: Period) \
 | 
			
		||||
        -> str | Response:
 | 
			
		||||
    """Returns the ledger.
 | 
			
		||||
 | 
			
		||||
    :param currency: The currency.
 | 
			
		||||
    :param account: The account.
 | 
			
		||||
    :param period: The period.
 | 
			
		||||
    :return: The ledger in the period.
 | 
			
		||||
    """
 | 
			
		||||
    report: Ledger = Ledger(currency, account, period)
 | 
			
		||||
    if "as" in request.args and request.args["as"] == "csv":
 | 
			
		||||
        return report.csv()
 | 
			
		||||
    return report.html()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("income-expenses/<currency:currency>/<ieAccount:account>",
 | 
			
		||||
        endpoint="income-expenses-default")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def get_default_income_expenses(currency: Currency,
 | 
			
		||||
                                account: IncomeExpensesAccount) \
 | 
			
		||||
        -> str | Response:
 | 
			
		||||
    """Returns the income and expenses log in the default period.
 | 
			
		||||
 | 
			
		||||
    :param currency: The currency.
 | 
			
		||||
    :param account: The account.
 | 
			
		||||
    :return: The income and expenses log in the default period.
 | 
			
		||||
    """
 | 
			
		||||
    return __get_income_expenses(currency, account, get_period())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get(
 | 
			
		||||
    "income-expenses/<currency:currency>/<ieAccount:account>/<period:period>",
 | 
			
		||||
    endpoint="income-expenses")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
 | 
			
		||||
                        period: Period) -> str | Response:
 | 
			
		||||
    """Returns the income and expenses log.
 | 
			
		||||
 | 
			
		||||
    :param currency: The currency.
 | 
			
		||||
    :param account: The account.
 | 
			
		||||
    :param period: The period.
 | 
			
		||||
    :return: The income and expenses log in the period.
 | 
			
		||||
    """
 | 
			
		||||
    return __get_income_expenses(currency, account, period)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
 | 
			
		||||
                          period: Period) -> str | Response:
 | 
			
		||||
    """Returns the income and expenses log.
 | 
			
		||||
 | 
			
		||||
    :param currency: The currency.
 | 
			
		||||
    :param account: The account.
 | 
			
		||||
    :param period: 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":
 | 
			
		||||
        return report.csv()
 | 
			
		||||
    return report.html()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("trial-balance/<currency:currency>",
 | 
			
		||||
        endpoint="trial-balance-default")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
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(currency, get_period())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("trial-balance/<currency:currency>/<period:period>",
 | 
			
		||||
        endpoint="trial-balance")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
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(currency, period)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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.
 | 
			
		||||
    """
 | 
			
		||||
    report: TrialBalance = TrialBalance(currency, period)
 | 
			
		||||
    if "as" in request.args and request.args["as"] == "csv":
 | 
			
		||||
        return report.csv()
 | 
			
		||||
    return report.html()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("income-statement/<currency:currency>",
 | 
			
		||||
        endpoint="income-statement-default")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
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(currency, get_period())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("income-statement/<currency:currency>/<period:period>",
 | 
			
		||||
        endpoint="income-statement")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
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(currency, period)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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.
 | 
			
		||||
    """
 | 
			
		||||
    report: IncomeStatement = IncomeStatement(currency, period)
 | 
			
		||||
    if "as" in request.args and request.args["as"] == "csv":
 | 
			
		||||
        return report.csv()
 | 
			
		||||
    return report.html()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("balance-sheet/<currency:currency>",
 | 
			
		||||
        endpoint="balance-sheet-default")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
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(currency, get_period())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("balance-sheet/<currency:currency>/<period:period>",
 | 
			
		||||
        endpoint="balance-sheet")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def get_balance_sheet(currency: Currency, period: Period) \
 | 
			
		||||
        -> str | Response:
 | 
			
		||||
    """Returns the balance sheet.
 | 
			
		||||
 | 
			
		||||
    :param currency: The currency.
 | 
			
		||||
    :param period: The period.
 | 
			
		||||
    :return: The balance sheet in the period.
 | 
			
		||||
    """
 | 
			
		||||
    return __get_balance_sheet(currency, period)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __get_balance_sheet(currency: Currency, period: Period) \
 | 
			
		||||
        -> str | Response:
 | 
			
		||||
    """Returns the balance sheet.
 | 
			
		||||
 | 
			
		||||
    :param currency: The currency.
 | 
			
		||||
    :param period: The period.
 | 
			
		||||
    :return: The balance sheet in the period.
 | 
			
		||||
    """
 | 
			
		||||
    report: BalanceSheet = BalanceSheet(currency, period)
 | 
			
		||||
    if "as" in request.args and request.args["as"] == "csv":
 | 
			
		||||
        return report.csv()
 | 
			
		||||
    return report.html()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("search", endpoint="search")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def search() -> str | Response:
 | 
			
		||||
    """Returns the search result.
 | 
			
		||||
 | 
			
		||||
    :return: The search result.
 | 
			
		||||
    """
 | 
			
		||||
    report: Search = Search()
 | 
			
		||||
    if "as" in request.args and request.args["as"] == "csv":
 | 
			
		||||
        return report.csv()
 | 
			
		||||
    return report.html()
 | 
			
		||||
@@ -24,16 +24,6 @@
 | 
			
		||||
.accounting-clickable {
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
.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;
 | 
			
		||||
}
 | 
			
		||||
@@ -42,6 +32,61 @@
 | 
			
		||||
    background-color: #D3D3D4;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** 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 {
 | 
			
		||||
    padding: 2em 1.5em;
 | 
			
		||||
@@ -58,6 +103,15 @@
 | 
			
		||||
    font-size: 1.4rem;
 | 
			
		||||
    color: #373b3e;
 | 
			
		||||
}
 | 
			
		||||
.accounting-sheet {
 | 
			
		||||
    padding: 2em 1.5em;
 | 
			
		||||
    margin: 1em;
 | 
			
		||||
    background-color: #F8F9FA;
 | 
			
		||||
    box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
 | 
			
		||||
}
 | 
			
		||||
.accounting-sheet h2 {
 | 
			
		||||
    border-bottom: thick double slategray;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** The option selector */
 | 
			
		||||
.accounting-selector-list {
 | 
			
		||||
@@ -79,15 +133,6 @@
 | 
			
		||||
.accounting-entry-control {
 | 
			
		||||
    border-color: transparent;
 | 
			
		||||
}
 | 
			
		||||
.accounting-transaction-card {
 | 
			
		||||
    padding: 2em 1.5em;
 | 
			
		||||
    margin: 1em;
 | 
			
		||||
    background-color: #F8F9FA;
 | 
			
		||||
    box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
 | 
			
		||||
}
 | 
			
		||||
.accounting-transaction-card h2 {
 | 
			
		||||
    border-bottom: thick double slategray;
 | 
			
		||||
}
 | 
			
		||||
.accounting-list-group-stripped .list-group-item:nth-child(2n+1) {
 | 
			
		||||
    background-color: #f2f2f2;
 | 
			
		||||
}
 | 
			
		||||
@@ -109,6 +154,134 @@
 | 
			
		||||
    border-top: thick double slategray;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* The report table */
 | 
			
		||||
.accounting-report-table-header, .accounting-report-table-footer {
 | 
			
		||||
    font-size: 1.2rem;
 | 
			
		||||
    font-weight: bolder;
 | 
			
		||||
}
 | 
			
		||||
.accounting-report-table-header {
 | 
			
		||||
    border-bottom: thin solid slategray;
 | 
			
		||||
}
 | 
			
		||||
.accounting-report-table-footer {
 | 
			
		||||
    font-style: italic;
 | 
			
		||||
    border-top: thin solid slategray;
 | 
			
		||||
}
 | 
			
		||||
.accounting-report-table-row {
 | 
			
		||||
    display: grid;
 | 
			
		||||
}
 | 
			
		||||
a.accounting-report-table-row {
 | 
			
		||||
    color: inherit;
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
}
 | 
			
		||||
.accounting-report-table-row > div {
 | 
			
		||||
    padding: .5rem;
 | 
			
		||||
}
 | 
			
		||||
.accounting-report-table .accounting-amount {
 | 
			
		||||
    text-align: right;
 | 
			
		||||
}
 | 
			
		||||
.accounting-report-table-body .accounting-amount {
 | 
			
		||||
    font-style: italic;
 | 
			
		||||
}
 | 
			
		||||
.accounting-report-table-body .accounting-report-table-row:nth-child(2n+1) {
 | 
			
		||||
    background-color: #f2f2f2;
 | 
			
		||||
}
 | 
			
		||||
.accounting-report-table-body .accounting-report-table-row:hover {
 | 
			
		||||
    background-color: rgba(0, 0, 0, 0.075);
 | 
			
		||||
}
 | 
			
		||||
.accounting-journal-table .accounting-report-table-row {
 | 
			
		||||
    grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr;
 | 
			
		||||
}
 | 
			
		||||
.accounting-ledger-table .accounting-report-table-row {
 | 
			
		||||
    grid-template-columns: 1fr 4fr 1fr 1fr 1fr;
 | 
			
		||||
}
 | 
			
		||||
.accounting-ledger-table .accounting-report-table-footer .accounting-report-table-row {
 | 
			
		||||
    grid-template-columns: 5fr 1fr 1fr 1fr;
 | 
			
		||||
}
 | 
			
		||||
.accounting-income-expenses-table .accounting-report-table-row {
 | 
			
		||||
    grid-template-columns: 1fr 2fr 4fr 1fr 1fr 1fr;
 | 
			
		||||
}
 | 
			
		||||
.accounting-income-expenses-table .accounting-report-table-footer .accounting-report-table-row {
 | 
			
		||||
    grid-template-columns: 7fr 1fr 1fr 1fr;
 | 
			
		||||
}
 | 
			
		||||
.accounting-trial-balance-table .accounting-report-table-header {
 | 
			
		||||
    border-bottom: thick double slategray;
 | 
			
		||||
}
 | 
			
		||||
.accounting-trial-balance-table .accounting-report-table-footer {
 | 
			
		||||
    border-top: thick double slategray;
 | 
			
		||||
}
 | 
			
		||||
.accounting-trial-balance-table .accounting-report-table-row {
 | 
			
		||||
    grid-template-columns: 3fr 1fr 1fr;
 | 
			
		||||
}
 | 
			
		||||
.accounting-income-statement-table .accounting-report-table-body {
 | 
			
		||||
    border-top: thick double slategray;
 | 
			
		||||
    border-bottom: thick double slategray;
 | 
			
		||||
}
 | 
			
		||||
.accounting-income-statement-table .accounting-report-table-row {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    justify-content: space-between;
 | 
			
		||||
}
 | 
			
		||||
.accounting-income-statement-table .accounting-report-table-header .accounting-report-table-row {
 | 
			
		||||
    display: block;
 | 
			
		||||
}
 | 
			
		||||
.accounting-income-statement-section, .accounting-income-statement-total {
 | 
			
		||||
    font-size: 1.2rem;
 | 
			
		||||
    font-weight: bolder;
 | 
			
		||||
}
 | 
			
		||||
.accounting-income-statement-subsection, .accounting-income-statement-subtotal {
 | 
			
		||||
    font-size: 1.1rem;
 | 
			
		||||
}
 | 
			
		||||
.accounting-income-statement-subtotal {
 | 
			
		||||
    border-top: thin solid darkslategray;
 | 
			
		||||
}
 | 
			
		||||
/* Indents */
 | 
			
		||||
.accounting-income-statement-subsection {
 | 
			
		||||
    margin-left: 0.5rem;
 | 
			
		||||
    margin-right: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
.accounting-income-statement-account, .accounting-income-statement-subtotal {
 | 
			
		||||
    margin-left: 1rem;
 | 
			
		||||
    margin-right: 1rem;
 | 
			
		||||
}
 | 
			
		||||
/* A visual blank line between categories */
 | 
			
		||||
.accounting-income-statement-section {
 | 
			
		||||
    margin-top: 2rem;
 | 
			
		||||
}
 | 
			
		||||
.accounting-income-statement-section:first-child {
 | 
			
		||||
    margin-top: 0;
 | 
			
		||||
}
 | 
			
		||||
.accounting-income-statement-total {
 | 
			
		||||
    margin-bottom: 2rem;
 | 
			
		||||
}
 | 
			
		||||
.accounting-income-statement-total:last-child {
 | 
			
		||||
    margin-bottom: 0;
 | 
			
		||||
}
 | 
			
		||||
.accounting-balance-sheet-section, .accounting-balance-sheet-total {
 | 
			
		||||
    font-size: 1.2rem;
 | 
			
		||||
    font-weight: bolder;
 | 
			
		||||
}
 | 
			
		||||
.accounting-balance-sheet-section {
 | 
			
		||||
    border-bottom: thick double darkslategray;
 | 
			
		||||
}
 | 
			
		||||
.accounting-balance-sheet-total {
 | 
			
		||||
    border-top: thick double darkslategray;
 | 
			
		||||
}
 | 
			
		||||
.accounting-balance-sheet-subtotal {
 | 
			
		||||
    font-size: 1.1rem;
 | 
			
		||||
    font-weight: bolder;
 | 
			
		||||
    border-top: thick double darkslategray;
 | 
			
		||||
}
 | 
			
		||||
.accounting-balance-sheet-account {
 | 
			
		||||
    margin-left: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
.accounting-balance-sheet-total .accounting-amount, .accounting-balance-sheet-subtotal, .accounting-amount {
 | 
			
		||||
    font-style: italic;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* The accounting report */
 | 
			
		||||
.accounting-mobile-journal-credit {
 | 
			
		||||
    padding-left: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* The Material Design text field (floating form control in Bootstrap) */
 | 
			
		||||
.accounting-material-text-field {
 | 
			
		||||
    position: relative;
 | 
			
		||||
 
 | 
			
		||||
@@ -20,164 +20,339 @@
 | 
			
		||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
 * First written: 2023/2/1
 | 
			
		||||
 */
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
// Initializes the page JavaScript.
 | 
			
		||||
document.addEventListener("DOMContentLoaded", function () {
 | 
			
		||||
    initializeBaseAccountSelector();
 | 
			
		||||
    document.getElementById("accounting-base-code")
 | 
			
		||||
        .onchange = validateBase;
 | 
			
		||||
    document.getElementById("accounting-title")
 | 
			
		||||
        .onchange = validateTitle;
 | 
			
		||||
    document.getElementById("accounting-form")
 | 
			
		||||
        .onsubmit = validateForm;
 | 
			
		||||
document.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
    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", function () {
 | 
			
		||||
        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", function () {
 | 
			
		||||
        if (baseCode.value === "") {
 | 
			
		||||
            base.classList.remove("accounting-not-empty");
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
    for (const option of options) {
 | 
			
		||||
        option.onclick = function () {
 | 
			
		||||
            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-offset-needed option
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #isOffsetNeededControl;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The is-offset-needed option
 | 
			
		||||
     * @type {HTMLInputElement}
 | 
			
		||||
     */
 | 
			
		||||
    #isOffsetNeeded;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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.#isOffsetNeededControl = document.getElementById("accounting-is-offset-needed-control");
 | 
			
		||||
        this.#isOffsetNeeded = document.getElementById("accounting-is-offset-needed");
 | 
			
		||||
        this.#formElement.onsubmit = () => {
 | 
			
		||||
            return this.#validateForm();
 | 
			
		||||
        };
 | 
			
		||||
        this.#baseControl.onclick = () => {
 | 
			
		||||
            this.#baseControl.classList.add("accounting-not-empty");
 | 
			
		||||
            this.#baseAccountSelector.onOpen(this.#baseCode.value);
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
    btnClear.onclick = function () {
 | 
			
		||||
        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");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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"].includes(code.substring(0, 1))) {
 | 
			
		||||
            this.#isOffsetNeededControl.classList.remove("d-none");
 | 
			
		||||
            this.#isOffsetNeeded.disabled = false;
 | 
			
		||||
        } else {
 | 
			
		||||
            this.#isOffsetNeededControl.classList.add("d-none");
 | 
			
		||||
            this.#isOffsetNeeded.disabled = true;
 | 
			
		||||
            this.#isOffsetNeeded.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();
 | 
			
		||||
    }
 | 
			
		||||
    initializeBaseAccountQuery();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Initializes the query on the base account options.
 | 
			
		||||
 * 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", function () {
 | 
			
		||||
        if (query.value === "") {
 | 
			
		||||
            for (const option of options) {
 | 
			
		||||
                option.classList.remove("d-none");
 | 
			
		||||
            }
 | 
			
		||||
            optionList.classList.remove("d-none");
 | 
			
		||||
            queryNoResult.classList.add("d-none");
 | 
			
		||||
            return
 | 
			
		||||
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);
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
        let hasAnyMatched = false;
 | 
			
		||||
        for (const option of options) {
 | 
			
		||||
            const queryValues = JSON.parse(option.dataset.queryValues);
 | 
			
		||||
            let isMatched = false;
 | 
			
		||||
            for (const queryValue of queryValues) {
 | 
			
		||||
                if (queryValue.includes(query.value)) {
 | 
			
		||||
                    isMatched = true;
 | 
			
		||||
                    break;
 | 
			
		||||
        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");
 | 
			
		||||
                }
 | 
			
		||||
                this.#optionList.classList.remove("d-none");
 | 
			
		||||
                this.#queryNoResult.classList.add("d-none");
 | 
			
		||||
                return
 | 
			
		||||
            }
 | 
			
		||||
            let hasAnyMatched = false;
 | 
			
		||||
            for (const option of this.#options) {
 | 
			
		||||
                const queryValues = JSON.parse(option.dataset.queryValues);
 | 
			
		||||
                let isMatched = false;
 | 
			
		||||
                for (const queryValue of queryValues) {
 | 
			
		||||
                    if (queryValue.includes(this.#query.value)) {
 | 
			
		||||
                        isMatched = true;
 | 
			
		||||
                        break;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                if (isMatched) {
 | 
			
		||||
                    option.classList.remove("d-none");
 | 
			
		||||
                    hasAnyMatched = true;
 | 
			
		||||
                } else {
 | 
			
		||||
                    option.classList.add("d-none");
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            if (isMatched) {
 | 
			
		||||
                option.classList.remove("d-none");
 | 
			
		||||
                hasAnyMatched = true;
 | 
			
		||||
            if (!hasAnyMatched) {
 | 
			
		||||
                this.#optionList.classList.add("d-none");
 | 
			
		||||
                this.#queryNoResult.classList.remove("d-none");
 | 
			
		||||
            } else {
 | 
			
		||||
                option.classList.add("d-none");
 | 
			
		||||
                this.#optionList.classList.remove("d-none");
 | 
			
		||||
                this.#queryNoResult.classList.add("d-none");
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The callback when the base account selector is shown.
 | 
			
		||||
     *
 | 
			
		||||
     * @param baseCode {string} the active base code
 | 
			
		||||
     */
 | 
			
		||||
    onOpen(baseCode) {
 | 
			
		||||
        for (const option of this.#options) {
 | 
			
		||||
            if (option.dataset.code === baseCode) {
 | 
			
		||||
                option.classList.add("active");
 | 
			
		||||
            } else {
 | 
			
		||||
                option.classList.remove("active");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (!hasAnyMatched) {
 | 
			
		||||
            optionList.classList.add("d-none");
 | 
			
		||||
            queryNoResult.classList.remove("d-none");
 | 
			
		||||
        if (baseCode === "") {
 | 
			
		||||
            this.#clearButton.classList.add("btn-secondary")
 | 
			
		||||
            this.#clearButton.classList.remove("btn-danger");
 | 
			
		||||
            this.#clearButton.disabled = true;
 | 
			
		||||
        } else {
 | 
			
		||||
            optionList.classList.remove("d-none");
 | 
			
		||||
            queryNoResult.classList.add("d-none");
 | 
			
		||||
            this.#clearButton.classList.add("btn-danger");
 | 
			
		||||
            this.#clearButton.classList.remove("btn-secondary")
 | 
			
		||||
            this.#clearButton.disabled = false;
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Validates the form.
 | 
			
		||||
 *
 | 
			
		||||
 * @returns {boolean} true if valid, or false otherwise
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
function validateForm() {
 | 
			
		||||
    let isValid = true;
 | 
			
		||||
    isValid = validateBase() && isValid;
 | 
			
		||||
    isValid = validateTitle() && isValid;
 | 
			
		||||
    return isValid;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 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;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 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;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -20,12 +20,13 @@
 | 
			
		||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
 * First written: 2023/2/2
 | 
			
		||||
 */
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
// Initializes the page JavaScript.
 | 
			
		||||
document.addEventListener("DOMContentLoaded", function () {
 | 
			
		||||
document.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
    const list = document.getElementById("accounting-order-list");
 | 
			
		||||
    if (list !== null) {
 | 
			
		||||
        const onReorder = function () {
 | 
			
		||||
        const onReorder = () => {
 | 
			
		||||
            const accounts = Array.from(list.children);
 | 
			
		||||
            for (let i = 0; i < accounts.length; i++) {
 | 
			
		||||
                const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
 | 
			
		||||
 
 | 
			
		||||
@@ -20,9 +20,10 @@
 | 
			
		||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
 * First written: 2023/2/28
 | 
			
		||||
 */
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
// Initializes the page JavaScript.
 | 
			
		||||
document.addEventListener("DOMContentLoaded", function () {
 | 
			
		||||
document.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
    AccountSelector.initialize();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@@ -44,80 +45,89 @@ 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;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The journal entry editor
 | 
			
		||||
     * @type {JournalEntryEditor}
 | 
			
		||||
     */
 | 
			
		||||
    #entryEditor;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Constructs an account selector.
 | 
			
		||||
     *
 | 
			
		||||
     * @param modal {HTMLFormElement} the account selector modal
 | 
			
		||||
     * @param modal {HTMLDivElement} the account selector modal
 | 
			
		||||
     */
 | 
			
		||||
    constructor(modal) {
 | 
			
		||||
        this.#entryType = modal.dataset.entryType;
 | 
			
		||||
        this.#prefix = "accounting-account-selector-" + modal.dataset.entryType;
 | 
			
		||||
        this.#init();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the account selector.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #init() {
 | 
			
		||||
        const formAccountControl = document.getElementById("accounting-entry-form-account-control");
 | 
			
		||||
        const formAccount = document.getElementById("accounting-entry-form-account");
 | 
			
		||||
        const more = document.getElementById(this.#prefix + "-more");
 | 
			
		||||
        const btnClear = document.getElementById(this.#prefix + "-btn-clear");
 | 
			
		||||
        const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
 | 
			
		||||
        const selector1 = this
 | 
			
		||||
        more.onclick = function () {
 | 
			
		||||
            more.classList.add("d-none");
 | 
			
		||||
            selector1.#filterAccountOptions();
 | 
			
		||||
        this.#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.#initializeAccountQuery();
 | 
			
		||||
        btnClear.onclick = function () {
 | 
			
		||||
            formAccountControl.classList.remove("accounting-not-empty");
 | 
			
		||||
            formAccount.innerText = "";
 | 
			
		||||
            formAccount.dataset.code = "";
 | 
			
		||||
            formAccount.dataset.text = "";
 | 
			
		||||
            validateJournalEntryAccount();
 | 
			
		||||
        this.#clearButton.onclick = () => {
 | 
			
		||||
            this.#entryEditor.clearAccount();
 | 
			
		||||
        };
 | 
			
		||||
        for (const option of options) {
 | 
			
		||||
            option.onclick = function () {
 | 
			
		||||
                formAccountControl.classList.add("accounting-not-empty");
 | 
			
		||||
                formAccount.innerText = option.dataset.content;
 | 
			
		||||
                formAccount.dataset.code = option.dataset.code;
 | 
			
		||||
                formAccount.dataset.text = option.dataset.content;
 | 
			
		||||
                validateJournalEntryAccount();
 | 
			
		||||
        for (const option of this.#options) {
 | 
			
		||||
            option.onclick = () => {
 | 
			
		||||
                this.#entryEditor.saveAccount(option.dataset.code, option.dataset.content);
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the query on the account options.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #initializeAccountQuery() {
 | 
			
		||||
        const query = document.getElementById(this.#prefix + "-query");
 | 
			
		||||
        const helper = this;
 | 
			
		||||
        query.addEventListener("input", function () {
 | 
			
		||||
            helper.#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;
 | 
			
		||||
@@ -125,12 +135,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");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -139,27 +149,25 @@ 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.getTransactionForm().getAccountCodesUsed(this.#entryType);
 | 
			
		||||
        if (this.#entryEditor.getAccountCode() !== null) {
 | 
			
		||||
            inUse.push(this.#entryEditor.getAccountCode());
 | 
			
		||||
        }
 | 
			
		||||
        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) {
 | 
			
		||||
        const isQueryMatched = function () {
 | 
			
		||||
    #shouldOptionShow(option, more, inUse, query) {
 | 
			
		||||
        const isQueryMatched = () => {
 | 
			
		||||
            if (query.value === "") {
 | 
			
		||||
                return true;
 | 
			
		||||
            }
 | 
			
		||||
@@ -171,7 +179,7 @@ class AccountSelector {
 | 
			
		||||
            }
 | 
			
		||||
            return false;
 | 
			
		||||
        };
 | 
			
		||||
        const isMoreMatched = function () {
 | 
			
		||||
        const isMoreMatched = () => {
 | 
			
		||||
            if (more.classList.contains("d-none")) {
 | 
			
		||||
                return true;
 | 
			
		||||
            }
 | 
			
		||||
@@ -181,33 +189,30 @@ class AccountSelector {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the account selector when it is shown.
 | 
			
		||||
     * The callback when the account selector is shown.
 | 
			
		||||
     *
 | 
			
		||||
     * @param entryEditor {JournalEntryEditor} the journal entry editor
 | 
			
		||||
     */
 | 
			
		||||
    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(entryEditor) {
 | 
			
		||||
        this.#entryEditor = entryEditor;
 | 
			
		||||
        this.#query.value = "";
 | 
			
		||||
        this.#more.classList.remove("d-none");
 | 
			
		||||
        this.#filterOptions();
 | 
			
		||||
        for (const option of this.#options) {
 | 
			
		||||
            if (option.dataset.code === entryEditor.getAccountCode()) {
 | 
			
		||||
                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 (entryEditor.getAccountCode() === 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;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -227,28 +232,15 @@ class AccountSelector {
 | 
			
		||||
            const selector = new AccountSelector(modal);
 | 
			
		||||
            this.#selectors[selector.#entryType] = selector;
 | 
			
		||||
        }
 | 
			
		||||
        this.#initializeTransactionForm();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the transaction form.
 | 
			
		||||
     * Starts the account selector.
 | 
			
		||||
     *
 | 
			
		||||
     * @param entryEditor {JournalEntryEditor} the journal entry editor
 | 
			
		||||
     * @param entryType {string} the entry type, either "debit" or "credit"
 | 
			
		||||
     */
 | 
			
		||||
    static #initializeTransactionForm() {
 | 
			
		||||
        const entryForm = document.getElementById("accounting-entry-form");
 | 
			
		||||
        const formAccountControl = document.getElementById("accounting-entry-form-account-control");
 | 
			
		||||
        const selectors = this.#selectors;
 | 
			
		||||
        formAccountControl.onclick = function () {
 | 
			
		||||
            selectors[entryForm.dataset.entryType].initShow();
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the account selector for the journal entry form.
 | 
			
		||||
     *x
 | 
			
		||||
     */
 | 
			
		||||
    static initializeJournalEntryForm() {
 | 
			
		||||
        const entryForm = document.getElementById("accounting-entry-form");
 | 
			
		||||
        const formAccountControl = document.getElementById("accounting-entry-form-account-control");
 | 
			
		||||
        formAccountControl.dataset.bsTarget = "#accounting-account-selector-" + entryForm.dataset.entryType + "-modal";
 | 
			
		||||
    static start(entryEditor, entryType) {
 | 
			
		||||
        this.#selectors[entryType].#onOpen(entryEditor);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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", function () {
 | 
			
		||||
    document.getElementById("accounting-code")
 | 
			
		||||
        .onchange = validateCode;
 | 
			
		||||
    document.getElementById("accounting-name")
 | 
			
		||||
        .onchange = validateName;
 | 
			
		||||
    document.getElementById("accounting-form")
 | 
			
		||||
        .onsubmit = validateForm;
 | 
			
		||||
document.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
    CurrencyForm.initialize();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The asynchronous validation result
 | 
			
		||||
 * @type {object}
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
let isAsyncValid = {};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Validates the form.
 | 
			
		||||
 *
 | 
			
		||||
 * @returns {boolean} true if valid, or false otherwise
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
function validateForm() {
 | 
			
		||||
    isAsyncValid = {
 | 
			
		||||
        "code": false,
 | 
			
		||||
        "_sync": false,
 | 
			
		||||
    };
 | 
			
		||||
    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.
 | 
			
		||||
 * The currency form.
 | 
			
		||||
 *
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
function submitFormIfAllAsyncValid() {
 | 
			
		||||
    let isValid = true;
 | 
			
		||||
    for (const key of Object.keys(isAsyncValid)) {
 | 
			
		||||
        isValid = isAsyncValid[key] && isValid;
 | 
			
		||||
    }
 | 
			
		||||
    if (isValid) {
 | 
			
		||||
        document.getElementById("accounting-form").submit()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
class CurrencyForm {
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Validates the code.
 | 
			
		||||
 *
 | 
			
		||||
 * @param changeEvent {Event} the change event, if invoked from onchange
 | 
			
		||||
 * @returns {boolean} true if valid, or false otherwise
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
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.");
 | 
			
		||||
        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.");
 | 
			
		||||
        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.");
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
    const original = field.dataset.original;
 | 
			
		||||
    if (original === "" || field.value !== original) {
 | 
			
		||||
        hasAsyncValidation = true;
 | 
			
		||||
        validateAsyncCodeIsDuplicated(isSubmission, key);
 | 
			
		||||
    }
 | 
			
		||||
    if (!hasAsyncValidation) {
 | 
			
		||||
        isAsyncValid[key] = true;
 | 
			
		||||
        field.classList.remove("is-invalid");
 | 
			
		||||
        error.innerText = "";
 | 
			
		||||
    }
 | 
			
		||||
    return true;
 | 
			
		||||
}
 | 
			
		||||
    /**
 | 
			
		||||
     * The form.
 | 
			
		||||
     * @type {HTMLFormElement}
 | 
			
		||||
     */
 | 
			
		||||
    #formElement;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 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;
 | 
			
		||||
    /**
 | 
			
		||||
     * 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;
 | 
			
		||||
            }
 | 
			
		||||
            field.classList.remove("is-invalid");
 | 
			
		||||
            error.innerText = "";
 | 
			
		||||
            if (isSubmission) {
 | 
			
		||||
                isAsyncValid[key] = true;
 | 
			
		||||
                submitFormIfAllAsyncValid();
 | 
			
		||||
            });
 | 
			
		||||
            return false;
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the form.
 | 
			
		||||
     *
 | 
			
		||||
     * @returns {Promise<boolean>} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    async #validateForm() {
 | 
			
		||||
        let isValid = true;
 | 
			
		||||
        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 {Promise<boolean>} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    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(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 (!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 = 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;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
    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.");
 | 
			
		||||
        return false;
 | 
			
		||||
        this.#code.classList.remove("is-invalid");
 | 
			
		||||
        this.#codeError.innerText = "";
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the name.
 | 
			
		||||
     *
 | 
			
		||||
     * @returns {boolean} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #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;
 | 
			
		||||
        }
 | 
			
		||||
        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();
 | 
			
		||||
    }
 | 
			
		||||
    field.classList.remove("is-invalid");
 | 
			
		||||
    error.innerText = "";
 | 
			
		||||
    return true;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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.
 | 
			
		||||
@@ -44,15 +45,15 @@ function initializeMouseDragAndDropReordering(list, onReorder) {
 | 
			
		||||
    let dragged = null;
 | 
			
		||||
    for (const item of items) {
 | 
			
		||||
        item.draggable = true;
 | 
			
		||||
        item.addEventListener("dragstart", function () {
 | 
			
		||||
        item.addEventListener("dragstart", () => {
 | 
			
		||||
            dragged = item;
 | 
			
		||||
            dragged.classList.add("accounting-dragged");
 | 
			
		||||
        });
 | 
			
		||||
        item.addEventListener("dragover", function () {
 | 
			
		||||
        item.addEventListener("dragover", () => {
 | 
			
		||||
            onDragOver(dragged, item);
 | 
			
		||||
            onReorder();
 | 
			
		||||
        });
 | 
			
		||||
        item.addEventListener("dragend", function () {
 | 
			
		||||
        item.addEventListener("dragend", () => {
 | 
			
		||||
            dragged.classList.remove("accounting-dragged");
 | 
			
		||||
            dragged = null;
 | 
			
		||||
        });
 | 
			
		||||
@@ -69,16 +70,16 @@ function initializeMouseDragAndDropReordering(list, onReorder) {
 | 
			
		||||
function initializeTouchDragAndDropReordering(list, onReorder) {
 | 
			
		||||
    const items = Array.from(list.children);
 | 
			
		||||
    for (const item of items) {
 | 
			
		||||
        item.addEventListener("touchstart", function () {
 | 
			
		||||
        item.addEventListener("touchstart", () => {
 | 
			
		||||
            item.classList.add("accounting-dragged");
 | 
			
		||||
        });
 | 
			
		||||
        item.addEventListener("touchmove", function (event) {
 | 
			
		||||
        item.addEventListener("touchmove", (event) => {
 | 
			
		||||
            const touch = event.targetTouches[0];
 | 
			
		||||
            const target = document.elementFromPoint(touch.pageX, touch.pageY);
 | 
			
		||||
            onDragOver(item, target);
 | 
			
		||||
            onReorder();
 | 
			
		||||
        });
 | 
			
		||||
        item.addEventListener("touchend", function () {
 | 
			
		||||
        item.addEventListener("touchend", () => {
 | 
			
		||||
            item.classList.remove("accounting-dragged");
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										386
									
								
								src/accounting/static/js/journal-entry-editor.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										386
									
								
								src/accounting/static/js/journal-entry-editor.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,386 @@
 | 
			
		||||
/* 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";
 | 
			
		||||
 | 
			
		||||
// Initializes the page JavaScript.
 | 
			
		||||
document.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
    JournalEntryEditor.initialize();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The journal entry editor.
 | 
			
		||||
 *
 | 
			
		||||
 */
 | 
			
		||||
class JournalEntryEditor {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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 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;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Constructs a new journal entry editor.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    constructor() {
 | 
			
		||||
        this.#element = document.getElementById(this.#prefix);
 | 
			
		||||
        this.#modal = document.getElementById(this.#prefix + "-modal");
 | 
			
		||||
        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.#summaryControl.onclick = () => {
 | 
			
		||||
            SummaryEditor.start(this, this.#summary.dataset.value);
 | 
			
		||||
        };
 | 
			
		||||
        this.#accountControl.onclick = () => {
 | 
			
		||||
            AccountSelector.start(this, this.entryType);
 | 
			
		||||
        }
 | 
			
		||||
        this.#element.onsubmit = () => {
 | 
			
		||||
            if (this.#validate()) {
 | 
			
		||||
                if (this.#entry === null) {
 | 
			
		||||
                    this.#entry = this.#side.addJournalEntry();
 | 
			
		||||
                }
 | 
			
		||||
                this.#entry.save(this.#account.dataset.code, this.#account.dataset.text, this.#summary.dataset.value, this.#amount.value);
 | 
			
		||||
                bootstrap.Modal.getInstance(this.#modal).hide();
 | 
			
		||||
            }
 | 
			
		||||
            return false;
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns the transaction form.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {TransactionForm} the transaction form
 | 
			
		||||
     */
 | 
			
		||||
    getTransactionForm() {
 | 
			
		||||
        return this.#side.currency.form;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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.dataset.value = summary;
 | 
			
		||||
        this.#summary.innerText = summary;
 | 
			
		||||
        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
 | 
			
		||||
     */
 | 
			
		||||
    saveSummaryWithAccount(summary, accountCode, accountText) {
 | 
			
		||||
        this.#accountControl.classList.add("accounting-not-empty");
 | 
			
		||||
        this.#account.dataset.code = accountCode;
 | 
			
		||||
        this.#account.dataset.text = accountText;
 | 
			
		||||
        this.#account.innerText = accountText;
 | 
			
		||||
        this.#validateAccount();
 | 
			
		||||
        this.saveSummary(summary)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns the account code.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string|null} the account code
 | 
			
		||||
     */
 | 
			
		||||
    getAccountCode() {
 | 
			
		||||
        return this.#account.dataset.code === "" ? null : this.#account.dataset.code;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Clears the account.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    clearAccount() {
 | 
			
		||||
        this.#accountControl.classList.remove("accounting-not-empty");
 | 
			
		||||
        this.#account.dataset.code = "";
 | 
			
		||||
        this.#account.dataset.text = "";
 | 
			
		||||
        this.#account.innerText = "";
 | 
			
		||||
        this.#validateAccount();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Sets the account.
 | 
			
		||||
     *
 | 
			
		||||
     * @param code {string} the account code
 | 
			
		||||
     * @param text {string} the account text
 | 
			
		||||
     */
 | 
			
		||||
    saveAccount(code, text) {
 | 
			
		||||
        this.#accountControl.classList.add("accounting-not-empty");
 | 
			
		||||
        this.#account.dataset.code = code;
 | 
			
		||||
        this.#account.dataset.text = text;
 | 
			
		||||
        this.#account.innerText = text;
 | 
			
		||||
        this.#validateAccount();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the form.
 | 
			
		||||
     *
 | 
			
		||||
     * @returns {boolean} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #validate() {
 | 
			
		||||
        let isValid = true;
 | 
			
		||||
        isValid = this.#validateSummary() && isValid;
 | 
			
		||||
        isValid = this.#validateAccount() && isValid;
 | 
			
		||||
        isValid = this.#validateAmount() && isValid
 | 
			
		||||
        return isValid;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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.#account.dataset.code === "") {
 | 
			
		||||
            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;
 | 
			
		||||
        }
 | 
			
		||||
        this.#amount.classList.remove("is-invalid");
 | 
			
		||||
        this.#amount.innerText = "";
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Adds 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.#element.dataset.entryType = side.entryType;
 | 
			
		||||
        this.#summaryControl.dataset.bsTarget = "#accounting-summary-editor-" + side.entryType + "-modal";
 | 
			
		||||
        this.#summaryControl.classList.remove("accounting-not-empty");
 | 
			
		||||
        this.#summaryControl.classList.remove("is-invalid");
 | 
			
		||||
        this.#summary.dataset.value = "";
 | 
			
		||||
        this.#summary.innerText = ""
 | 
			
		||||
        this.#summaryError.innerText = ""
 | 
			
		||||
        this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + side.entryType + "-modal";
 | 
			
		||||
        this.#accountControl.classList.remove("accounting-not-empty");
 | 
			
		||||
        this.#accountControl.classList.remove("is-invalid");
 | 
			
		||||
        this.#account.innerText = "";
 | 
			
		||||
        this.#account.dataset.code = "";
 | 
			
		||||
        this.#account.dataset.text = "";
 | 
			
		||||
        this.#accountError.innerText = "";
 | 
			
		||||
        this.#amount.value = "";
 | 
			
		||||
        this.#amount.classList.remove("is-invalid");
 | 
			
		||||
        this.#amountError.innerText = "";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Edits a journal entry.
 | 
			
		||||
     *
 | 
			
		||||
     * @param entry {JournalEntrySubForm} the journal entry sub-form
 | 
			
		||||
     * @param summary {string} the summary
 | 
			
		||||
     * @param accountCode {string} the account code
 | 
			
		||||
     * @param accountText {string} the account text
 | 
			
		||||
     * @param amount {string} the amount
 | 
			
		||||
     */
 | 
			
		||||
    #onEdit(entry, summary, accountCode, accountText, amount) {
 | 
			
		||||
        this.#entry = entry;
 | 
			
		||||
        this.#side = entry.side;
 | 
			
		||||
        this.entryType = this.#side.entryType;
 | 
			
		||||
        this.#element.dataset.entryType = entry.entryType;
 | 
			
		||||
        this.#summaryControl.dataset.bsTarget = "#accounting-summary-editor-" + entry.entryType + "-modal";
 | 
			
		||||
        if (summary === "") {
 | 
			
		||||
            this.#summaryControl.classList.remove("accounting-not-empty");
 | 
			
		||||
        } else {
 | 
			
		||||
            this.#summaryControl.classList.add("accounting-not-empty");
 | 
			
		||||
        }
 | 
			
		||||
        this.#summary.dataset.value = summary;
 | 
			
		||||
        this.#summary.innerText = summary;
 | 
			
		||||
        this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + entry.entryType + "-modal";
 | 
			
		||||
        if (accountCode === "") {
 | 
			
		||||
            this.#accountControl.classList.remove("accounting-not-empty");
 | 
			
		||||
        } else {
 | 
			
		||||
            this.#accountControl.classList.add("accounting-not-empty");
 | 
			
		||||
        }
 | 
			
		||||
        this.#account.innerText = accountText;
 | 
			
		||||
        this.#account.dataset.code = accountCode;
 | 
			
		||||
        this.#account.dataset.text = accountText;
 | 
			
		||||
        this.#amount.value = amount;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The journal entry editor
 | 
			
		||||
     * @type {JournalEntryEditor}
 | 
			
		||||
     */
 | 
			
		||||
    static #editor;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the journal entry editor.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    static initialize() {
 | 
			
		||||
        this.#editor = new JournalEntryEditor();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Adds a new journal entry.
 | 
			
		||||
     *
 | 
			
		||||
     * @param side {DebitCreditSideSubForm} the debit or credit side sub-form
 | 
			
		||||
     */
 | 
			
		||||
    static addNew(side) {
 | 
			
		||||
        this.#editor.#onAddNew(side);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Edits a journal entry.
 | 
			
		||||
     *
 | 
			
		||||
     * @param entry {JournalEntrySubForm} the journal entry sub-form
 | 
			
		||||
     * @param summary {string} the summary
 | 
			
		||||
     * @param accountCode {string} the account code
 | 
			
		||||
     * @param accountText {string} the account text
 | 
			
		||||
     * @param amount {string} the amount
 | 
			
		||||
     */
 | 
			
		||||
    static edit(entry, summary, accountCode, accountText, amount) {
 | 
			
		||||
        this.#editor.#onEdit(entry, summary, accountCode, accountText, amount);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -20,9 +20,10 @@
 | 
			
		||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
 * First written: 2023/2/25
 | 
			
		||||
 */
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
// Initializes the page JavaScript.
 | 
			
		||||
document.addEventListener("DOMContentLoaded", function () {
 | 
			
		||||
document.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
    initializeMaterialFabSpeedDial();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@@ -34,7 +35,7 @@ document.addEventListener("DOMContentLoaded", function () {
 | 
			
		||||
function initializeMaterialFabSpeedDial() {
 | 
			
		||||
    const btnFab = document.getElementById("accounting-btn-material-fab-speed-dial");
 | 
			
		||||
    const fab = document.getElementById(btnFab.dataset.target);
 | 
			
		||||
    btnFab.onclick = function () {
 | 
			
		||||
    btnFab.onclick = () => {
 | 
			
		||||
        if (fab.classList.contains("show")) {
 | 
			
		||||
            fab.classList.remove("show");
 | 
			
		||||
        } else {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										426
									
								
								src/accounting/static/js/period-chooser.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										426
									
								
								src/accounting/static/js/period-chooser.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,426 @@
 | 
			
		||||
/* The Mia! Accounting Flask Project
 | 
			
		||||
 * period-chooser.js: The JavaScript for the period 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
 | 
			
		||||
 */
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
// Initializes the page JavaScript.
 | 
			
		||||
document.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
    PeriodChooser.initialize();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The period chooser.
 | 
			
		||||
 *
 | 
			
		||||
 */
 | 
			
		||||
class PeriodChooser {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The prefix of the HTML ID and class
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    prefix;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The modal of the period chooser
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    modal;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The tab planes
 | 
			
		||||
     * @type {{month: MonthTab, year: YearTab, day: DayTab, custom: CustomTab}}
 | 
			
		||||
     */
 | 
			
		||||
    tabPlanes = {};
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Constructs the period chooser.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    constructor() {
 | 
			
		||||
        this.prefix = "accounting-period-chooser";
 | 
			
		||||
        this.modal = document.getElementById(this.prefix + "-modal");
 | 
			
		||||
        for (const cls of [MonthTab, YearTab, DayTab, CustomTab]) {
 | 
			
		||||
            const tab = new cls(this);
 | 
			
		||||
            this.tabPlanes[tab.tabId()] = tab;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The period chooser.
 | 
			
		||||
     * @type {PeriodChooser}
 | 
			
		||||
     */
 | 
			
		||||
    static #chooser;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the period chooser.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    static initialize() {
 | 
			
		||||
        this.#chooser = new PeriodChooser();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A tab plane.
 | 
			
		||||
 *
 | 
			
		||||
 * @abstract
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
class TabPlane {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The period chooser
 | 
			
		||||
     * @type {PeriodChooser}
 | 
			
		||||
     */
 | 
			
		||||
    chooser;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The prefix of the HTML ID and class
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    prefix;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The tab
 | 
			
		||||
     * @type {HTMLSpanElement}
 | 
			
		||||
     */
 | 
			
		||||
    #tab;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The page
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #page;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Constructs a tab plane.
 | 
			
		||||
     *
 | 
			
		||||
     * @param chooser {PeriodChooser} the period chooser
 | 
			
		||||
     */
 | 
			
		||||
    constructor(chooser) {
 | 
			
		||||
        this.chooser = chooser;
 | 
			
		||||
        this.prefix = "accounting-period-chooser-" + this.tabId();
 | 
			
		||||
        this.#tab = document.getElementById(this.prefix + "-tab");
 | 
			
		||||
        this.#page = document.getElementById(this.prefix + "-page");
 | 
			
		||||
        this.#tab.onclick = () => this.#switchToMe();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The tab ID
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string}
 | 
			
		||||
     * @abstract
 | 
			
		||||
     */
 | 
			
		||||
    tabId() { throw new Error("Method not implemented.") };
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Switches to the tab plane.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #switchToMe() {
 | 
			
		||||
        for (const tabPlane of Object.values(this.chooser.tabPlanes)) {
 | 
			
		||||
            tabPlane.#tab.classList.remove("active")
 | 
			
		||||
            tabPlane.#tab.ariaCurrent = "false";
 | 
			
		||||
            tabPlane.#page.classList.add("d-none");
 | 
			
		||||
            tabPlane.#page.ariaCurrent = "false";
 | 
			
		||||
        }
 | 
			
		||||
        this.#tab.classList.add("active");
 | 
			
		||||
        this.#tab.ariaCurrent = "page";
 | 
			
		||||
        this.#page.classList.remove("d-none");
 | 
			
		||||
        this.#page.ariaCurrent = "page";
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The month tab plane.
 | 
			
		||||
 *
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
class MonthTab extends TabPlane {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The month chooser.
 | 
			
		||||
     * @type {tempusDominus.TempusDominus}
 | 
			
		||||
     */
 | 
			
		||||
    #monthChooser
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Constructs a tab plane.
 | 
			
		||||
     *
 | 
			
		||||
     * @param chooser {PeriodChooser} the period chooser
 | 
			
		||||
     */
 | 
			
		||||
    constructor(chooser) {
 | 
			
		||||
        super(chooser);
 | 
			
		||||
        const monthChooser = document.getElementById(this.prefix + "-chooser");
 | 
			
		||||
        if (monthChooser !== null) {
 | 
			
		||||
            let start = monthChooser.dataset.start;
 | 
			
		||||
            this.#monthChooser = new tempusDominus.TempusDominus(monthChooser, {
 | 
			
		||||
                restrictions: {
 | 
			
		||||
                    minDate: start,
 | 
			
		||||
                },
 | 
			
		||||
                display: {
 | 
			
		||||
                    inline: true,
 | 
			
		||||
                    components: {
 | 
			
		||||
                        date: false,
 | 
			
		||||
                        clock: false,
 | 
			
		||||
                    },
 | 
			
		||||
                },
 | 
			
		||||
                defaultDate: monthChooser.dataset.default,
 | 
			
		||||
            });
 | 
			
		||||
            monthChooser.addEventListener(tempusDominus.Namespace.events.change, (e) => {
 | 
			
		||||
                const date = e.detail.date;
 | 
			
		||||
                const year = date.year;
 | 
			
		||||
                const month = date.month + 1;
 | 
			
		||||
                const period = month < 10? year + "-0" + month: year + "-" + month;
 | 
			
		||||
                window.location = chooser.modal.dataset.urlTemplate
 | 
			
		||||
                    .replaceAll("PERIOD", period);
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The tab ID
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string}
 | 
			
		||||
     */
 | 
			
		||||
    tabId() {
 | 
			
		||||
        return "month";
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The year tab plane.
 | 
			
		||||
 *
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
class YearTab extends TabPlane {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The tab ID
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string}
 | 
			
		||||
     */
 | 
			
		||||
    tabId() {
 | 
			
		||||
        return "year";
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The day tab plane.
 | 
			
		||||
 *
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
class DayTab extends TabPlane {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The day input
 | 
			
		||||
     * @type {HTMLInputElement}
 | 
			
		||||
     */
 | 
			
		||||
    #date;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The error of the date input
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #dateError;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Constructs a tab plane.
 | 
			
		||||
     *
 | 
			
		||||
     * @param chooser {PeriodChooser} the period chooser
 | 
			
		||||
     */
 | 
			
		||||
    constructor(chooser) {
 | 
			
		||||
        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
 | 
			
		||||
                        .replaceAll("PERIOD", this.#date.value);
 | 
			
		||||
                }
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the date.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {boolean} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #validateDate() {
 | 
			
		||||
        if (this.#date.value === "") {
 | 
			
		||||
            this.#date.classList.add("is-invalid");
 | 
			
		||||
            this.#dateError.innerText = A_("Please fill in the date.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        if (this.#date.value < this.#date.min) {
 | 
			
		||||
            this.#date.classList.add("is-invalid");
 | 
			
		||||
            this.#dateError.innerText = A_("The date is too early.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        this.#date.classList.remove("is-invalid");
 | 
			
		||||
        this.#dateError.innerText = "";
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The tab ID
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string}
 | 
			
		||||
     */
 | 
			
		||||
    tabId() {
 | 
			
		||||
        return "day";
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The custom tab plane.
 | 
			
		||||
 *
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
class CustomTab extends TabPlane {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The start of the period
 | 
			
		||||
     * @type {HTMLInputElement}
 | 
			
		||||
     */
 | 
			
		||||
    #start;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The error of the start
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #startError;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The end of the period
 | 
			
		||||
     * @type {HTMLInputElement}
 | 
			
		||||
     */
 | 
			
		||||
    #end;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The error of the end
 | 
			
		||||
     * @type {HTMLDivElement}
 | 
			
		||||
     */
 | 
			
		||||
    #endError;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The confirm button
 | 
			
		||||
     * @type {HTMLButtonElement}
 | 
			
		||||
     */
 | 
			
		||||
    #conform;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Constructs a tab plane.
 | 
			
		||||
     *
 | 
			
		||||
     * @param chooser {PeriodChooser} the period chooser
 | 
			
		||||
     */
 | 
			
		||||
    constructor(chooser) {
 | 
			
		||||
        super(chooser);
 | 
			
		||||
        this.#start = document.getElementById(this.prefix + "-start");
 | 
			
		||||
        this.#startError = document.getElementById(this.prefix + "-start-error");
 | 
			
		||||
        this.#end = document.getElementById(this.prefix + "-end");
 | 
			
		||||
        this.#endError = document.getElementById(this.prefix + "-end-error");
 | 
			
		||||
        this.#conform = document.getElementById(this.prefix + "-confirm");
 | 
			
		||||
        if (this.#start !== null) {
 | 
			
		||||
            this.#start.onchange = () => {
 | 
			
		||||
                if (this.#validateStart()) {
 | 
			
		||||
                    this.#end.min = this.#start.value;
 | 
			
		||||
                }
 | 
			
		||||
            };
 | 
			
		||||
            this.#end.onchange = () => {
 | 
			
		||||
                if (this.#validateEnd()) {
 | 
			
		||||
                    this.#start.max = this.#end.value;
 | 
			
		||||
                }
 | 
			
		||||
            };
 | 
			
		||||
            this.#conform.onclick = () => {
 | 
			
		||||
                let isValid = true;
 | 
			
		||||
                isValid = this.#validateStart() && isValid;
 | 
			
		||||
                isValid = this.#validateEnd() && isValid;
 | 
			
		||||
                if (isValid) {
 | 
			
		||||
                    window.location = chooser.modal.dataset.urlTemplate
 | 
			
		||||
                        .replaceAll("PERIOD", this.#start.value + "-" + this.#end.value);
 | 
			
		||||
                }
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the start of the period.
 | 
			
		||||
     *
 | 
			
		||||
     * @returns {boolean} true if valid, or false otherwise
 | 
			
		||||
     * @private
 | 
			
		||||
     */
 | 
			
		||||
    #validateStart() {
 | 
			
		||||
        if (this.#start.value === "") {
 | 
			
		||||
            this.#start.classList.add("is-invalid");
 | 
			
		||||
            this.#startError.innerText = A_("Please fill in the start date.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        if (this.#start.value < this.#start.min) {
 | 
			
		||||
            this.#start.classList.add("is-invalid");
 | 
			
		||||
            this.#startError.innerText = A_("The start date is too early.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        if (this.#start.value > this.#start.max) {
 | 
			
		||||
            this.#start.classList.add("is-invalid");
 | 
			
		||||
            this.#startError.innerText = A_("The start date cannot be beyond the end date.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        this.#start.classList.remove("is-invalid");
 | 
			
		||||
        this.#startError.innerText = "";
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the end of the period.
 | 
			
		||||
     *
 | 
			
		||||
     * @returns {boolean} true if valid, or false otherwise
 | 
			
		||||
     * @private
 | 
			
		||||
     */
 | 
			
		||||
    #validateEnd() {
 | 
			
		||||
        this.#end.value = this.#end.value.trim();
 | 
			
		||||
        if (this.#end.value === "") {
 | 
			
		||||
            this.#end.classList.add("is-invalid");
 | 
			
		||||
            this.#endError.innerText = A_("Please fill in the end date.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        if (this.#end.value < this.#end.min) {
 | 
			
		||||
            this.#end.classList.add("is-invalid");
 | 
			
		||||
            this.#endError.innerText = A_("The end date cannot be beyond the start date.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        this.#end.classList.remove("is-invalid");
 | 
			
		||||
        this.#endError.innerText = "";
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The tab ID
 | 
			
		||||
     *
 | 
			
		||||
     * @return {string}
 | 
			
		||||
     */
 | 
			
		||||
    tabId() {
 | 
			
		||||
        return "custom";
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1160
									
								
								src/accounting/static/js/summary-editor.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1160
									
								
								src/accounting/static/js/summary-editor.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -1,827 +0,0 @@
 | 
			
		||||
/* The Mia! Accounting Flask Project
 | 
			
		||||
 * summary-helper.js: The JavaScript for the summary helper
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/*  Copyright (c) 2023 imacat.
 | 
			
		||||
 *
 | 
			
		||||
 *  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
 *  you may not use this file except in compliance with the License.
 | 
			
		||||
 *  You may obtain a copy of the License at
 | 
			
		||||
 *
 | 
			
		||||
 *      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 *
 | 
			
		||||
 *  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
 *  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
 *  See the License for the specific language governing permissions and
 | 
			
		||||
 *  limitations under the License.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
 * First written: 2023/2/28
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
// Initializes the page JavaScript.
 | 
			
		||||
document.addEventListener("DOMContentLoaded", function () {
 | 
			
		||||
    SummaryHelper.initialize();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A summary helper.
 | 
			
		||||
 *
 | 
			
		||||
 */
 | 
			
		||||
class SummaryHelper {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The entry type, either "debit" or "credit"
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    #entryType;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The prefix of the HTML ID and class
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    #prefix;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The default tab ID
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     */
 | 
			
		||||
    #defaultTabId;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Constructs a summary helper.
 | 
			
		||||
     *
 | 
			
		||||
     * @param form {HTMLFormElement} the summary helper form
 | 
			
		||||
     */
 | 
			
		||||
    constructor(form) {
 | 
			
		||||
        this.#entryType = form.dataset.entryType;
 | 
			
		||||
        this.#prefix = "accounting-summary-helper-" + form.dataset.entryType;
 | 
			
		||||
        this.#defaultTabId = form.dataset.defaultTabId;
 | 
			
		||||
        this.#init();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the summary helper.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #init() {
 | 
			
		||||
        const helper = this;
 | 
			
		||||
        const summary = document.getElementById(this.#prefix + "-summary");
 | 
			
		||||
        const tabs = Array.from(document.getElementsByClassName(this.#prefix + "-tab"));
 | 
			
		||||
        for (const tab of tabs) {
 | 
			
		||||
            tab.onclick = function () {
 | 
			
		||||
                helper.#switchToTab(tab.dataset.tabId);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        this.#initializeGeneralTagHelper();
 | 
			
		||||
        this.#initializeGeneralTripHelper();
 | 
			
		||||
        this.#initializeBusTripHelper();
 | 
			
		||||
        this.#initializeNumberHelper();
 | 
			
		||||
        this.#initializeSuggestedAccounts();
 | 
			
		||||
        this.#initializeSubmission();
 | 
			
		||||
        summary.onchange = function () {
 | 
			
		||||
            summary.value = summary.value.trim();
 | 
			
		||||
            helper.#parseAndPopulate();
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Switches to a tab.
 | 
			
		||||
     *
 | 
			
		||||
     * @param tabId {string} the tab ID.
 | 
			
		||||
     */
 | 
			
		||||
    #switchToTab(tabId) {
 | 
			
		||||
        const tabs = Array.from(document.getElementsByClassName(this.#prefix + "-tab"));
 | 
			
		||||
        const pages = Array.from(document.getElementsByClassName(this.#prefix + "-page"));
 | 
			
		||||
        const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-" + tabId + "-btn-tag"));
 | 
			
		||||
        for (const tab of tabs) {
 | 
			
		||||
            if (tab.dataset.tabId === tabId) {
 | 
			
		||||
                tab.classList.add("active");
 | 
			
		||||
                tab.ariaCurrent = "page";
 | 
			
		||||
            } else {
 | 
			
		||||
                tab.classList.remove("active");
 | 
			
		||||
                tab.ariaCurrent = "false";
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        for (const page of pages) {
 | 
			
		||||
            if (page.dataset.tabId === tabId) {
 | 
			
		||||
                page.classList.remove("d-none");
 | 
			
		||||
                page.ariaCurrent = "page";
 | 
			
		||||
            } else {
 | 
			
		||||
                page.classList.add("d-none");
 | 
			
		||||
                page.ariaCurrent = "false";
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        let selectedBtnTag = null;
 | 
			
		||||
        for (const tagButton of tagButtons) {
 | 
			
		||||
            if (tagButton.classList.contains("btn-primary")) {
 | 
			
		||||
                selectedBtnTag = tagButton;
 | 
			
		||||
                break;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        this.#filterSuggestedAccounts(selectedBtnTag);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the general tag helper.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #initializeGeneralTagHelper() {
 | 
			
		||||
        const summary = document.getElementById(this.#prefix + "-summary");
 | 
			
		||||
        const tag = document.getElementById(this.#prefix + "-general-tag");
 | 
			
		||||
        const helper = this;
 | 
			
		||||
        const updateSummary = function () {
 | 
			
		||||
            const pos = summary.value.indexOf("—");
 | 
			
		||||
            const prefix = tag.value === ""? "": tag.value + "—";
 | 
			
		||||
            if (pos === -1) {
 | 
			
		||||
                summary.value = prefix + summary.value;
 | 
			
		||||
            } else {
 | 
			
		||||
                summary.value = prefix + summary.value.substring(pos + 1);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        this.#initializeTagButtons("general", tag, updateSummary);
 | 
			
		||||
        tag.onchange = function () {
 | 
			
		||||
            helper.#onTagInputChange("general", tag, updateSummary);
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the general trip helper.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #initializeGeneralTripHelper() {
 | 
			
		||||
        const summary = document.getElementById(this.#prefix + "-summary");
 | 
			
		||||
        const tag = document.getElementById(this.#prefix + "-travel-tag");
 | 
			
		||||
        const from = document.getElementById(this.#prefix + "-travel-from");
 | 
			
		||||
        const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction"))
 | 
			
		||||
        const to = document.getElementById(this.#prefix + "-travel-to");
 | 
			
		||||
        const helper = this;
 | 
			
		||||
        const updateSummary = function () {
 | 
			
		||||
            let direction;
 | 
			
		||||
            for (const directionButton of directionButtons) {
 | 
			
		||||
                if (directionButton.classList.contains("btn-primary")) {
 | 
			
		||||
                    direction = directionButton.dataset.arrow;
 | 
			
		||||
                    break;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            summary.value = tag.value + "—" + from.value + direction + to.value;
 | 
			
		||||
        };
 | 
			
		||||
        this.#initializeTagButtons("travel", tag, updateSummary);
 | 
			
		||||
        tag.onchange = function () {
 | 
			
		||||
            helper.#onTagInputChange("travel", tag, updateSummary);
 | 
			
		||||
            helper.#validateGeneralTripTag();
 | 
			
		||||
        };
 | 
			
		||||
        from.onchange = function () {
 | 
			
		||||
            updateSummary();
 | 
			
		||||
            helper.#validateGeneralTripFrom();
 | 
			
		||||
        };
 | 
			
		||||
        for (const directionButton of directionButtons) {
 | 
			
		||||
            directionButton.onclick = function () {
 | 
			
		||||
                for (const otherButton of directionButtons) {
 | 
			
		||||
                    otherButton.classList.remove("btn-primary");
 | 
			
		||||
                    otherButton.classList.add("btn-outline-primary");
 | 
			
		||||
                }
 | 
			
		||||
                directionButton.classList.remove("btn-outline-primary");
 | 
			
		||||
                directionButton.classList.add("btn-primary");
 | 
			
		||||
                updateSummary();
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
        to.onchange = function () {
 | 
			
		||||
            updateSummary();
 | 
			
		||||
            helper.#validateGeneralTripTo();
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the bus trip helper.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #initializeBusTripHelper() {
 | 
			
		||||
        const summary = document.getElementById(this.#prefix + "-summary");
 | 
			
		||||
        const tag = document.getElementById(this.#prefix + "-bus-tag");
 | 
			
		||||
        const route = document.getElementById(this.#prefix + "-bus-route");
 | 
			
		||||
        const from = document.getElementById(this.#prefix + "-bus-from");
 | 
			
		||||
        const to = document.getElementById(this.#prefix + "-bus-to");
 | 
			
		||||
        const helper = this;
 | 
			
		||||
        const updateSummary = function () {
 | 
			
		||||
            summary.value = tag.value + "—" + route.value + "—" + from.value + "→" + to.value;
 | 
			
		||||
        };
 | 
			
		||||
        this.#initializeTagButtons("bus", tag, updateSummary);
 | 
			
		||||
        tag.onchange = function () {
 | 
			
		||||
            helper.#onTagInputChange("bus", tag, updateSummary);
 | 
			
		||||
            helper.#validateBusTripTag();
 | 
			
		||||
        };
 | 
			
		||||
        route.onchange = function () {
 | 
			
		||||
            updateSummary();
 | 
			
		||||
            helper.#validateBusTripRoute();
 | 
			
		||||
        };
 | 
			
		||||
        from.onchange = function () {
 | 
			
		||||
            updateSummary();
 | 
			
		||||
            helper.#validateBusTripFrom();
 | 
			
		||||
        };
 | 
			
		||||
        to.onchange = function () {
 | 
			
		||||
            updateSummary();
 | 
			
		||||
            helper.#validateBusTripTo();
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the tag buttons.
 | 
			
		||||
     *
 | 
			
		||||
     * @param tabId {string} the tab ID
 | 
			
		||||
     * @param tag {HTMLInputElement} the tag input
 | 
			
		||||
     * @param updateSummary {function(): void} the callback to update the summary
 | 
			
		||||
     */
 | 
			
		||||
    #initializeTagButtons(tabId, tag, updateSummary) {
 | 
			
		||||
        const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-" + tabId + "-btn-tag"));
 | 
			
		||||
        const helper = this;
 | 
			
		||||
        for (const tagButton of tagButtons) {
 | 
			
		||||
            tagButton.onclick = function () {
 | 
			
		||||
                for (const otherButton of tagButtons) {
 | 
			
		||||
                    otherButton.classList.remove("btn-primary");
 | 
			
		||||
                    otherButton.classList.add("btn-outline-primary");
 | 
			
		||||
                }
 | 
			
		||||
                tagButton.classList.remove("btn-outline-primary");
 | 
			
		||||
                tagButton.classList.add("btn-primary");
 | 
			
		||||
                tag.value = tagButton.dataset.value;
 | 
			
		||||
                helper.#filterSuggestedAccounts(tagButton);
 | 
			
		||||
                updateSummary();
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The callback when the tag input is changed.
 | 
			
		||||
     *
 | 
			
		||||
     * @param tabId {string} the tab ID
 | 
			
		||||
     * @param tag {HTMLInputElement} the tag input
 | 
			
		||||
     * @param updateSummary {function(): void} the callback to update the summary
 | 
			
		||||
     */
 | 
			
		||||
    #onTagInputChange(tabId, tag, updateSummary) {
 | 
			
		||||
        const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-" + tabId + "-btn-tag"));
 | 
			
		||||
        let isMatched = false;
 | 
			
		||||
        for (const tagButton of tagButtons) {
 | 
			
		||||
            if (tagButton.dataset.value === tag.value) {
 | 
			
		||||
                tagButton.classList.remove("btn-outline-primary");
 | 
			
		||||
                tagButton.classList.add("btn-primary");
 | 
			
		||||
                this.#filterSuggestedAccounts(tagButton);
 | 
			
		||||
                isMatched = true;
 | 
			
		||||
            } else {
 | 
			
		||||
                tagButton.classList.remove("btn-primary");
 | 
			
		||||
                tagButton.classList.add("btn-outline-primary");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (!isMatched) {
 | 
			
		||||
            this.#filterSuggestedAccounts(null);
 | 
			
		||||
        }
 | 
			
		||||
        updateSummary();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Filters the suggested accounts.
 | 
			
		||||
     *
 | 
			
		||||
     * @param tagButton {HTMLButtonElement|null} the tag button
 | 
			
		||||
     */
 | 
			
		||||
    #filterSuggestedAccounts(tagButton) {
 | 
			
		||||
        const accountButtons = Array.from(document.getElementsByClassName(this.#prefix + "-account"));
 | 
			
		||||
        if (tagButton === null) {
 | 
			
		||||
            for (const accountButton of accountButtons) {
 | 
			
		||||
                accountButton.classList.add("d-none");
 | 
			
		||||
                accountButton.classList.remove("btn-primary");
 | 
			
		||||
                accountButton.classList.add("btn-outline-primary");
 | 
			
		||||
                this.#selectAccount(null);
 | 
			
		||||
            }
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        const suggested = JSON.parse(tagButton.dataset.accounts);
 | 
			
		||||
        for (const accountButton of accountButtons) {
 | 
			
		||||
            if (suggested.includes(accountButton.dataset.code)) {
 | 
			
		||||
                accountButton.classList.remove("d-none");
 | 
			
		||||
            } else {
 | 
			
		||||
                accountButton.classList.add("d-none");
 | 
			
		||||
            }
 | 
			
		||||
            this.#selectAccount(suggested[0]);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the number helper.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #initializeNumberHelper() {
 | 
			
		||||
        const summary = document.getElementById(this.#prefix + "-summary");
 | 
			
		||||
        const number = document.getElementById(this.#prefix + "-number");
 | 
			
		||||
        number.onchange = function () {
 | 
			
		||||
            const found = summary.value.match(/^(.+)×(\d+)$/);
 | 
			
		||||
            if (found !== null) {
 | 
			
		||||
                summary.value = found[1];
 | 
			
		||||
            }
 | 
			
		||||
            if (number.value > 1) {
 | 
			
		||||
                summary.value = summary.value + "×" + String(number.value);
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the suggested accounts.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #initializeSuggestedAccounts() {
 | 
			
		||||
        const accountButtons = Array.from(document.getElementsByClassName(this.#prefix + "-account"));
 | 
			
		||||
        const helper = this;
 | 
			
		||||
        for (const accountButton of accountButtons) {
 | 
			
		||||
            accountButton.onclick = function () {
 | 
			
		||||
                helper.#selectAccount(accountButton.dataset.code);
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Select a suggested account.
 | 
			
		||||
     *
 | 
			
		||||
     * @param selectedCode {string|null} the account code, or null to deselect the account
 | 
			
		||||
     */
 | 
			
		||||
    #selectAccount(selectedCode) {
 | 
			
		||||
        const form = document.getElementById(this.#prefix);
 | 
			
		||||
        if (selectedCode === null) {
 | 
			
		||||
            form.dataset.selectedAccountCode = "";
 | 
			
		||||
            form.dataset.selectedAccountText = "";
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        const accountButtons = Array.from(document.getElementsByClassName(this.#prefix + "-account"));
 | 
			
		||||
        for (const accountButton of accountButtons) {
 | 
			
		||||
            if (accountButton.dataset.code === selectedCode) {
 | 
			
		||||
                accountButton.classList.remove("btn-outline-primary");
 | 
			
		||||
                accountButton.classList.add("btn-primary");
 | 
			
		||||
                form.dataset.selectedAccountCode = accountButton.dataset.code;
 | 
			
		||||
                form.dataset.selectedAccountText = accountButton.dataset.text;
 | 
			
		||||
            } else {
 | 
			
		||||
                accountButton.classList.remove("btn-primary");
 | 
			
		||||
                accountButton.classList.add("btn-outline-primary");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the summary submission
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #initializeSubmission() {
 | 
			
		||||
        const form = document.getElementById(this.#prefix);
 | 
			
		||||
        const helper = this;
 | 
			
		||||
        form.onsubmit = function () {
 | 
			
		||||
            if (helper.#validate()) {
 | 
			
		||||
                helper.#submit();
 | 
			
		||||
            }
 | 
			
		||||
            return false;
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the form.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {boolean} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #validate() {
 | 
			
		||||
        const tabs = Array.from(document.getElementsByClassName(this.#prefix + "-tab"));
 | 
			
		||||
        let isValid = true;
 | 
			
		||||
        for (const tab of tabs) {
 | 
			
		||||
            if (tab.classList.contains("active")) {
 | 
			
		||||
                switch (tab.dataset.tabId) {
 | 
			
		||||
                    case "general":
 | 
			
		||||
                        isValid = this.#validateGeneralTag() && isValid;
 | 
			
		||||
                        break;
 | 
			
		||||
                    case "travel":
 | 
			
		||||
                        isValid = this.#validateGeneralTrip() && isValid;
 | 
			
		||||
                        break;
 | 
			
		||||
                    case "bus":
 | 
			
		||||
                        isValid = this.#validateBusTrip() && isValid;
 | 
			
		||||
                        break;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return isValid;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates a general tag.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {boolean} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #validateGeneralTag() {
 | 
			
		||||
        const field = document.getElementById(this.#prefix + "-general-tag");
 | 
			
		||||
        const error = document.getElementById(this.#prefix + "-general-tag-error");
 | 
			
		||||
        field.value = field.value.trim();
 | 
			
		||||
        field.classList.remove("is-invalid");
 | 
			
		||||
        error.innerText = "";
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates a general trip.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {boolean} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #validateGeneralTrip() {
 | 
			
		||||
        let isValid = true;
 | 
			
		||||
        isValid = this.#validateGeneralTripTag() && isValid;
 | 
			
		||||
        isValid = this.#validateGeneralTripFrom() && isValid;
 | 
			
		||||
        isValid = this.#validateGeneralTripTo() && isValid;
 | 
			
		||||
        return isValid;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the tag of a general trip.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {boolean} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #validateGeneralTripTag() {
 | 
			
		||||
        const field = document.getElementById(this.#prefix + "-travel-tag");
 | 
			
		||||
        const error = document.getElementById(this.#prefix + "-travel-tag-error");
 | 
			
		||||
        field.value = field.value.trim();
 | 
			
		||||
        if (field.value === "") {
 | 
			
		||||
            field.classList.add("is-invalid");
 | 
			
		||||
            error.innerText = A_("Please fill in the tag.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        field.classList.remove("is-invalid");
 | 
			
		||||
        error.innerText = "";
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the origin of a general trip.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {boolean} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #validateGeneralTripFrom() {
 | 
			
		||||
        const field = document.getElementById(this.#prefix + "-travel-from");
 | 
			
		||||
        const error = document.getElementById(this.#prefix + "-travel-from-error");
 | 
			
		||||
        field.value = field.value.trim();
 | 
			
		||||
        if (field.value === "") {
 | 
			
		||||
            field.classList.add("is-invalid");
 | 
			
		||||
            error.innerText = A_("Please fill in the origin.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        field.classList.remove("is-invalid");
 | 
			
		||||
        error.innerText = "";
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the destination of a general trip.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {boolean} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #validateGeneralTripTo() {
 | 
			
		||||
        const field = document.getElementById(this.#prefix + "-travel-to");
 | 
			
		||||
        const error = document.getElementById(this.#prefix + "-travel-to-error");
 | 
			
		||||
        field.value = field.value.trim();
 | 
			
		||||
        if (field.value === "") {
 | 
			
		||||
            field.classList.add("is-invalid");
 | 
			
		||||
            error.innerText = A_("Please fill in the destination.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        field.classList.remove("is-invalid");
 | 
			
		||||
        error.innerText = "";
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates a bus trip.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {boolean} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #validateBusTrip() {
 | 
			
		||||
        let isValid = true;
 | 
			
		||||
        isValid = this.#validateBusTripTag() && isValid;
 | 
			
		||||
        isValid = this.#validateBusTripRoute() && isValid;
 | 
			
		||||
        isValid = this.#validateBusTripFrom() && isValid;
 | 
			
		||||
        isValid = this.#validateBusTripTo() && isValid;
 | 
			
		||||
        return isValid;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the tag of a bus trip.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {boolean} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #validateBusTripTag() {
 | 
			
		||||
        const field = document.getElementById(this.#prefix + "-bus-tag");
 | 
			
		||||
        const error = document.getElementById(this.#prefix + "-bus-tag-error");
 | 
			
		||||
        field.value = field.value.trim();
 | 
			
		||||
        if (field.value === "") {
 | 
			
		||||
            field.classList.add("is-invalid");
 | 
			
		||||
            error.innerText = A_("Please fill in the tag.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        field.classList.remove("is-invalid");
 | 
			
		||||
        error.innerText = "";
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the route of a bus trip.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {boolean} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #validateBusTripRoute() {
 | 
			
		||||
        const field = document.getElementById(this.#prefix + "-bus-route");
 | 
			
		||||
        const error = document.getElementById(this.#prefix + "-bus-route-error");
 | 
			
		||||
        field.value = field.value.trim();
 | 
			
		||||
        if (field.value === "") {
 | 
			
		||||
            field.classList.add("is-invalid");
 | 
			
		||||
            error.innerText = A_("Please fill in the route.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        field.classList.remove("is-invalid");
 | 
			
		||||
        error.innerText = "";
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the origin of a bus trip.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {boolean} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #validateBusTripFrom() {
 | 
			
		||||
        const field = document.getElementById(this.#prefix + "-bus-from");
 | 
			
		||||
        const error = document.getElementById(this.#prefix + "-bus-from-error");
 | 
			
		||||
        field.value = field.value.trim();
 | 
			
		||||
        if (field.value === "") {
 | 
			
		||||
            field.classList.add("is-invalid");
 | 
			
		||||
            error.innerText = A_("Please fill in the origin.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        field.classList.remove("is-invalid");
 | 
			
		||||
        error.innerText = "";
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Validates the destination of a bus trip.
 | 
			
		||||
     *
 | 
			
		||||
     * @return {boolean} true if valid, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    #validateBusTripTo() {
 | 
			
		||||
        const field = document.getElementById(this.#prefix + "-bus-to");
 | 
			
		||||
        const error = document.getElementById(this.#prefix + "-bus-to-error");
 | 
			
		||||
        field.value = field.value.trim();
 | 
			
		||||
        if (field.value === "") {
 | 
			
		||||
            field.classList.add("is-invalid");
 | 
			
		||||
            error.innerText = A_("Please fill in the destination.");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        field.classList.remove("is-invalid");
 | 
			
		||||
        error.innerText = "";
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Submits the summary.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #submit() {
 | 
			
		||||
        const form = document.getElementById(this.#prefix);
 | 
			
		||||
        const summary = document.getElementById(this.#prefix + "-summary");
 | 
			
		||||
        const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
 | 
			
		||||
        const formSummary = document.getElementById("accounting-entry-form-summary");
 | 
			
		||||
        const formAccountControl = document.getElementById("accounting-entry-form-account-control");
 | 
			
		||||
        const formAccount = document.getElementById("accounting-entry-form-account");
 | 
			
		||||
        const helperModal = document.getElementById(this.#prefix + "-modal");
 | 
			
		||||
        const entryModal = document.getElementById("accounting-entry-form-modal");
 | 
			
		||||
        if (summary.value === "") {
 | 
			
		||||
            formSummaryControl.classList.remove("accounting-not-empty");
 | 
			
		||||
        } else {
 | 
			
		||||
            formSummaryControl.classList.add("accounting-not-empty");
 | 
			
		||||
        }
 | 
			
		||||
        if (form.dataset.selectedAccountCode !== "") {
 | 
			
		||||
            formAccountControl.classList.add("accounting-not-empty");
 | 
			
		||||
            formAccount.dataset.code = form.dataset.selectedAccountCode;
 | 
			
		||||
            formAccount.dataset.text = form.dataset.selectedAccountText;
 | 
			
		||||
            formAccount.innerText = form.dataset.selectedAccountText;
 | 
			
		||||
        }
 | 
			
		||||
        formSummary.dataset.value = summary.value;
 | 
			
		||||
        formSummary.innerText = summary.value;
 | 
			
		||||
        bootstrap.Modal.getInstance(helperModal).hide();
 | 
			
		||||
        bootstrap.Modal.getOrCreateInstance(entryModal).show();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the summary helper when it is shown.
 | 
			
		||||
     *
 | 
			
		||||
     * @param isNew {boolean} true for adding a new journal entry, or false otherwise
 | 
			
		||||
     */
 | 
			
		||||
    initShow(isNew) {
 | 
			
		||||
        const formSummary = document.getElementById("accounting-entry-form-summary");
 | 
			
		||||
        const summary = document.getElementById(this.#prefix + "-summary");
 | 
			
		||||
        const closeButtons = Array.from(document.getElementsByClassName(this.#prefix + "-close"));
 | 
			
		||||
        for (const closeButton of closeButtons) {
 | 
			
		||||
            if (isNew) {
 | 
			
		||||
                closeButton.dataset.bsTarget = "#" + this.#prefix + "-modal";
 | 
			
		||||
            } else {
 | 
			
		||||
                closeButton.dataset.bsTarget = "#accounting-entry-form-modal";
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        this.#reset();
 | 
			
		||||
        if (!isNew) {
 | 
			
		||||
            summary.value = formSummary.dataset.value;
 | 
			
		||||
            this.#parseAndPopulate();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Resets the summary helper.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #reset() {
 | 
			
		||||
        const inputs = Array.from(document.getElementsByClassName(this.#prefix + "-input"));
 | 
			
		||||
        const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-btn-tag"));
 | 
			
		||||
        const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction"));
 | 
			
		||||
        for (const input of inputs) {
 | 
			
		||||
            input.value = "";
 | 
			
		||||
            input.classList.remove("is-invalid");
 | 
			
		||||
        }
 | 
			
		||||
        for (const tagButton of tagButtons) {
 | 
			
		||||
            tagButton.classList.remove("btn-primary");
 | 
			
		||||
            tagButton.classList.add("btn-outline-primary");
 | 
			
		||||
        }
 | 
			
		||||
        for (const directionButton of directionButtons) {
 | 
			
		||||
            if (directionButton.classList.contains("accounting-default")) {
 | 
			
		||||
                directionButton.classList.remove("btn-outline-primary");
 | 
			
		||||
                directionButton.classList.add("btn-primary");
 | 
			
		||||
            } else {
 | 
			
		||||
                directionButton.classList.add("btn-outline-primary");
 | 
			
		||||
                directionButton.classList.remove("btn-primary");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        this.#filterSuggestedAccounts(null);
 | 
			
		||||
        this.#switchToTab(this.#defaultTabId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Parses the summary input and populates the summary helper.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    #parseAndPopulate() {
 | 
			
		||||
        const summary = document.getElementById(this.#prefix + "-summary");
 | 
			
		||||
        const pos = summary.value.indexOf("—");
 | 
			
		||||
        if (pos === -1) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        let found;
 | 
			
		||||
        found = summary.value.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:×(\d+))?$/);
 | 
			
		||||
        if (found !== null) {
 | 
			
		||||
            return this.#populateBusTrip(found[1], found[2], found[3], found[4], found[5]);
 | 
			
		||||
        }
 | 
			
		||||
        found = summary.value.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:×(\d+))?$/);
 | 
			
		||||
        if (found !== null) {
 | 
			
		||||
            return this.#populateGeneralTrip(found[1], found[2], found[3], found[4], found[5]);
 | 
			
		||||
        }
 | 
			
		||||
        found = summary.value.match(/^([^—]+)—.+?(?:×(\d+)?)?$/);
 | 
			
		||||
        if (found !== null) {
 | 
			
		||||
            return this.#populateGeneralTag(found[1], found[2]);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Populates a bus trip.
 | 
			
		||||
     *
 | 
			
		||||
     * @param tagName {string} the tag name
 | 
			
		||||
     * @param routeName {string} the route name or route number
 | 
			
		||||
     * @param fromName {string} the name of the origin
 | 
			
		||||
     * @param toName {string} the name of the destination
 | 
			
		||||
     * @param numberStr {string|undefined} the number of items, if any
 | 
			
		||||
     */
 | 
			
		||||
    #populateBusTrip(tagName, routeName, fromName, toName, numberStr) {
 | 
			
		||||
        const tag = document.getElementById(this.#prefix + "-bus-tag");
 | 
			
		||||
        const route = document.getElementById(this.#prefix + "-bus-route");
 | 
			
		||||
        const from = document.getElementById(this.#prefix + "-bus-from");
 | 
			
		||||
        const to = document.getElementById(this.#prefix + "-bus-to");
 | 
			
		||||
        const number = document.getElementById(this.#prefix + "-number");
 | 
			
		||||
        const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-bus-btn-tag"));
 | 
			
		||||
        tag.value = tagName;
 | 
			
		||||
        route.value = routeName;
 | 
			
		||||
        from.value = fromName;
 | 
			
		||||
        to.value = toName;
 | 
			
		||||
        if (numberStr !== undefined) {
 | 
			
		||||
            number.value = parseInt(numberStr);
 | 
			
		||||
        }
 | 
			
		||||
        for (const tagButton of tagButtons) {
 | 
			
		||||
            if (tagButton.dataset.value === tagName) {
 | 
			
		||||
                tagButton.classList.remove("btn-outline-primary");
 | 
			
		||||
                tagButton.classList.add("btn-primary");
 | 
			
		||||
                this.#filterSuggestedAccounts(tagButton);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        this.#switchToTab("bus");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Populates a general trip.
 | 
			
		||||
     *
 | 
			
		||||
     * @param tagName {string} the tag name
 | 
			
		||||
     * @param fromName {string} the name of the origin
 | 
			
		||||
     * @param direction {string} the direction arrow, either "→" or "↔"
 | 
			
		||||
     * @param toName {string} the name of the destination
 | 
			
		||||
     * @param numberStr {string|undefined} the number of items, if any
 | 
			
		||||
     */
 | 
			
		||||
    #populateGeneralTrip(tagName, fromName, direction, toName, numberStr) {
 | 
			
		||||
        const tag = document.getElementById(this.#prefix + "-travel-tag");
 | 
			
		||||
        const from = document.getElementById(this.#prefix + "-travel-from");
 | 
			
		||||
        const to = document.getElementById(this.#prefix + "-travel-to");
 | 
			
		||||
        const number = document.getElementById(this.#prefix + "-number");
 | 
			
		||||
        const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-btn-tag"));
 | 
			
		||||
        const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction"));
 | 
			
		||||
        tag.value = tagName;
 | 
			
		||||
        from.value = fromName;
 | 
			
		||||
        for (const directionButton of directionButtons) {
 | 
			
		||||
            if (directionButton.dataset.arrow === direction) {
 | 
			
		||||
                directionButton.classList.remove("btn-outline-primary");
 | 
			
		||||
                directionButton.classList.add("btn-primary");
 | 
			
		||||
            } else {
 | 
			
		||||
                directionButton.classList.add("btn-outline-primary");
 | 
			
		||||
                directionButton.classList.remove("btn-primary");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        to.value = toName;
 | 
			
		||||
        if (numberStr !== undefined) {
 | 
			
		||||
            number.value = parseInt(numberStr);
 | 
			
		||||
        }
 | 
			
		||||
        for (const tagButton of tagButtons) {
 | 
			
		||||
            if (tagButton.dataset.value === tagName) {
 | 
			
		||||
                tagButton.classList.remove("btn-outline-primary");
 | 
			
		||||
                tagButton.classList.add("btn-primary");
 | 
			
		||||
                this.#filterSuggestedAccounts(tagButton);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        this.#switchToTab("travel");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Populates a general tag.
 | 
			
		||||
     *
 | 
			
		||||
     * @param tagName {string} the tag name
 | 
			
		||||
     * @param numberStr {string|undefined} the number of items, if any
 | 
			
		||||
     */
 | 
			
		||||
    #populateGeneralTag(tagName, numberStr) {
 | 
			
		||||
        const tag = document.getElementById(this.#prefix + "-general-tag");
 | 
			
		||||
        const number = document.getElementById(this.#prefix + "-number");
 | 
			
		||||
        const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-general-btn-tag"));
 | 
			
		||||
        tag.value = tagName;
 | 
			
		||||
        if (numberStr !== undefined) {
 | 
			
		||||
            number.value = parseInt(numberStr);
 | 
			
		||||
        }
 | 
			
		||||
        for (const tagButton of tagButtons) {
 | 
			
		||||
            if (tagButton.dataset.value === tagName) {
 | 
			
		||||
                tagButton.classList.remove("btn-outline-primary");
 | 
			
		||||
                tagButton.classList.add("btn-primary");
 | 
			
		||||
                this.#filterSuggestedAccounts(tagButton);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        this.#switchToTab("general");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The summary helpers.
 | 
			
		||||
     * @type {{debit: SummaryHelper, credit: SummaryHelper}}
 | 
			
		||||
     */
 | 
			
		||||
    static #helpers = {}
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the summary helpers.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    static initialize() {
 | 
			
		||||
        const forms = Array.from(document.getElementsByClassName("accounting-summary-helper"));
 | 
			
		||||
        for (const form of forms) {
 | 
			
		||||
            const helper = new SummaryHelper(form);
 | 
			
		||||
            this.#helpers[helper.#entryType] = helper;
 | 
			
		||||
        }
 | 
			
		||||
        this.#initializeTransactionForm();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the transaction form.
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    static #initializeTransactionForm() {
 | 
			
		||||
        const entryForm = document.getElementById("accounting-entry-form");
 | 
			
		||||
        const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
 | 
			
		||||
        const helpers = this.#helpers;
 | 
			
		||||
        formSummaryControl.onclick = function () {
 | 
			
		||||
            helpers[entryForm.dataset.entryType].initShow(false);
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Initializes the summary helper for a new journal entry.
 | 
			
		||||
     *
 | 
			
		||||
     * @param entryType {string} the entry type, either "debit" or "credit"
 | 
			
		||||
     */
 | 
			
		||||
    static initializeNewJournalEntry(entryType) {
 | 
			
		||||
        this.#helpers[entryType].initShow(true);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -20,12 +20,13 @@
 | 
			
		||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
 * First written: 2023/2/26
 | 
			
		||||
 */
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
// Initializes the page JavaScript.
 | 
			
		||||
document.addEventListener("DOMContentLoaded", function () {
 | 
			
		||||
document.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
    const list = document.getElementById("accounting-order-list");
 | 
			
		||||
    if (list !== null) {
 | 
			
		||||
        const onReorder = function () {
 | 
			
		||||
        const onReorder = () => {
 | 
			
		||||
            const accounts = Array.from(list.children);
 | 
			
		||||
            for (let i = 0; i < accounts.length; i++) {
 | 
			
		||||
                const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										81
									
								
								src/accounting/template_filters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								src/accounting/template_filters.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,81 @@
 | 
			
		||||
# The Mia! Accounting Flask Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/25
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
#  You may obtain a copy of the License at
 | 
			
		||||
#
 | 
			
		||||
#      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
#
 | 
			
		||||
#  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
"""The template filters.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import typing as t
 | 
			
		||||
from datetime import date, timedelta
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
from flask_babel import get_locale
 | 
			
		||||
 | 
			
		||||
from accounting.locale import gettext
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def format_amount(value: Decimal | None) -> str | None:
 | 
			
		||||
    """Formats an amount for readability.
 | 
			
		||||
 | 
			
		||||
    :param value: The amount.
 | 
			
		||||
    :return: The formatted amount text.
 | 
			
		||||
    """
 | 
			
		||||
    if value is None:
 | 
			
		||||
        return None
 | 
			
		||||
    if value == 0:
 | 
			
		||||
        return "-"
 | 
			
		||||
    whole: int = int(value)
 | 
			
		||||
    frac: Decimal = (value - whole).normalize()
 | 
			
		||||
    return "{:,}".format(whole) + str(abs(frac))[1:]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def format_date(value: date) -> str:
 | 
			
		||||
    """Formats a date to be human-friendly.
 | 
			
		||||
 | 
			
		||||
    :param value: The date.
 | 
			
		||||
    :return: The human-friendly date text.
 | 
			
		||||
    """
 | 
			
		||||
    today: date = date.today()
 | 
			
		||||
    if value == today:
 | 
			
		||||
        return gettext("Today")
 | 
			
		||||
    if value == today - timedelta(days=1):
 | 
			
		||||
        return gettext("Yesterday")
 | 
			
		||||
    if value == today + timedelta(days=1):
 | 
			
		||||
        return gettext("Tomorrow")
 | 
			
		||||
    locale = str(get_locale())
 | 
			
		||||
    if locale == "zh" or locale.startswith("zh_"):
 | 
			
		||||
        if value == today - timedelta(days=2):
 | 
			
		||||
            return gettext("The day before yesterday")
 | 
			
		||||
        if value == today + timedelta(days=2):
 | 
			
		||||
            return gettext("The day after tomorrow")
 | 
			
		||||
    if locale == "zh" or locale.startswith("zh_"):
 | 
			
		||||
        weekdays = ["一", "二", "三", "四", "五", "六", "日"]
 | 
			
		||||
        weekday = weekdays[value.weekday()]
 | 
			
		||||
    else:
 | 
			
		||||
        weekday = value.strftime("%a")
 | 
			
		||||
    if value.year != today.year:
 | 
			
		||||
        return "{}/{}/{}({})".format(
 | 
			
		||||
            value.year, value.month, value.day, weekday)
 | 
			
		||||
    return "{}/{}({})".format(value.month, value.day, weekday)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def default(value: t.Any, default_value: t.Any = "") -> t.Any:
 | 
			
		||||
    """Returns the default value if the given value is None.
 | 
			
		||||
 | 
			
		||||
    :param value: The value.
 | 
			
		||||
    :param default_value: The default value when the given value is None.
 | 
			
		||||
    :return: The value, or the default value if the given value is None.
 | 
			
		||||
    """
 | 
			
		||||
    return default_value if value is None else value
 | 
			
		||||
							
								
								
									
										39
									
								
								src/accounting/template_globals.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/accounting/template_globals.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,39 @@
 | 
			
		||||
# The Mia! Accounting Flask Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
#  You may obtain a copy of the License at
 | 
			
		||||
#
 | 
			
		||||
#      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
#
 | 
			
		||||
#  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
"""The template globals for the transaction management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from flask import current_app
 | 
			
		||||
 | 
			
		||||
from accounting.models import Currency
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def currency_options() -> str:
 | 
			
		||||
    """Returns the currency options.
 | 
			
		||||
 | 
			
		||||
    :return: The currency options.
 | 
			
		||||
    """
 | 
			
		||||
    return Currency.query.order_by(Currency.code).all()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def default_currency_code() -> str:
 | 
			
		||||
    """Returns the default currency code.
 | 
			
		||||
 | 
			
		||||
    :return: The default currency code.
 | 
			
		||||
    """
 | 
			
		||||
    with current_app.app_context():
 | 
			
		||||
        return current_app.config.get("DEFAULT_CURRENCY", "USD")
 | 
			
		||||
@@ -59,8 +59,8 @@ First written: 2023/1/31
 | 
			
		||||
{% if accounting_can_edit() %}
 | 
			
		||||
  <form action="{{ url_for("accounting.account.delete", account=obj) }}" method="post">
 | 
			
		||||
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
			
		||||
    {% if "next" in request.args %}
 | 
			
		||||
      <input type="hidden" name="next" value="{{ request.args["next"] }}">
 | 
			
		||||
    {% if request.args.next %}
 | 
			
		||||
      <input type="hidden" name="next" value="{{ request.args.next }}">
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    <div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
 | 
			
		||||
      <div class="modal-dialog">
 | 
			
		||||
@@ -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_offset_needed %}
 | 
			
		||||
    <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">
 | 
			
		||||
 
 | 
			
		||||
@@ -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") }}
 | 
			
		||||
@@ -36,14 +36,14 @@ First written: 2023/2/1
 | 
			
		||||
 | 
			
		||||
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post">
 | 
			
		||||
  {{ form.csrf_token }}
 | 
			
		||||
  {% if "next" in request.args %}
 | 
			
		||||
    <input type="hidden" name="next" value="{{ request.args["next"] }}">
 | 
			
		||||
  {% if request.args.next %}
 | 
			
		||||
    <input type="hidden" name="next" value="{{ request.args.next }}">
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  <div class="form-floating mb-3">
 | 
			
		||||
    <input id="accounting-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}">
 | 
			
		||||
    <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">
 | 
			
		||||
    <input id="accounting-base-code" type="hidden" name="base_code" value="{{ form.base_code.data|accounting_default }}">
 | 
			
		||||
    <div id="accounting-base-control" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-modal">
 | 
			
		||||
      <label class="form-label" for="accounting-base">{{ A_("Base account") }}</label>
 | 
			
		||||
      <div id="accounting-base-content">
 | 
			
		||||
      <div id="accounting-base">
 | 
			
		||||
        {% if form.base_code.data %}
 | 
			
		||||
          {% if form.base_code.errors %}
 | 
			
		||||
            {{ A_("(Unknown)") }}
 | 
			
		||||
@@ -53,19 +53,19 @@ 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">
 | 
			
		||||
    <input id="accounting-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ "" if form.title.data is none else form.title.data }}" placeholder=" " required="required">
 | 
			
		||||
    <input id="accounting-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ form.title.data|accounting_default }}" placeholder=" " required="required">
 | 
			
		||||
    <label class="form-label" for="accounting-title">{{ A_("Title") }}</label>
 | 
			
		||||
    <div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <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-offset-needed-control" class="form-check form-switch mb-3 {% if form.base_code.data[0] not in ["1", "2"] %} d-none {% endif %}">
 | 
			
		||||
    <input id="accounting-is-offset-needed" class="form-check-input" type="checkbox" name="is_offset_needed" value="1" {% if form.is_offset_needed.data %} checked="checked" {% endif %}>
 | 
			
		||||
    <label class="form-check-label" for="accounting-is-offset-needed">
 | 
			
		||||
      {{ 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 }}">
 | 
			
		||||
            {{ base }}
 | 
			
		||||
          </li>
 | 
			
		||||
            <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>
 | 
			
		||||
 
 | 
			
		||||
@@ -21,35 +21,23 @@ First written: 2023/1/30
 | 
			
		||||
#}
 | 
			
		||||
{% extends "accounting/base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Account Management") }}{% endif %}{% endblock %}{% endblock %}
 | 
			
		||||
{% block header %}{% block title %}{% if request.args.q %}{{ A_("Search Result for \"%(query)s\"", query=request.args.q) }}{% else %}{{ A_("Account Management") }}{% endif %}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% 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" 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"] if "q" in request.args else "" }}" 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"] if "q" in request.args else "" }}" placeholder=" " required="required">
 | 
			
		||||
    <label for="accounting-search-mobile" class="accounting-search-label">
 | 
			
		||||
      <button type="submit">
 | 
			
		||||
        <i class="fa-solid fa-magnifying-glass"></i>
 | 
			
		||||
        {{ A_("Search") }}
 | 
			
		||||
        <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_offset_needed %}
 | 
			
		||||
        <span class="badge rounded-pill bg-info">{{ A_("Need offset") }}</span>
 | 
			
		||||
      {% endif %}
 | 
			
		||||
    </a>
 | 
			
		||||
  {% endfor %}
 | 
			
		||||
 
 | 
			
		||||
@@ -40,8 +40,8 @@ First written: 2023/2/2
 | 
			
		||||
{% if base.accounts|length > 1 and accounting_can_edit() %}
 | 
			
		||||
  <form action="{{ url_for("accounting.account.sort", base=base) }}" method="post">
 | 
			
		||||
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
			
		||||
    {% if "next" in request.args %}
 | 
			
		||||
      <input type="hidden" name="next" value="{{ request.args["next"] }}">
 | 
			
		||||
    {% if request.args.next %}
 | 
			
		||||
      <input type="hidden" name="next" value="{{ request.args.next }}">
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    <ul id="accounting-order-list" class="list-group mb-3" data-base-code="{{ base.code }}">
 | 
			
		||||
      {% for account in base.accounts|sort(attribute="no") %}
 | 
			
		||||
 
 | 
			
		||||
@@ -21,17 +21,17 @@ First written: 2023/1/26
 | 
			
		||||
#}
 | 
			
		||||
{% extends "accounting/base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Base Account Managements") }}{% endif %}{% endblock %}{% endblock %}
 | 
			
		||||
{% block header %}{% block title %}{% if request.args.q %}{{ A_("Search Result for \"%(query)s\"", query=request.args.q) }}{% else %}{{ A_("Base Account Managements") }}{% endif %}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="btn-group mb-2">
 | 
			
		||||
  <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-label="{{ A_("Search") }}">
 | 
			
		||||
    <input id="accounting-search" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
 | 
			
		||||
    <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>
 | 
			
		||||
 
 | 
			
		||||
@@ -55,8 +55,8 @@ First written: 2023/2/6
 | 
			
		||||
{% if accounting_can_edit() %}
 | 
			
		||||
  <form action="{{ url_for("accounting.currency.delete", currency=obj) }}" method="post">
 | 
			
		||||
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
			
		||||
    {% if "next" in request.args %}
 | 
			
		||||
      <input type="hidden" name="next" value="{{ request.args["next"] }}">
 | 
			
		||||
    {% if request.args.next %}
 | 
			
		||||
      <input type="hidden" name="next" value="{{ request.args.next }}">
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    <div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
 | 
			
		||||
      <div class="modal-dialog">
 | 
			
		||||
 
 | 
			
		||||
@@ -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") }}
 | 
			
		||||
@@ -36,17 +36,17 @@ First written: 2023/2/6
 | 
			
		||||
 | 
			
		||||
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post">
 | 
			
		||||
  {{ form.csrf_token }}
 | 
			
		||||
  {% if "next" in request.args %}
 | 
			
		||||
    <input type="hidden" name="next" value="{{ request.args["next"] }}">
 | 
			
		||||
  {% if request.args.next %}
 | 
			
		||||
    <input type="hidden" name="next" value="{{ request.args.next }}">
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  <div class="form-floating mb-3">
 | 
			
		||||
    <input id="accounting-code" class="form-control {% if form.code.errors %} is-invalid {% endif %}" type="text" name="code" value="{{ "" if form.code.data is none else form.code.data }}" placeholder=" " required="required" data-exists-url="{{ url_for("accounting.currency-api.exists") }}" data-original="{% block original_code %}{% endblock %}" data-blocklist="{{ form.CODE_BLOCKLIST|tojson|forceescape }}">
 | 
			
		||||
    <input id="accounting-code" class="form-control {% if form.code.errors %} is-invalid {% endif %}" type="text" name="code" value="{{ form.code.data|accounting_default }}" placeholder=" " required="required" data-exists-url="{{ url_for("accounting.currency-api.exists") }}" data-original="{% block original_code %}{% endblock %}" data-blocklist="{{ form.CODE_BLOCKLIST|tojson|forceescape }}">
 | 
			
		||||
    <label class="form-label" for="accounting-code">{{ A_("Code") }}</label>
 | 
			
		||||
    <div id="accounting-code-error" class="invalid-feedback">{% if form.code.errors %}{{ form.code.errors[0] }}{% endif %}</div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div class="form-floating mb-3">
 | 
			
		||||
    <input id="accounting-name" class="form-control {% if form.name.errors %} is-invalid {% endif %}" type="text" name="name" value="{{ "" if form.name.data is none else form.name.data }}" placeholder=" " required="required">
 | 
			
		||||
    <input id="accounting-name" class="form-control {% if form.name.errors %} is-invalid {% endif %}" type="text" name="name" value="{{ form.name.data|accounting_default }}" placeholder=" " required="required">
 | 
			
		||||
    <label class="form-label" for="accounting-name">{{ A_("Name") }}</label>
 | 
			
		||||
    <div id="accounting-name-error" class="invalid-feedback">{% if form.name.errors %}{{ form.name.errors[0] }}{% endif %}</div>
 | 
			
		||||
  </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -21,35 +21,23 @@ First written: 2023/2/6
 | 
			
		||||
#}
 | 
			
		||||
{% extends "accounting/base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Currency Management") }}{% endif %}{% endblock %}{% endblock %}
 | 
			
		||||
{% block header %}{% block title %}{% if request.args.q %}{{ A_("Search Result for \"%(query)s\"", query=request.args.q) }}{% else %}{{ A_("Currency Management") }}{% endif %}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% 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" 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"] if "q" in request.args else "" }}" 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"] if "q" in request.args else "" }}" placeholder=" " required="required">
 | 
			
		||||
    <label for="accounting-search-mobile" class="accounting-search-label">
 | 
			
		||||
      <button type="submit">
 | 
			
		||||
        <i class="fa-solid fa-magnifying-glass"></i>
 | 
			
		||||
        {{ A_("Search") }}
 | 
			
		||||
        <span class="d-none d-md-inline">{{ A_("Search") }}</span>
 | 
			
		||||
      </button>
 | 
			
		||||
    </label>
 | 
			
		||||
  </form>
 | 
			
		||||
 
 | 
			
		||||
@@ -28,14 +28,14 @@ First written: 2023/1/26
 | 
			
		||||
    </span>
 | 
			
		||||
    <ul class="dropdown-menu">
 | 
			
		||||
      <li>
 | 
			
		||||
        <a class="dropdown-item {% if request.endpoint.startswith("accounting.transaction.") %} active {% endif %}" href="{{ url_for("accounting.transaction.list") }}">
 | 
			
		||||
          <i class="fa-solid fa-receipt"></i>
 | 
			
		||||
          {{ A_("Transactions") }}
 | 
			
		||||
        <a 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>
 | 
			
		||||
      </li>
 | 
			
		||||
      <li>
 | 
			
		||||
        <a class="dropdown-item {% if request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}">
 | 
			
		||||
          <i class="fa-solid fa-list"></i>
 | 
			
		||||
          <i class="fa-solid fa-clipboard"></i>
 | 
			
		||||
          {{ A_("Accounts") }}
 | 
			
		||||
        </a>
 | 
			
		||||
      </li>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										113
									
								
								src/accounting/templates/accounting/report/balance-sheet.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								src/accounting/templates/accounting/report/balance-sheet.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,113 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
income-statement.html: The income statement
 | 
			
		||||
 | 
			
		||||
 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/7
 | 
			
		||||
#}
 | 
			
		||||
{% extends "accounting/base.html" %}
 | 
			
		||||
 | 
			
		||||
{% 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_("Balance Sheet of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="mb-3 accounting-toolbar">
 | 
			
		||||
  {% with use_currency_chooser = true,
 | 
			
		||||
          use_period_chooser = true %}
 | 
			
		||||
    {% include "accounting/report/include/toolbar-buttons.html" %}
 | 
			
		||||
  {% endwith %}
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
{% include "accounting/report/include/add-txn-material-fab.html" %}
 | 
			
		||||
 | 
			
		||||
{% include "accounting/report/include/period-chooser.html" %}
 | 
			
		||||
 | 
			
		||||
{% 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">{{ 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 %}
 | 
			
		||||
          {% 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>
 | 
			
		||||
          </div>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div class="col-sm-6">
 | 
			
		||||
        {% if report.liabilities.subsections %}
 | 
			
		||||
          {% 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>
 | 
			
		||||
          </div>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
 | 
			
		||||
        {% if report.owner_s_equity.subsections %}
 | 
			
		||||
          {% 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>
 | 
			
		||||
          </div>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
 | 
			
		||||
        <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.liabilities.total < 0 %} text-danger {% endif %}">{{ (report.liabilities.total + report.owner_s_equity.total)|accounting_report_format_amount }}</div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="row d-none d-md-flex">
 | 
			
		||||
      <div class="col-sm-6">
 | 
			
		||||
        <div class="d-flex justify-content-between 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>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div class="col-sm-6">
 | 
			
		||||
        <div class="d-flex justify-content-between accounting-balance-sheet-total">
 | 
			
		||||
          <div>{{ A_("Total") }}</div>
 | 
			
		||||
          <div class="accounting-amount {% if report.liabilities.total + report.owner_s_equity.total < 0 %} text-danger {% endif %}">{{ (report.liabilities.total + report.owner_s_equity.total)|accounting_report_format_amount }}</div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
{% else %}
 | 
			
		||||
  <p>{{ A_("There is no data.") }}</p>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
add-new-material-fab.html: The material floating action buttons to add a new transaction
 | 
			
		||||
add-txn-material-fab.html: The material floating action buttons to add a new transaction
 | 
			
		||||
 | 
			
		||||
 Copyright (c) 2023 imacat.
 | 
			
		||||
 | 
			
		||||
@@ -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=types.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=types.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=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>
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -0,0 +1,50 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
income-expenses-row-mobile.html: The row in the income and expenses log for the mobile devices
 | 
			
		||||
 | 
			
		||||
 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/5
 | 
			
		||||
#}
 | 
			
		||||
<div>
 | 
			
		||||
  {% if entry.date or entry.account %}
 | 
			
		||||
    <div class="text-muted small">
 | 
			
		||||
      {% if entry.date %}
 | 
			
		||||
        {{ entry.date|accounting_format_date }}
 | 
			
		||||
      {% endif %}
 | 
			
		||||
      {% if entry.account %}
 | 
			
		||||
        {{ entry.account.title|title }}
 | 
			
		||||
      {% endif %}
 | 
			
		||||
    </div>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  {% if entry.summary %}
 | 
			
		||||
    <div>{{ entry.summary }}</div>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div class="text-nowrap">
 | 
			
		||||
  {% if entry.income %}
 | 
			
		||||
    <span class="badge rounded-pill bg-success">+{{ entry.income|accounting_format_amount }}</span>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  {% if entry.expense %}
 | 
			
		||||
    <span class="badge rounded-pill bg-warning">-{{ entry.expense|accounting_format_amount }}</span>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  {% if entry.balance < 0 %}
 | 
			
		||||
    <span class="badge rounded-pill bg-danger">{{ entry.balance|accounting_format_amount }}</span>
 | 
			
		||||
  {% else %}
 | 
			
		||||
    <span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
</div>
 | 
			
		||||
@@ -0,0 +1,26 @@
 | 
			
		||||
{#
 | 
			
		||||
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>
 | 
			
		||||
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
 | 
			
		||||
@@ -0,0 +1,41 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
ledger-row-mobile.html: The row in the ledger for the mobile devices
 | 
			
		||||
 | 
			
		||||
 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/5
 | 
			
		||||
#}
 | 
			
		||||
<div>
 | 
			
		||||
  {% if entry.date %}
 | 
			
		||||
    <div class="text-muted small">
 | 
			
		||||
      {{ entry.date|accounting_format_date }}
 | 
			
		||||
    </div>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  {% if entry.summary %}
 | 
			
		||||
    <div>{{ entry.summary }}</div>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div>
 | 
			
		||||
  {% if entry.debit %}
 | 
			
		||||
    <span class="badge rounded-pill bg-success">+{{ entry.debit|accounting_format_amount }}</span>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  {% if entry.credit %}
 | 
			
		||||
    <span class="badge rounded-pill bg-warning">-{{ entry.credit|accounting_format_amount }}</span>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  <span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -0,0 +1,150 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
period-chooser.html: The period 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 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">
 | 
			
		||||
        <h1 class="modal-title fs-5" id="accounting-period-chooser-modal-label">{{ A_("Period Chooser") }}</h1>
 | 
			
		||||
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="modal-body">
 | 
			
		||||
        {# Tab navigation #}
 | 
			
		||||
        <ul class="nav nav-tabs mb-2">
 | 
			
		||||
          <li class="nav-item">
 | 
			
		||||
            <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 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 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 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 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 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 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 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 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 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 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 report.period_chooser.available_years %}
 | 
			
		||||
            <ul class="nav nav-pills mt-3">
 | 
			
		||||
              {% for year in report.period_chooser.available_years %}
 | 
			
		||||
                <li class="nav-item">
 | 
			
		||||
                  <a class="nav-link {% if report.period.is_year(year) %} active {% endif %}" href="{{ report.period_chooser.year_url(year) }}">{{ year }}</a>
 | 
			
		||||
                </li>
 | 
			
		||||
              {% endfor %}
 | 
			
		||||
            </ul>
 | 
			
		||||
          {% endif %}
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        {# The day periods #}
 | 
			
		||||
        <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 report.period.is_today %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.today_url }}">
 | 
			
		||||
              {{ A_("Today") }}
 | 
			
		||||
            </a>
 | 
			
		||||
            {% 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 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="{{ 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>
 | 
			
		||||
            </div>
 | 
			
		||||
          {% endif %}
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        {# The custom periods #}
 | 
			
		||||
        <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 report.period.is_all %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.all_url }}">
 | 
			
		||||
              {{ A_("All") }}
 | 
			
		||||
            </a>
 | 
			
		||||
          </div>
 | 
			
		||||
          {% 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="{{ 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="{{ 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>
 | 
			
		||||
 | 
			
		||||
              <div>
 | 
			
		||||
                <button id="accounting-period-chooser-custom-confirm" class="btn btn-primary" type="submit">
 | 
			
		||||
                  {{ A_("Confirm") }}
 | 
			
		||||
                </button>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          {% endif %}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -0,0 +1,43 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
search-modal.html: The search modal
 | 
			
		||||
 | 
			
		||||
 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
 | 
			
		||||
#}
 | 
			
		||||
<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">
 | 
			
		||||
        <div class="modal-header">
 | 
			
		||||
          <h1 class="modal-title fs-5" id="accounting-search-modal-label">{{ A_("Search") }}</h1>
 | 
			
		||||
          <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="modal-body">
 | 
			
		||||
          <div class="form-floating">
 | 
			
		||||
            <input id="accounting-search-modal-search" class="form-control" type="text" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
 | 
			
		||||
            <label for="accounting-search-modal-search">{{ A_("Search") }}</label>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="modal-footer">
 | 
			
		||||
          <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
 | 
			
		||||
          <button type="submit" class="btn btn-primary">{{ A_("Search") }}</button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</form>
 | 
			
		||||
@@ -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 %}
 | 
			
		||||
							
								
								
									
										116
									
								
								src/accounting/templates/accounting/report/income-expenses.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								src/accounting/templates/accounting/report/income-expenses.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,116 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
income-expenses.html: The income and expenses log
 | 
			
		||||
 | 
			
		||||
 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/5
 | 
			
		||||
#}
 | 
			
		||||
{% extends "accounting/base.html" %}
 | 
			
		||||
 | 
			
		||||
{% 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_("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="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>
 | 
			
		||||
 | 
			
		||||
{% include "accounting/report/include/add-txn-material-fab.html" %}
 | 
			
		||||
 | 
			
		||||
{% include "accounting/report/include/period-chooser.html" %}
 | 
			
		||||
 | 
			
		||||
{% include "accounting/report/include/search-modal.html" %}
 | 
			
		||||
 | 
			
		||||
{% if report.has_data %}
 | 
			
		||||
  {% with pagination = report.pagination %}
 | 
			
		||||
    {% include "accounting/include/pagination.html" %}
 | 
			
		||||
  {% endwith %}
 | 
			
		||||
 | 
			
		||||
  <div class="d-none d-md-block accounting-report-table accounting-income-expenses-table">
 | 
			
		||||
    <div class="accounting-report-table-header">
 | 
			
		||||
      <div class="accounting-report-table-row">
 | 
			
		||||
        <div>{{ A_("Date") }}</div>
 | 
			
		||||
        <div>{{ A_("Account") }}</div>
 | 
			
		||||
        <div>{{ A_("Summary") }}</div>
 | 
			
		||||
        <div class="accounting-amount">{{ A_("Income") }}</div>
 | 
			
		||||
        <div class="accounting-amount">{{ A_("Expense") }}</div>
 | 
			
		||||
        <div class="accounting-amount">{{ A_("Balance") }}</div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="accounting-report-table-body">
 | 
			
		||||
      {% if report.brought_forward %}
 | 
			
		||||
        {% with entry = report.brought_forward %}
 | 
			
		||||
          <div class="accounting-report-table-row">
 | 
			
		||||
            {% include "accounting/report/include/income-expenses-row-desktop.html" %}
 | 
			
		||||
          </div>
 | 
			
		||||
        {% endwith %}
 | 
			
		||||
      {% endif %}
 | 
			
		||||
      {% for entry in report.entries %}
 | 
			
		||||
        <a class="accounting-report-table-row" href="{{ entry.url|accounting_append_next }}">
 | 
			
		||||
          {% include "accounting/report/include/income-expenses-row-desktop.html" %}
 | 
			
		||||
        </a>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
    </div>
 | 
			
		||||
    {% if report.total %}
 | 
			
		||||
      {% with entry = report.total %}
 | 
			
		||||
        <div class="accounting-report-table-footer">
 | 
			
		||||
          <div class="accounting-report-table-row">
 | 
			
		||||
            <div>{{ A_("Total") }}</div>
 | 
			
		||||
            <div class="accounting-amount">{{ entry.income|accounting_format_amount }}</div>
 | 
			
		||||
            <div class="accounting-amount">{{ entry.expense|accounting_format_amount }}</div>
 | 
			
		||||
            <div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      {% endwith %}
 | 
			
		||||
    {% endif %}
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div class="list-group d-md-none">
 | 
			
		||||
  {% 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-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="{{ 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-row-mobile.html" %}
 | 
			
		||||
      </div>
 | 
			
		||||
    {% endwith %}
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  </div>
 | 
			
		||||
{% else %}
 | 
			
		||||
  <p>{{ A_("There is no data.") }}</p>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -0,0 +1,99 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
income-statement.html: The income statement
 | 
			
		||||
 | 
			
		||||
 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/7
 | 
			
		||||
#}
 | 
			
		||||
{% extends "accounting/base.html" %}
 | 
			
		||||
 | 
			
		||||
{% 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_("Income Statement of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="mb-3 accounting-toolbar">
 | 
			
		||||
  {% with use_currency_chooser = true,
 | 
			
		||||
          use_period_chooser = true %}
 | 
			
		||||
    {% include "accounting/report/include/toolbar-buttons.html" %}
 | 
			
		||||
  {% endwith %}
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
{% include "accounting/report/include/add-txn-material-fab.html" %}
 | 
			
		||||
 | 
			
		||||
{% include "accounting/report/include/period-chooser.html" %}
 | 
			
		||||
 | 
			
		||||
{% 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">{{ 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">
 | 
			
		||||
      <div class="accounting-report-table-header">
 | 
			
		||||
        <div class="accounting-report-table-row">
 | 
			
		||||
          <div class="accounting-amount">{{ A_("Amount") }}</div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="accounting-report-table-body">
 | 
			
		||||
        {% for section in report.sections %}
 | 
			
		||||
          <div class="accounting-report-table-row accounting-income-statement-section">
 | 
			
		||||
            <div>
 | 
			
		||||
              <span class="d-none d-md-inline">{{ section.title.code }}</span>
 | 
			
		||||
              {{ section.title.title|title }}
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
          {% for subsection in section.subsections %}
 | 
			
		||||
            <div class="accounting-report-table-row accounting-income-statement-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="accounting-report-table-row accounting-income-statement-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 %}
 | 
			
		||||
            <div class="accounting-report-table-row accounting-income-statement-subtotal">
 | 
			
		||||
              <div>{{ A_("Total") }}</div>
 | 
			
		||||
              <div class="accounting-amount {% if subsection.total < 0 %} text-danger {% endif %}">{{ subsection.total|accounting_report_format_amount }}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
          <div class="accounting-report-table-row accounting-income-statement-total">
 | 
			
		||||
            <div>{{ section.accumulated.title|title }}</div>
 | 
			
		||||
            <div class="accounting-amount {% if section.accumulated.amount < 0 %} text-danger {% endif %}">{{ section.accumulated.amount|accounting_report_format_amount }}</div>
 | 
			
		||||
          </div>
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
{% else %}
 | 
			
		||||
  <p>{{ A_("There is no data.") }}</p>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										106
									
								
								src/accounting/templates/accounting/report/journal.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								src/accounting/templates/accounting/report/journal.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,106 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
journal.html: The journal
 | 
			
		||||
 | 
			
		||||
 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
 | 
			
		||||
#}
 | 
			
		||||
{% extends "accounting/base.html" %}
 | 
			
		||||
 | 
			
		||||
{% 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_("Journal %(period)s", period=report.period.desc|title) }}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="mb-3 accounting-toolbar">
 | 
			
		||||
  {% with use_period_chooser = true %}
 | 
			
		||||
    {% include "accounting/report/include/toolbar-buttons.html" %}
 | 
			
		||||
  {% endwith %}
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
{% include "accounting/report/include/add-txn-material-fab.html" %}
 | 
			
		||||
 | 
			
		||||
{% include "accounting/report/include/period-chooser.html" %}
 | 
			
		||||
 | 
			
		||||
{% include "accounting/report/include/search-modal.html" %}
 | 
			
		||||
 | 
			
		||||
{% if report.has_data %}
 | 
			
		||||
  {% with pagination = report.pagination %}
 | 
			
		||||
    {% include "accounting/include/pagination.html" %}
 | 
			
		||||
  {% endwith %}
 | 
			
		||||
 | 
			
		||||
  <div class="d-none d-md-block accounting-report-table accounting-journal-table">
 | 
			
		||||
    <div class="accounting-report-table-header">
 | 
			
		||||
      <div class="accounting-report-table-row">
 | 
			
		||||
        <div>{{ A_("Date") }}</div>
 | 
			
		||||
        <div>{{ A_("Currency") }}</div>
 | 
			
		||||
        <div>{{ A_("Account") }}</div>
 | 
			
		||||
        <div>{{ A_("Summary") }}</div>
 | 
			
		||||
        <div class="accounting-amount">{{ A_("Debit") }}</div>
 | 
			
		||||
        <div class="accounting-amount">{{ A_("Credit") }}</div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="accounting-report-table-body">
 | 
			
		||||
      {% 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.transaction.date|accounting_format_date }}</div>
 | 
			
		||||
          <div>{{ entry.currency.name }}</div>
 | 
			
		||||
          <div>
 | 
			
		||||
            <span class="d-none d-md-inline">{{ entry.account.code }}</span>
 | 
			
		||||
            {{ entry.account.title|title }}
 | 
			
		||||
          </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>
 | 
			
		||||
        </a>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div class="list-group d-md-none">
 | 
			
		||||
  {% for entry in report.entries %}
 | 
			
		||||
    <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
 | 
			
		||||
      <div class="d-flex justify-content-between">
 | 
			
		||||
        <div {% if not entry.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
 | 
			
		||||
          <div class="text-muted small">
 | 
			
		||||
            {{ entry.transaction.date|accounting_format_date }}
 | 
			
		||||
            {{ entry.account.title|title }}
 | 
			
		||||
            {% if entry.currency.code != accounting_default_currency_code() %}
 | 
			
		||||
              <span class="badge rounded-pill bg-info">{{ entry.currency.code }}</span>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
          </div>
 | 
			
		||||
          {% if entry.summary is not none %}
 | 
			
		||||
            <div>{{ entry.summary }}</div>
 | 
			
		||||
          {% endif %}
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div>
 | 
			
		||||
          <span class="badge rounded-pill bg-info">{{ entry.amount|accounting_format_amount }}</span>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </a>
 | 
			
		||||
  {% endfor %}
 | 
			
		||||
  </div>
 | 
			
		||||
{% else %}
 | 
			
		||||
  <p>{{ A_("There is no data.") }}</p>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										115
									
								
								src/accounting/templates/accounting/report/ledger.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								src/accounting/templates/accounting/report/ledger.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,115 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
ledger.html: The ledger
 | 
			
		||||
 | 
			
		||||
 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/5
 | 
			
		||||
#}
 | 
			
		||||
{% extends "accounting/base.html" %}
 | 
			
		||||
 | 
			
		||||
{% 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_("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="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>
 | 
			
		||||
 | 
			
		||||
{% include "accounting/report/include/add-txn-material-fab.html" %}
 | 
			
		||||
 | 
			
		||||
{% include "accounting/report/include/period-chooser.html" %}
 | 
			
		||||
 | 
			
		||||
{% include "accounting/report/include/search-modal.html" %}
 | 
			
		||||
 | 
			
		||||
{% if report.has_data %}
 | 
			
		||||
  {% with pagination = report.pagination %}
 | 
			
		||||
    {% include "accounting/include/pagination.html" %}
 | 
			
		||||
  {% endwith %}
 | 
			
		||||
 | 
			
		||||
  <div class="d-none d-md-block accounting-report-table accounting-ledger-table">
 | 
			
		||||
    <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>
 | 
			
		||||
        <div class="accounting-amount">{{ A_("Balance") }}</div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="accounting-report-table-body">
 | 
			
		||||
      {% if report.brought_forward %}
 | 
			
		||||
        {% with entry = report.brought_forward %}
 | 
			
		||||
          <div class="accounting-report-table-row">
 | 
			
		||||
            {% include "accounting/report/include/ledger-row-desktop.html" %}
 | 
			
		||||
          </div>
 | 
			
		||||
        {% endwith %}
 | 
			
		||||
      {% endif %}
 | 
			
		||||
      {% for entry in report.entries %}
 | 
			
		||||
        <a class="accounting-report-table-row" href="{{ entry.url|accounting_append_next }}">
 | 
			
		||||
          {% include "accounting/report/include/ledger-row-desktop.html" %}
 | 
			
		||||
        </a>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
    </div>
 | 
			
		||||
    {% if report.total %}
 | 
			
		||||
      {% with entry = report.total %}
 | 
			
		||||
        <div class="accounting-report-table-footer">
 | 
			
		||||
          <div class="accounting-report-table-row">
 | 
			
		||||
            <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>
 | 
			
		||||
            <div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      {% endwith %}
 | 
			
		||||
    {% endif %}
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div class="list-group d-md-none">
 | 
			
		||||
  {% 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-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="{{ 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-row-mobile.html" %}
 | 
			
		||||
      </div>
 | 
			
		||||
    {% endwith %}
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  </div>
 | 
			
		||||
{% else %}
 | 
			
		||||
  <p>{{ A_("There is no data.") }}</p>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										103
									
								
								src/accounting/templates/accounting/report/search.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								src/accounting/templates/accounting/report/search.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,103 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
search.html: The search result
 | 
			
		||||
 | 
			
		||||
 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
 | 
			
		||||
#}
 | 
			
		||||
{% extends "accounting/base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block accounting_scripts %}
 | 
			
		||||
  <script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block header %}{% block title %}{{ A_("Search Result for \"%(query)s\"", query=request.args.q) }}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="mb-3 accounting-toolbar">
 | 
			
		||||
  {% with use_search = true %}
 | 
			
		||||
    {% include "accounting/report/include/toolbar-buttons.html" %}
 | 
			
		||||
  {% endwith %}
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
{% include "accounting/report/include/add-txn-material-fab.html" %}
 | 
			
		||||
 | 
			
		||||
{% include "accounting/report/include/search-modal.html" %}
 | 
			
		||||
 | 
			
		||||
{% if report.has_data %}
 | 
			
		||||
  {% with pagination = report.pagination %}
 | 
			
		||||
    {% include "accounting/include/pagination.html" %}
 | 
			
		||||
  {% endwith %}
 | 
			
		||||
 | 
			
		||||
  <div class="d-none d-md-block accounting-report-table accounting-journal-table">
 | 
			
		||||
    <div class="accounting-report-table-header">
 | 
			
		||||
      <div class="accounting-report-table-row">
 | 
			
		||||
        <div>{{ A_("Date") }}</div>
 | 
			
		||||
        <div>{{ A_("Currency") }}</div>
 | 
			
		||||
        <div>{{ A_("Account") }}</div>
 | 
			
		||||
        <div>{{ A_("Summary") }}</div>
 | 
			
		||||
        <div class="accounting-amount">{{ A_("Debit") }}</div>
 | 
			
		||||
        <div class="accounting-amount">{{ A_("Credit") }}</div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="accounting-report-table-body">
 | 
			
		||||
      {% 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.transaction.date|accounting_format_date }}</div>
 | 
			
		||||
          <div>{{ entry.currency.name }}</div>
 | 
			
		||||
          <div>
 | 
			
		||||
            <span class="d-none d-md-inline">{{ entry.account.code }}</span>
 | 
			
		||||
            {{ entry.account.title|title }}
 | 
			
		||||
          </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>
 | 
			
		||||
        </a>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div class="list-group d-md-none">
 | 
			
		||||
  {% for entry in report.entries %}
 | 
			
		||||
    <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
 | 
			
		||||
      <div class="d-flex justify-content-between">
 | 
			
		||||
        <div {% if not entry.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
 | 
			
		||||
          <div class="text-muted small">
 | 
			
		||||
            {{ entry.transaction.date|accounting_format_date }}
 | 
			
		||||
            {{ entry.account.title|title }}
 | 
			
		||||
            {% if entry.currency.code != accounting_default_currency_code() %}
 | 
			
		||||
              <span class="badge rounded-pill bg-info">{{ entry.currency.code }}</span>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
          </div>
 | 
			
		||||
          {% if entry.summary is not none %}
 | 
			
		||||
            <div>{{ entry.summary }}</div>
 | 
			
		||||
          {% endif %}
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div>
 | 
			
		||||
          <span class="badge rounded-pill bg-info">{{ entry.amount|accounting_format_amount }}</span>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </a>
 | 
			
		||||
  {% endfor %}
 | 
			
		||||
  </div>
 | 
			
		||||
{% else %}
 | 
			
		||||
  <p>{{ A_("There is no data.") }}</p>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -0,0 +1,85 @@
 | 
			
		||||
{#
 | 
			
		||||
The Mia! Accounting Flask Project
 | 
			
		||||
trial-balance.html: The trial balance
 | 
			
		||||
 | 
			
		||||
 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/5
 | 
			
		||||
#}
 | 
			
		||||
{% extends "accounting/base.html" %}
 | 
			
		||||
 | 
			
		||||
{% 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_("Trial Balance of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="mb-3 accounting-toolbar">
 | 
			
		||||
  {% with use_currency_chooser = true,
 | 
			
		||||
          use_period_chooser = true %}
 | 
			
		||||
    {% include "accounting/report/include/toolbar-buttons.html" %}
 | 
			
		||||
  {% endwith %}
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
{% include "accounting/report/include/add-txn-material-fab.html" %}
 | 
			
		||||
 | 
			
		||||
{% include "accounting/report/include/period-chooser.html" %}
 | 
			
		||||
 | 
			
		||||
{% 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">{{ 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">
 | 
			
		||||
      <div class="accounting-report-table-header">
 | 
			
		||||
        <div class="accounting-report-table-row">
 | 
			
		||||
          <div>{{ A_("Account") }}</div>
 | 
			
		||||
          <div class="accounting-amount">{{ A_("Debit") }}</div>
 | 
			
		||||
          <div class="accounting-amount">{{ A_("Credit") }}</div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="accounting-report-table-body">
 | 
			
		||||
        {% for account in report.accounts %}
 | 
			
		||||
          <a class="accounting-report-table-row" href="{{ account.url }}">
 | 
			
		||||
            <div>
 | 
			
		||||
              <span class="d-none d-md-inline">{{ account.account.code }}</span>
 | 
			
		||||
              {{ account.account.title|title }}
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="accounting-amount">{{ account.debit|accounting_format_amount|accounting_default }}</div>
 | 
			
		||||
            <div class="accounting-amount">{{ account.credit|accounting_format_amount|accounting_default }}</div>
 | 
			
		||||
          </a>
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="accounting-report-table-footer">
 | 
			
		||||
        <div class="accounting-report-table-row">
 | 
			
		||||
          <div>{{ A_("Total") }}</div>
 | 
			
		||||
          <div class="accounting-amount">{{ report.total.debit|accounting_format_amount }}</div>
 | 
			
		||||
          <div class="accounting-amount">{{ report.total.credit|accounting_format_amount }}</div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
{% else %}
 | 
			
		||||
  <p>{{ A_("There is no data.") }}</p>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -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 %}
 | 
			
		||||
 
 | 
			
		||||
@@ -44,14 +44,14 @@ First written: 2023/2/26
 | 
			
		||||
                  <div>{{ entry.summary }}</div>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
              </div>
 | 
			
		||||
              <div>{{ entry.amount|accounting_txn_format_amount }}</div>
 | 
			
		||||
              <div>{{ entry.amount|accounting_format_amount }}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </li>
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
        <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
 | 
			
		||||
          <div class="d-flex justify-content-between">
 | 
			
		||||
            <div>{{ A_("Total") }}</div>
 | 
			
		||||
            <div>{{ currency.debit_total|accounting_txn_format_amount }}</div>
 | 
			
		||||
            <div>{{ currency.debit_total|accounting_format_amount }}</div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </li>
 | 
			
		||||
      </ul>
 | 
			
		||||
 
 | 
			
		||||
@@ -19,13 +19,13 @@ 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 }}">
 | 
			
		||||
  <div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
 | 
			
		||||
    <div class="d-flex justify-content-between mt-2 mb-3">
 | 
			
		||||
      <div class="form-floating accounting-currency-content">
 | 
			
		||||
        <select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code">
 | 
			
		||||
          {% for currency in accounting_txn_currency_options() %}
 | 
			
		||||
        <select id="accounting-currency-{{ currency_index }}-code" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code">
 | 
			
		||||
          {% for currency in accounting_currency_options() %}
 | 
			
		||||
            <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
        </select>
 | 
			
		||||
@@ -34,7 +34,7 @@ First written: 2023/2/25
 | 
			
		||||
      </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>
 | 
			
		||||
@@ -50,14 +50,14 @@ First written: 2023/2/25
 | 
			
		||||
                    entry_index = loop.index,
 | 
			
		||||
                    entry_id = entry_form.eid.data,
 | 
			
		||||
                    only_one_entry_form = debit_forms|length == 1,
 | 
			
		||||
                    account_code_data = "" if entry_form.account_code.data is none else entry_form.account_code.data,
 | 
			
		||||
                    account_code_data = entry_form.account_code.data|accounting_default,
 | 
			
		||||
                    account_code_error = entry_form.account_code.errors,
 | 
			
		||||
                    account_text = entry_form.account_text,
 | 
			
		||||
                    summary_data = "" if entry_form.summary.data is none else entry_form.summary.data,
 | 
			
		||||
                    summary_data = entry_form.summary.data|accounting_default,
 | 
			
		||||
                    summary_errors = entry_form.summary.errors,
 | 
			
		||||
                    amount_data = "" if entry_form.amount.data is none else entry_form.amount.data|accounting_txn_format_amount_input,
 | 
			
		||||
                    amount_data = entry_form.amount.data|accounting_txn_format_amount_input,
 | 
			
		||||
                    amount_errors = entry_form.amount.errors,
 | 
			
		||||
                    amount_text = entry_form.amount.data|accounting_txn_format_amount,
 | 
			
		||||
                    amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"),
 | 
			
		||||
                    entry_errors = entry_form.all_errors %}
 | 
			
		||||
              {% include "accounting/transaction/include/form-entry-item.html" %}
 | 
			
		||||
            {% endwith %}
 | 
			
		||||
@@ -70,7 +70,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>
 | 
			
		||||
 
 | 
			
		||||
@@ -31,14 +31,14 @@ First written: 2023/2/25
 | 
			
		||||
              currency_code_errors = currency_form.code.errors,
 | 
			
		||||
              debit_forms = currency_form.debit,
 | 
			
		||||
              debit_errors = currency_form.debit_errors,
 | 
			
		||||
              debit_total = currency_form.form.debit_total|accounting_txn_format_amount %}
 | 
			
		||||
              debit_total = currency_form.form.debit_total|accounting_format_amount %}
 | 
			
		||||
        {% include "accounting/transaction/expense/include/form-currency-item.html" %}
 | 
			
		||||
      {% endwith %}
 | 
			
		||||
    {% endfor %}
 | 
			
		||||
  {% else %}
 | 
			
		||||
    {% with currency_index = 1,
 | 
			
		||||
            only_one_currency_form = True,
 | 
			
		||||
            currency_code_data = accounting_txn_default_currency_code(),
 | 
			
		||||
            currency_code_data = accounting_default_currency_code(),
 | 
			
		||||
            debit_total = "-" %}
 | 
			
		||||
      {% include "accounting/transaction/expense/include/form-currency-item.html" %}
 | 
			
		||||
    {% endwith %}
 | 
			
		||||
@@ -46,8 +46,8 @@ First written: 2023/2/25
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block form_modals %}
 | 
			
		||||
  {% with summary_helper = form.summary_helper.debit %}
 | 
			
		||||
    {% include "accounting/transaction/include/summary-helper-modal.html" %}
 | 
			
		||||
  {% with summary_editor = form.summary_editor.debit %}
 | 
			
		||||
    {% include "accounting/transaction/include/summary-editor-modal.html" %}
 | 
			
		||||
  {% endwith %}
 | 
			
		||||
  {% with entry_type = "debit",
 | 
			
		||||
          account_options = form.debit_account_options %}
 | 
			
		||||
 
 | 
			
		||||
@@ -24,7 +24,7 @@ First written: 2023/2/25
 | 
			
		||||
    <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,17 +37,17 @@ 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">
 | 
			
		||||
            {{ account }}
 | 
			
		||||
          </li>
 | 
			
		||||
            <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-editor-modal">
 | 
			
		||||
              {{ account }}
 | 
			
		||||
            </li>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
          <li id="accounting-account-selector-{{ entry_type }}-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
 | 
			
		||||
        </ul>
 | 
			
		||||
        <p id="accounting-account-selector-{{ entry_type }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="modal-footer">
 | 
			
		||||
        <button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button>
 | 
			
		||||
        <button id="accounting-account-selector-{{ entry_type }}-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button>
 | 
			
		||||
        <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>
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -60,8 +60,8 @@ First written: 2023/2/26
 | 
			
		||||
{% if accounting_can_edit() %}
 | 
			
		||||
  <form action="{{ url_for("accounting.transaction.delete", txn=obj) }}" method="post">
 | 
			
		||||
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
			
		||||
    {% if "next" in request.args %}
 | 
			
		||||
      <input type="hidden" name="next" value="{{ request.args["next"] }}">
 | 
			
		||||
    {% if request.args.next %}
 | 
			
		||||
      <input type="hidden" name="next" value="{{ request.args.next }}">
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    <div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
 | 
			
		||||
      <div class="modal-dialog">
 | 
			
		||||
@@ -83,13 +83,13 @@ First written: 2023/2/26
 | 
			
		||||
  </form>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
<div class="accounting-transaction-card">
 | 
			
		||||
<div class="accounting-sheet">
 | 
			
		||||
  <div class="d-none d-sm-flex justify-content-center mb-3">
 | 
			
		||||
    <h2 class="text-center">{{ obj }}</h2>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div class="mb-3">
 | 
			
		||||
    {{ obj.date|accounting_txn_format_date }}
 | 
			
		||||
    {{ obj.date|accounting_format_date }}
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  {% block transaction_currencies %}{% endblock %}
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user