Compare commits

...

38 Commits

Author SHA1 Message Date
f3548a2327 Advanced to version 0.4.0. 2023-03-01 01:49:08 +08:00
79883d6940 Changed the Sphinx documentation scheme from "nature" to "sphinx_rtd_theme", to prepare for publishing in the future. 2023-03-01 01:48:56 +08:00
b2bc993416 Replaced the #populate method with the #parseAndPopulate method that is used both when starting the summary helper and when the summary input is updated. 2023-03-01 01:45:38 +08:00
453b3f0da5 Renamed the #tagInputOnChange method to #onTagInputChange in the JavaScript summary helper. 2023-03-01 01:31:25 +08:00
63ae3f0746 Replace the is_in_use pseudo property of the Account data model with the AccountOption class, and revised the #getAccountCodeUsedInForm method of the SummaryHelper, to solve the issue that the list of used accounts should be different for debit and credit entries. 2023-03-01 01:28:25 +08:00
da4cc6489f Removed the direction arrows from the tab navigation in the summary helper. 2023-03-01 00:59:40 +08:00
1102a3a4f3 Updated the translation. 2023-03-01 00:51:58 +08:00
1402a12f04 Simplified the logic in the add_txn function in testlib_txn.py. 2023-03-01 00:51:24 +08:00
f049b5d7ee Revised the form data used in the SummeryHelperTestCase test case, to avoid problems with SonarQube. 2023-03-01 00:51:24 +08:00
14ed4ca354 Added the #initializeTagButtons and #tagInputOnChange methods to the JavaScript SummaryHelper to simplify the code. 2023-03-01 00:51:24 +08:00
535ff96ab3 Revised the JavaScript regular expressions used in the summary helper, as suggested by SonarQube for security. 2023-03-01 00:51:24 +08:00
57482f81fc Revised the transaction form to start a new journal entry with the journal entry form instead of the summary helper, because it feels strange when the user want to leave the summary empty. 2023-03-01 00:51:24 +08:00
a31ce3c400 Replaced the function-based JavaScript account selector with the AccountSelector class that does things better. 2023-03-01 00:51:11 +08:00
319f0aed90 Fixed a documentation in the JavaScript summary helper. 2023-02-28 22:54:20 +08:00
826dcf0f86 Revised the documentation of the JavaScript for the summary helper. 2023-02-28 22:47:04 +08:00
b2411aee74 Updated the Sphinx documentation. 2023-02-28 22:44:40 +08:00
731acdced0 Revised the HTML in the summary helper template. 2023-02-28 22:41:56 +08:00
35b3bca1e6 Renamed the variables for the button elements in the summary helper, to be clear. 2023-02-28 22:37:46 +08:00
3c413497ae Split the JavaScript for the account selector from transaction-form.js to account-selector.js, to modularize the complex JavaScript. 2023-02-28 22:33:14 +08:00
1b5e516413 Renamed the HTML ID and class name prefix of the account selector modal, for consistency. 2023-02-28 22:24:12 +08:00
20cb5cecc4 Renamed the accounting-selector-modal class to accounting-account-selector-modal in the account selector. 2023-02-28 22:14:03 +08:00
08dc24605d Replaced the forEach loops with the for-of loops in the JavaScript for the currency form, account form, and the drag-and-drop reorder library functions. 2023-02-28 22:09:39 +08:00
bb7e9e94ee Replaced the forEach loops with the for-of loops whenever appropriate in the JavaScript for the transaction form. 2023-02-28 22:00:19 +08:00
2680a1c872 Merged debit-account-modal.html and credit-account-modal.html into account-selector-modal.html, because they are almost the same. 2023-02-28 21:45:10 +08:00
20a7ce591c Renamed the account_selector_modals block to form_modals in the transaction form templates. 2023-02-28 21:37:08 +08:00
474e844ed9 Revised the loading of the summary helper so that only the required helpers are loaded, but not both the debit and credit helpers. 2023-02-28 21:35:02 +08:00
b34955f2fb Replaced the forEach loops with the for-of loops in the JavaScript summary helper. The for-of loops are more consistent with the other languages and the traditional for loops, and do not mess up with the "this" object. 2023-02-28 20:20:36 +08:00
2bd0f0f14d Fixed the target in the initShow method of the JavaScript summary helper. 2023-02-28 19:13:08 +08:00
8b77d9ff93 Added the suggested accounts to the summary helper. 2023-02-28 19:11:09 +08:00
a9c7360020 Renamed the variables in the #reset method of the JavaScript SummaryHelper class, for consistency. 2023-02-28 17:14:02 +08:00
d02c87602b Added validation to the summary helper. 2023-02-28 16:38:50 +08:00
9f966643b5 Added ARIA labels to the different pages in the summary helper. 2023-02-28 16:38:19 +08:00
5746e2a3d6 Added a missing amount filter to the debit entries of the transaction form. 2023-02-28 15:52:30 +08:00
d5c2231794 Added the summary helper for the transaction form. 2023-02-28 15:49:01 +08:00
fc8e257a10 Added missing documentation to the currencies_errors pseudo property of the TransactionForm form. 2023-02-28 09:36:20 +08:00
2e9bf382fb Revised the documentation of the "accounting.transaction.dispatcher" module. 2023-02-28 09:31:46 +08:00
de48c848da Revised the code in the common account shorts in testlib_txn.py. 2023-02-28 08:24:15 +08:00
9cdcc828a7 Added the add_txn function to testlib_txn.py and applied it in the transaction test cases. 2023-02-28 08:14:23 +08:00
30 changed files with 2233 additions and 530 deletions

View File

@ -36,6 +36,14 @@ accounting.transaction.query module
:undoc-members:
:show-inheritance:
accounting.transaction.summary\_helper module
---------------------------------------------
.. automodule:: accounting.transaction.summary_helper
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.template module
--------------------------------------

View File

@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath('../../src/'))
project = 'Mia! Accounting Flask'
copyright = '2023, imacat'
author = 'imacat'
release = '0.0.0'
release = '0.4.0'
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
@ -28,5 +28,5 @@ exclude_patterns = []
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'nature'
html_theme = 'sphinx_rtd_theme'
html_static_path = ['_static']

View File

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

View File

@ -203,25 +203,6 @@ class Account(db.Model):
return
self.l10n.append(AccountL10n(locale=current_locale, title=value))
@property
def is_in_use(self) -> bool:
"""Returns whether the account is in use.
:return: True if the account is in use, or False otherwise.
"""
if not hasattr(self, "__is_in_use"):
setattr(self, "__is_in_use", len(self.entries) > 0)
return getattr(self, "__is_in_use")
@is_in_use.setter
def is_in_use(self, is_in_use: bool) -> None:
"""Sets whether the account is in use.
:param is_in_use: True if the account is in use, or False otherwise.
:return: None.
"""
setattr(self, "__is_in_use", is_in_use)
@classmethod
def find_by_code(cls, code: str) -> t.Self | None:
"""Finds an account by its code.

View File

@ -46,9 +46,9 @@ function initializeBaseAccountSelector() {
const btnClear = document.getElementById("accounting-btn-clear-base");
selector.addEventListener("show.bs.modal", function () {
base.classList.add("accounting-not-empty");
options.forEach(function (item) {
item.classList.remove("active");
});
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");
@ -59,7 +59,7 @@ function initializeBaseAccountSelector() {
base.classList.remove("accounting-not-empty");
}
});
options.forEach(function (option) {
for (const option of options) {
option.onclick = function () {
baseCode.value = option.dataset.code;
baseContent.innerText = option.dataset.content;
@ -69,7 +69,7 @@ function initializeBaseAccountSelector() {
validateBase();
bootstrap.Modal.getInstance(selector).hide();
};
});
}
btnClear.onclick = function () {
baseCode.value = "";
baseContent.innerText = "";
@ -94,15 +94,15 @@ function initializeBaseAccountQuery() {
const queryNoResult = document.getElementById("accounting-base-option-no-result");
query.addEventListener("input", function () {
if (query.value === "") {
options.forEach(function (option) {
for (const option of options) {
option.classList.remove("d-none");
});
}
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
return
}
let hasAnyMatched = false;
options.forEach(function (option) {
for (const option of options) {
const queryValues = JSON.parse(option.dataset.queryValues);
let isMatched = false;
for (const queryValue of queryValues) {
@ -117,7 +117,7 @@ function initializeBaseAccountQuery() {
} else {
option.classList.add("d-none");
}
});
}
if (!hasAnyMatched) {
optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none");

View File

@ -0,0 +1,254 @@
/* The Mia! Accounting Flask Project
* transaction-transfer-form.js: The JavaScript for the transfer transaction form
*/
/* Copyright (c) 2023 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/28
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
AccountSelector.initialize();
});
/**
* The account selector.
*
*/
class AccountSelector {
/**
* The entry type
* @type {string}
*/
#entryType;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix;
/**
* Constructs an account selector.
*
* @param modal {HTMLFormElement} 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.#initializeAccountQuery();
btnClear.onclick = function () {
formAccountControl.classList.remove("accounting-not-empty");
formAccount.innerText = "";
formAccount.dataset.code = "";
formAccount.dataset.text = "";
validateJournalEntryAccount();
};
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();
};
}
}
/**
* Initializes the query on the account options.
*
*/
#initializeAccountQuery() {
const query = document.getElementById(this.#prefix + "-query");
const helper = this;
query.addEventListener("input", function () {
helper.#filterAccountOptions();
});
}
/**
* Filters the account 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();
let shouldAnyShow = false;
for (const option of options) {
const shouldShow = this.#shouldAccountOptionShow(option, more, codesInUse, query);
if (shouldShow) {
option.classList.remove("d-none");
shouldAnyShow = true;
} else {
option.classList.add("d-none");
}
}
if (!shouldAnyShow && more.classList.contains("d-none")) {
optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none");
} else {
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
}
}
/**
* Returns the account codes that are used in the form.
*
* @return {string[]} the account codes that are used in the form
*/
#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);
}
return inUse
}
/**
* Returns whether an account option should show.
*
* @param option {HTMLLIElement} the account option
* @param more {HTMLLIElement} the more account 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
*/
#shouldAccountOptionShow(option, more, inUse, query) {
const isQueryMatched = function () {
if (query.value === "") {
return true;
}
const queryValues = JSON.parse(option.dataset.queryValues);
for (const queryValue of queryValues) {
if (queryValue.includes(query.value)) {
return true;
}
}
return false;
};
const isMoreMatched = function () {
if (more.classList.contains("d-none")) {
return true;
}
return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code);
};
return isMoreMatched() && isQueryMatched();
}
/**
* Initializes the account selector when it is shown.
*
*/
initShow() {
const formAccount = document.getElementById("accounting-entry-form-account");
const query = document.getElementById(this.#prefix + "-query")
const more = document.getElementById(this.#prefix + "-more");
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
const btnClear = document.getElementById(this.#prefix + "-btn-clear");
query.value = "";
more.classList.remove("d-none");
this.#filterAccountOptions();
for (const option of options) {
if (option.dataset.code === formAccount.dataset.code) {
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;
} else {
btnClear.classList.add("btn-danger");
btnClear.classList.remove("btn-secondary");
btnClear.disabled = false;
}
}
/**
* The account selectors.
* @type {{debit: AccountSelector, credit: AccountSelector}}
*/
static #selectors = {}
/**
* Initializes the account selectors.
*
*/
static initialize() {
const modals = Array.from(document.getElementsByClassName("accounting-account-selector-modal"));
for (const modal of modals) {
const selector = new AccountSelector(modal);
this.#selectors[selector.#entryType] = selector;
}
this.#initializeTransactionForm();
}
/**
* Initializes the transaction form.
*
*/
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";
}
}

View File

@ -65,9 +65,9 @@ function validateForm() {
*/
function submitFormIfAllAsyncValid() {
let isValid = true;
Object.keys(isAsyncValid).forEach(function (key) {
for (const key of Object.keys(isAsyncValid)) {
isValid = isAsyncValid[key] && isValid;
});
}
if (isValid) {
document.getElementById("accounting-form").submit()
}

View File

@ -42,7 +42,7 @@ function initializeDragAndDropReordering(list, onReorder) {
function initializeMouseDragAndDropReordering(list, onReorder) {
const items = Array.from(list.children);
let dragged = null;
items.forEach(function (item) {
for (const item of items) {
item.draggable = true;
item.addEventListener("dragstart", function () {
dragged = item;
@ -56,7 +56,7 @@ function initializeMouseDragAndDropReordering(list, onReorder) {
dragged.classList.remove("accounting-dragged");
dragged = null;
});
});
}
}
/**
@ -68,7 +68,7 @@ function initializeMouseDragAndDropReordering(list, onReorder) {
*/
function initializeTouchDragAndDropReordering(list, onReorder) {
const items = Array.from(list.children);
items.forEach(function (item) {
for (const item of items) {
item.addEventListener("touchstart", function () {
item.classList.add("accounting-dragged");
});
@ -81,7 +81,7 @@ function initializeTouchDragAndDropReordering(list, onReorder) {
item.addEventListener("touchend", function () {
item.classList.remove("accounting-dragged");
});
});
}
}
/**

View File

@ -0,0 +1,827 @@
/* 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);
}
}

View File

@ -25,7 +25,6 @@
document.addEventListener("DOMContentLoaded", function () {
initializeCurrencyForms();
initializeJournalEntries();
initializeAccountSelectors();
initializeFormValidation();
});
@ -79,12 +78,12 @@ function initializeCurrencyForms() {
btnNew.onclick = function () {
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
let maxIndex = 0;
currencies.forEach(function (currency) {
for (const currency of currencies) {
const index = parseInt(currency.dataset.index);
if (maxIndex < index) {
maxIndex = index;
}
});
}
const newIndex = String(maxIndex + 1);
const html = form.dataset.currencyTemplate
.replaceAll("CURRENCY_INDEX", escapeHtml(newIndex));
@ -122,9 +121,9 @@ function initializeBtnDeleteCurrency(button) {
function resetDeleteCurrencyButtons() {
const buttons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency"));
if (buttons.length > 1) {
buttons.forEach(function (button) {
for (const button of buttons) {
button.classList.remove("d-none");
});
}
} else {
buttons[0].classList.add("d-none");
}
@ -157,6 +156,7 @@ function initializeNewEntryButton(button) {
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account");
const formAccountError = document.getElementById("accounting-entry-form-account-error")
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
const formSummary = document.getElementById("accounting-entry-form-summary");
const formSummaryError = document.getElementById("accounting-entry-form-summary-error");
const formAmount = document.getElementById("accounting-entry-form-amount");
@ -165,19 +165,23 @@ function initializeNewEntryButton(button) {
entryForm.dataset.currencyIndex = button.dataset.currencyIndex;
entryForm.dataset.entryType = button.dataset.entryType;
entryForm.dataset.entryIndex = button.dataset.entryIndex;
formAccountControl.classList.remove("accounting-not-empty")
formAccountControl.classList.remove("accounting-not-empty");
formAccountControl.classList.remove("is-invalid");
formAccountControl.dataset.bsTarget = button.dataset.accountModal;
formAccount.innerText = "";
formAccount.dataset.code = "";
formAccount.dataset.text = "";
formAccountError.innerText = "";
formSummary.value = "";
formSummary.classList.remove("is-invalid");
formSummaryControl.dataset.bsTarget = "#accounting-summary-helper-" + button.dataset.entryType + "-modal";
formSummaryControl.classList.remove("accounting-not-empty");
formSummaryControl.classList.remove("is-invalid");
formSummary.dataset.value = "";
formSummary.innerText = ""
formSummaryError.innerText = ""
formAmount.value = "";
formAmount.classList.remove("is-invalid");
formAmountError.innerText = "";
AccountSelector.initializeJournalEntryForm();
SummaryHelper.initializeNewJournalEntry(button.dataset.entryType);
};
}
@ -209,6 +213,7 @@ function initializeJournalEntry(entry) {
const control = document.getElementById(entry.dataset.prefix + "-control");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account");
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
const formSummary = document.getElementById("accounting-entry-form-summary");
const formAmount = document.getElementById("accounting-entry-form-amount");
control.onclick = function () {
@ -220,12 +225,19 @@ function initializeJournalEntry(entry) {
} else {
formAccountControl.classList.add("accounting-not-empty");
}
formAccountControl.dataset.bsTarget = entry.dataset.accountModal;
formAccount.innerText = accountCode.dataset.text;
formAccount.dataset.code = accountCode.value;
formAccount.dataset.text = accountCode.dataset.text;
formSummary.value = summary.value;
formSummaryControl.dataset.bsTarget = "#accounting-summary-helper-" + entry.dataset.entryType + "-modal";
if (summary.value === "") {
formSummaryControl.classList.remove("accounting-not-empty");
} else {
formSummaryControl.classList.add("accounting-not-empty");
}
formSummary.dataset.value = summary.value;
formSummary.innerText = summary.value;
formAmount.value = amount.value;
AccountSelector.initializeJournalEntryForm();
validateJournalEntryForm();
};
}
@ -237,38 +249,8 @@ function initializeJournalEntry(entry) {
*/
function initializeJournalEntryFormModal() {
const entryForm = document.getElementById("accounting-entry-form");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account");
const formSummary = document.getElementById("accounting-entry-form-summary");
const formAmount = document.getElementById("accounting-entry-form-amount");
const modal = document.getElementById("accounting-entry-form-modal");
formAccountControl.onclick = function () {
const prefix = "accounting-" + entryForm.dataset.entryType + "-account";
const query = document.getElementById(prefix + "-selector-query")
const more = document.getElementById(prefix + "-more");
const options = Array.from(document.getElementsByClassName(prefix + "-option"));
const btnClear = document.getElementById(prefix + "-btn-clear");
query.value = "";
more.classList.remove("d-none");
filterAccountOptions(prefix);
options.forEach(function (option) {
if (option.dataset.code === formAccount.dataset.code) {
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;
} else {
btnClear.classList.add("btn-danger");
btnClear.classList.remove("btn-secondary");
btnClear.disabled = false;
}
};
formSummary.onchange = validateJournalEntrySummary;
formAmount.onchange = validateJournalEntryAmount;
entryForm.onsubmit = function () {
if (validateJournalEntryForm()) {
@ -297,7 +279,6 @@ function validateJournalEntryForm() {
* Validates the account in the journal entry form modal.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateJournalEntryAccount() {
const field = document.getElementById("accounting-entry-form-account");
@ -320,10 +301,9 @@ function validateJournalEntryAccount() {
* @private
*/
function validateJournalEntrySummary() {
const field = document.getElementById("accounting-entry-form-summary");
const control = document.getElementById("accounting-entry-form-summary-control");
const error = document.getElementById("accounting-entry-form-summary-error");
field.value = field.value.trim();
field.classList.remove("is-invalid");
control.classList.remove("is-invalid");
error.innerText = "";
return true;
}
@ -366,12 +346,12 @@ function saveJournalEntryForm() {
const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType));
const entryList = document.getElementById("accounting-currency-" + currencyIndex + "-" + entryType + "-list")
let maxIndex = 0;
entries.forEach(function (entry) {
for (const entry of entries) {
const index = parseInt(entry.dataset.entryIndex);
if (maxIndex < index) {
maxIndex = index;
}
});
}
entryIndex = String(maxIndex + 1);
const html = form.dataset.entryTemplate
.replaceAll("CURRENCY_INDEX", escapeHtml(currencyIndex))
@ -393,8 +373,8 @@ function saveJournalEntryForm() {
accountCode.value = formAccount.dataset.code;
accountCode.dataset.text = formAccount.dataset.text;
accountText.innerText = formAccount.dataset.text;
summary.value = formSummary.value;
summaryText.innerText = formSummary.value;
summary.value = formSummary.dataset.value;
summaryText.innerText = formSummary.dataset.value;
amount.value = formAmount.value;
amountText.innerText = formatDecimal(new Decimal(formAmount.value));
if (entryForm.dataset.entryIndex === "new") {
@ -436,9 +416,9 @@ function initializeDeleteJournalEntryButton(button) {
function resetDeleteJournalEntryButtons(sameClass) {
const buttons = Array.from(document.getElementsByClassName(sameClass));
if (buttons.length > 1) {
buttons.forEach(function (button) {
for (const button of buttons) {
button.classList.remove("d-none");
});
}
} else {
buttons[0].classList.add("d-none");
}
@ -456,147 +436,14 @@ function updateBalance(currencyIndex, entryType) {
const amounts = Array.from(document.getElementsByClassName(prefix + "-amount"));
const totalText = document.getElementById(prefix + "-total");
let total = new Decimal("0");
amounts.forEach(function (amount) {
for (const amount of amounts) {
if (amount.value !== "") {
total = total.plus(new Decimal(amount.value));
}
});
}
totalText.innerText = formatDecimal(total);
}
/**
* Initializes the account selectors.
*
* @private
*/
function initializeAccountSelectors() {
const selectors = Array.from(document.getElementsByClassName("accounting-selector-modal"));
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account");
selectors.forEach(function (selector) {
const more = document.getElementById(selector.dataset.prefix + "-more");
const btnClear = document.getElementById(selector.dataset.prefix + "-btn-clear");
const options = Array.from(document.getElementsByClassName(selector.dataset.prefix + "-option"));
more.onclick = function () {
more.classList.add("d-none");
filterAccountOptions(selector.dataset.prefix);
};
initializeAccountQuery(selector);
btnClear.onclick = function () {
formAccountControl.classList.remove("accounting-not-empty");
formAccount.innerText = "";
formAccount.dataset.code = "";
formAccount.dataset.text = "";
validateJournalEntryAccount();
};
options.forEach(function (option) {
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();
};
});
});
}
/**
* Initializes the query on the account options.
*
* @param selector {HTMLDivElement} the selector modal
* @private
*/
function initializeAccountQuery(selector) {
const query = document.getElementById(selector.dataset.prefix + "-selector-query");
query.addEventListener("input", function () {
filterAccountOptions(selector.dataset.prefix);
});
}
/**
* Filters the account options.
*
* @param prefix {string} the HTML ID and class prefix
* @private
*/
function filterAccountOptions(prefix) {
const query = document.getElementById(prefix + "-selector-query");
const optionList = document.getElementById(prefix + "-option-list");
if (optionList === null) {
console.log(prefix + "-option-list");
}
const options = Array.from(document.getElementsByClassName(prefix + "-option"));
const more = document.getElementById(prefix + "-more");
const queryNoResult = document.getElementById(prefix + "-option-no-result");
const codesInUse = getAccountCodeUsedInForm();
let shouldAnyShow = false;
options.forEach(function (option) {
const shouldShow = shouldAccountOptionShow(option, more, codesInUse, query);
if (shouldShow) {
option.classList.remove("d-none");
shouldAnyShow = true;
} else {
option.classList.add("d-none");
}
});
if (!shouldAnyShow && more.classList.contains("d-none")) {
optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none");
} else {
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
}
}
/**
* Returns whether an account option should show.
*
* @param option {HTMLLIElement} the account option
* @param more {HTMLLIElement} the more account 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
* @private
*/
function shouldAccountOptionShow(option, more, inUse, query) {
const isQueryMatched = function () {
if (query.value === "") {
return true;
}
const queryValues = JSON.parse(option.dataset.queryValues);
for (const queryValue of queryValues) {
if (queryValue.includes(query.value)) {
return true;
}
}
return false;
};
const isMoreMatched = function () {
if (more.classList.contains("d-none")) {
return true;
}
return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code);
};
return isMoreMatched() && isQueryMatched();
}
/**
* Returns the account codes that are used in the form.
*
* @return {string[]} the account codes that are used in the form
* @private
*/
function getAccountCodeUsedInForm() {
const accountCodes = Array.from(document.getElementsByClassName("accounting-account-code"));
const formAccount = document.getElementById("accounting-entry-form-account");
const inUse = [formAccount.dataset.code];
accountCodes.forEach(function (accountCode) {
inUse.push(accountCode.value);
});
return inUse
}
/**
* Initializes the form validation.
*
@ -655,9 +502,9 @@ function validateCurrencies() {
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
let isValid = true;
isValid = validateCurrenciesReal() && isValid;
currencies.forEach(function (currency) {
for (const currency of currencies) {
isValid = validateCurrency(currency) && isValid;
});
}
return isValid;
}
@ -718,9 +565,9 @@ function validateJournalEntries(currency, entryType) {
const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType));
let isValid = true;
isValid = validateJournalEntriesReal(currencyIndex, entryType) && isValid;
entries.forEach(function (entry) {
for (const entry of entries) {
isValid = validateJournalEntry(entry) && isValid;
})
}
return isValid;
}
@ -791,17 +638,17 @@ function validateBalance(currency) {
const creditAmounts = Array.from(document.getElementsByClassName(prefix + "-credit-amount"));
if (debit !== null && credit !== null) {
let debitTotal = new Decimal("0");
debitAmounts.forEach(function (amount) {
for (const amount of debitAmounts) {
if (amount.value !== "") {
debitTotal = debitTotal.plus(new Decimal(amount.value));
}
});
}
let creditTotal = new Decimal("0");
creditAmounts.forEach(function (amount) {
for (const amount of creditAmounts) {
if (amount.value !== "") {
creditTotal = creditTotal.plus(new Decimal(amount.value));
}
});
}
if (!debitTotal.equals(creditTotal)) {
control.classList.add("is-invalid");
error.innerText = A_("The totals of the debit and credit amounts do not match.");

View File

@ -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-account-modal="#accounting-debit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<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">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>

View File

@ -45,6 +45,12 @@ First written: 2023/2/25
{% endif %}
{% endblock %}
{% block account_selector_modals %}
{% include "accounting/transaction/include/debit-account-modal.html" %}
{% block form_modals %}
{% with summary_helper = form.summary_helper.debit %}
{% include "accounting/transaction/include/summary-helper-modal.html" %}
{% endwith %}
{% with entry_type = "debit",
account_options = form.debit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% endwith %}
{% endblock %}

View File

@ -0,0 +1,54 @@
{#
The Mia! Accounting Flask Project
account-selector-modal.html: The modal for the account selector
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-account-selector-{{ entry_type }}-modal" class="modal fade accounting-account-selector-modal" data-entry-type="{{ entry_type }}" tabindex="-1" aria-labelledby="accounting-account-selector-{{ entry_type }}-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-account-selector-{{ entry_type }}-modal-label">{{ A_("Select Account") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-account-selector-{{ entry_type }}-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-account-selector-{{ entry_type }}-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<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>
{% 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>
</div>
</div>
</div>
</div>

View File

@ -1,54 +0,0 @@
{#
The Mia! Accounting Flask Project
credit-modals.html: The modals for the credit 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
#}
<div id="accounting-credit-account-selector-modal" class="modal fade accounting-selector-modal" data-prefix="accounting-credit-account" tabindex="-1" aria-labelledby="accounting-credit-account-selector-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-credit-account-selector-modal-label">{{ A_("Select Credit Account") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-credit-account-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-credit-account-selector-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-credit-account-option-list" class="list-group accounting-selector-list">
{% for account in form.credit_account_options %}
<li id="accounting-credit-account-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-credit-account-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>
{% endfor %}
<li id="accounting-credit-account-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
</ul>
<p id="accounting-credit-account-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-credit-account-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button>
</div>
</div>
</div>
</div>

View File

@ -1,54 +0,0 @@
{#
The Mia! Accounting Flask Project
credit-modals.html: The modals for the debit 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
#}
<div id="accounting-debit-account-selector-modal" class="modal fade accounting-selector-modal" data-prefix="accounting-debit-account" tabindex="-1" aria-labelledby="accounting-debit-account-selector-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-debit-account-selector-modal-label">{{ A_("Select Debit Account") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-debit-account-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-debit-account-selector-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-debit-account-option-list" class="list-group accounting-selector-list">
{% for account in form.debit_account_options %}
<li id="accounting-debit-account-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-debit-account-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>
{% endfor %}
<li id="accounting-debit-account-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
</ul>
<p id="accounting-debit-account-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-debit-account-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button>
</div>
</div>
</div>
</div>

View File

@ -36,9 +36,11 @@ First written: 2023/2/25
<div id="accounting-entry-form-account-error" class="invalid-feedback"></div>
</div>
<div class="form-floating mb-3">
<input id="accounting-entry-form-summary" class="form-control" type="text" value="" placeholder=" ">
<label for="accounting-entry-form-summary">{{ A_("Summary") }}</label>
<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>

View File

@ -20,12 +20,12 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{# <ul> For SonarQube not to complain about incorrect HTML #}
<li id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-entry accounting-currency-{{ currency_index }}-{{ entry_type }}" data-currency-index="{{ currency_index }}" data-entry-type="{{ entry_type }}" data-entry-index="{{ entry_index }}" data-account-modal="#accounting-{{ entry_type }}-account-selector-modal" data-prefix="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}">
<li id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-entry accounting-currency-{{ currency_index }}-{{ entry_type }}" data-currency-index="{{ currency_index }}" data-entry-type="{{ entry_type }}" data-entry-index="{{ entry_index }}" data-prefix="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}">
{% if entry_id %}
<input type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-eid" value="{{ entry_id }}">
{% endif %}
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" value="{{ entry_index }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-code" class="accounting-account-code" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account_code" value="{{ account_code_data }}" data-text="{{ account_text }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-code" class="accounting-{{ entry_type }}-account-code" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account_code" value="{{ account_code_data }}" data-text="{{ account_text }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" value="{{ summary_data }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" class="accounting-currency-{{ currency_index }}-{{ entry_type }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" value="{{ amount_data }}">
<div class="accounting-entry-content">

View File

@ -24,6 +24,8 @@ First written: 2023/2/26
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/transaction-form.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/summary-helper.js") }}"></script>
{% endblock %}
{% block content %}
@ -85,6 +87,6 @@ First written: 2023/2/26
</form>
{% include "accounting/transaction/include/entry-form-modal.html" %}
{% block account_selector_modals %}{% endblock %}
{% block form_modals %}{% endblock %}
{% endblock %}

View File

@ -0,0 +1,181 @@
{#
The Mia! Accounting Flask Project
entry-form-modal.html: The modal of 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
#}
<form id="accounting-summary-helper-{{ summary_helper.type }}" class="accounting-summary-helper" data-entry-type="{{ summary_helper.type }}" data-default-tab-id="general" data-selected-account-code="" data-selected-account-text="">
<div id="accounting-summary-helper-{{ summary_helper.type }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-summary-helper-{{ summary_helper.type }}-modal-label">
<label for="accounting-summary-helper-{{ summary_helper.type }}-summary">{{ A_("Summary") }}</label>
</h1>
<button class="btn-close accounting-summary-helper-{{ summary_helper.type }}-close" type="button" data-bs-toggle="modal" data-bs-target="" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<input id="accounting-summary-helper-{{ summary_helper.type }}-summary" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-modal-label">
</div>
<ul class="nav nav-tabs mb-2">
<li class="nav-item">
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-general" class="nav-link active accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="page" data-tab-id="general">
{{ A_("General") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-travel" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="travel">
{{ A_("Travel") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-bus" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="bus">
{{ A_("Bus") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-regular" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="regular">
{{ A_("Regular") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-number" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="number">
{{ A_("Number") }}
</span>
</li>
</ul>
{# A general summary with a tag #}
<div class="accounting-summary-helper-{{ summary_helper.type }}-page" aria-current="page" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-general" data-tab-id="general">
<div class="form-floating mb-2">
<input id="accounting-summary-helper-{{ summary_helper.type }}-general-tag" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-general-tag">{{ A_("Tag") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-general-tag-error" class="invalid-feedback"></div>
</div>
<div>
{% for tag in summary_helper.general.tags %}
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-btn-tag accounting-summary-helper-{{ summary_helper.type }}-general-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }}
</button>
{% endfor %}
</div>
</div>
{# A general trip with the origin and distination #}
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-travel" data-tab-id="travel">
<div class="form-floating mb-2">
<input id="accounting-summary-helper-{{ summary_helper.type }}-travel-tag" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-travel-tag">{{ A_("Tag") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-travel-tag-error" class="invalid-feedback"></div>
</div>
<div>
{% for tag in summary_helper.travel.tags %}
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-btn-tag accounting-summary-helper-{{ summary_helper.type }}-travel-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }}
</button>
{% endfor %}
</div>
<div class="d-flex justify-content-between mt-2">
<div class="form-floating">
<input id="accounting-summary-helper-{{ summary_helper.type }}-travel-from" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-travel-from">{{ A_("From") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-travel-from-error" class="invalid-feedback"></div>
</div>
<div class="btn-group-vertical ms-1 me-1">
<button class="btn btn-primary accounting-summary-helper-{{ summary_helper.type }}-travel-direction accounting-default" type="button" tabindex="-1" data-arrow="&rarr;">&rarr;</button>
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-travel-direction" type="button" tabindex="-1" data-arrow="&harr;">&harr;</button>
</div>
<div class="form-floating">
<input id="accounting-summary-helper-{{ summary_helper.type }}-travel-to" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-travel-to">{{ A_("To") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-travel-to-error" class="invalid-feedback"></div>
</div>
</div>
</div>
{# A bus trip with the route name or route number, the origin and distination #}
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-bus" data-tab-id="bus">
<div class="d-flex justify-content-between mb-2">
<div class="form-floating me-2">
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-tag" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-tag">{{ A_("Tag") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-tag-error" class="invalid-feedback"></div>
</div>
<div class="form-floating">
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-route" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-route">{{ A_("Route") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-route-error" class="invalid-feedback"></div>
</div>
</div>
<div>
{% for tag in summary_helper.bus.tags %}
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-btn-tag accounting-summary-helper-{{ summary_helper.type }}-bus-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }}
</button>
{% endfor %}
</div>
<div class="d-flex justify-content-between mt-2">
<div class="form-floating me-2">
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-from" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-from">{{ A_("From") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-from-error" class="invalid-feedback"></div>
</div>
<div class="form-floating">
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-to" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-to">{{ A_("To") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-to-error" class="invalid-feedback"></div>
</div>
</div>
</div>
{# A regular income/payment #}
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-regular" data-tab-id="regular">
{# TODO: To be done #}
</div>
{# The number of items #}
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-number" data-tab-id="number">
<div class="form-floating">
<input id="accounting-summary-helper-{{ summary_helper.type }}-number" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="number" min="1" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-number">{{ A_("The number of items") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-number-error" class="invalid-feedback"></div>
</div>
</div>
{# The suggested accounts #}
<div class="mt-3">
{% for account in summary_helper.accounts %}
<button class="btn btn-outline-primary d-none accounting-summary-helper-{{ summary_helper.type }}-account" type="button" data-code="{{ account.code }}" data-text="{{ account }}">
{{ account }}
</button>
{% endfor %}
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary accounting-summary-helper-{{ summary_helper.type }}-close" type="button" data-bs-toggle="modal" data-bs-target="">{{ A_("Cancel") }}</button>
<button id="accounting-summary-helper-{{ summary_helper.type }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
</div>
</div>
</div>
</div>
</form>

View File

@ -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="credit" data-entry-index="new" data-account-modal="#accounting-credit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<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="credit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>

View File

@ -45,6 +45,12 @@ First written: 2023/2/25
{% endif %}
{% endblock %}
{% block account_selector_modals %}
{% include "accounting/transaction/include/credit-account-modal.html" %}
{% block form_modals %}
{% with summary_helper = form.summary_helper.credit %}
{% include "accounting/transaction/include/summary-helper-modal.html" %}
{% endwith %}
{% with entry_type = "credit",
account_options = form.credit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% endwith %}
{% endblock %}

View File

@ -57,7 +57,7 @@ First written: 2023/2/25
account_text = entry_form.account_text,
summary_data = "" if entry_form.summary.data is none else entry_form.summary.data,
summary_errors = entry_form.summary.errors,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data,
amount_data = "" if entry_form.amount.data is none else 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,
entry_errors = entry_form.all_errors %}
@ -72,7 +72,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-account-modal="#accounting-debit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<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">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>
@ -112,7 +112,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="credit" data-entry-index="new" data-account-modal="#accounting-credit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<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="credit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>

View File

@ -49,7 +49,19 @@ First written: 2023/2/25
{% endif %}
{% endblock %}
{% block account_selector_modals %}
{% include "accounting/transaction/include/debit-account-modal.html" %}
{% include "accounting/transaction/include/credit-account-modal.html" %}
{% block form_modals %}
{% with summary_helper = form.summary_helper.debit %}
{% include "accounting/transaction/include/summary-helper-modal.html" %}
{% endwith %}
{% with summary_helper = form.summary_helper.credit %}
{% include "accounting/transaction/include/summary-helper-modal.html" %}
{% endwith %}
{% with entry_type = "debit",
account_options = form.debit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% endwith %}
{% with entry_type = "credit",
account_options = form.credit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% endwith %}
{% endblock %}

View File

@ -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 transaction type dispatcher.
"""The view dispatcher for different transaction types.
"""
import typing as t

View File

@ -37,6 +37,7 @@ from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Transaction, Account, JournalEntry, \
TransactionCurrency, Currency
from accounting.transaction.summary_helper import SummaryHelper
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text, strip_multiline_text
from accounting.utils.user import get_current_user_pk
@ -114,6 +115,35 @@ class IsDebitAccount:
"This account is not for debit entries."))
class AccountOption:
"""An account option."""
def __init__(self, account: Account):
"""Constructs an account option.
:param account: The account.
"""
self.__account: Account = account
self.id: str = account.id
self.code: str = account.code
self.is_in_use: bool = False
def __str__(self) -> str:
"""Returns the string representation of the account option.
:return: The string representation of the account option.
"""
return str(self.__account)
@property
def query_values(self) -> list[str]:
"""Returns the values to be queried.
:return: The values to be queried.
"""
return self.__account.query_values
class JournalEntryForm(FlaskForm):
"""The base form to create or edit a journal entry."""
eid = IntegerField()
@ -320,49 +350,54 @@ class TransactionForm(FlaskForm):
obj.no = count + 1
@property
def debit_account_options(self) -> list[Account]:
def debit_account_options(self) -> list[AccountOption]:
"""The selectable debit accounts.
:return: The selectable debit accounts.
"""
accounts: list[Account] = Account.debit()
in_use: set[int] = self.__get_in_use_account_id()
accounts: list[AccountOption] \
= [AccountOption(x) for x in Account.debit()]
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntry.account_id)
.filter(JournalEntry.is_debit)
.group_by(JournalEntry.account_id)).all())
for account in accounts:
account.is_in_use = account.id in in_use
return accounts
@property
def credit_account_options(self) -> list[Account]:
def credit_account_options(self) -> list[AccountOption]:
"""The selectable credit accounts.
:return: The selectable credit accounts.
"""
accounts: list[Account] = Account.credit()
in_use: set[int] = self.__get_in_use_account_id()
accounts: list[AccountOption] \
= [AccountOption(x) for x in Account.credit()]
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntry.account_id)
.filter(sa.not_(JournalEntry.is_debit))
.group_by(JournalEntry.account_id)).all())
for account in accounts:
account.is_in_use = account.id in in_use
return accounts
def __get_in_use_account_id(self) -> set[int]:
"""Returns the ID of the accounts that are in use.
:return: The ID of the accounts that are in use.
"""
if self.__in_use_account_id is None:
self.__in_use_account_id = set(db.session.scalars(
sa.select(JournalEntry.account_id)
.group_by(JournalEntry.account_id)).all())
return self.__in_use_account_id
@property
def currencies_errors(self) -> list[str | LazyString]:
"""Returns the currency errors, without the errors in their sub-forms.
:return:
:return: The currency errors, without the errors in their sub-forms.
"""
return [x for x in self.currencies.errors
if isinstance(x, str) or isinstance(x, LazyString)]
@property
def summary_helper(self) -> SummaryHelper:
"""Returns the summary helper.
:return: The summary helper.
"""
return SummaryHelper()
T = t.TypeVar("T", bound=TransactionForm)
"""A transaction form variant."""

View File

@ -0,0 +1,255 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
# 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 summary helper.
"""
import typing as t
import sqlalchemy as sa
from accounting import db
from accounting.models import Account, JournalEntry
class SummaryAccount:
"""An account for a summary tag."""
def __init__(self, account: Account, freq: int):
"""Constructs an account for a summary tag.
:param account: The account.
:param freq: The frequency of the tag with the account.
"""
self.account: Account = account
"""The account."""
self.id: int = account.id
"""The account ID."""
self.code: str = account.code
"""The account code."""
self.freq: int = freq
"""The frequency of the tag with the account."""
def __str__(self) -> str:
"""Returns the string representation of the account.
:return: The string representation of the account.
"""
return str(self.account)
def add_freq(self, freq: int) -> None:
"""Adds the frequency of an account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
self.freq = self.freq + freq
class SummaryTag:
"""A summary tag."""
def __init__(self, name: str):
"""Constructs a summary tag.
:param name: The tag name.
"""
self.name: str = name
"""The tag name."""
self.__account_dict: dict[int, SummaryAccount] = {}
"""The accounts that come with the tag, in the order of their
frequency."""
self.freq: int = 0
"""The frequency of the tag."""
def __str__(self) -> str:
"""Returns the string representation of the tag.
:return: The string representation of the tag.
"""
return self.name
def add_account(self, account: Account, freq: int):
"""Adds an account.
:param account: The associated account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
self.__account_dict[account.id] = SummaryAccount(account, freq)
self.freq = self.freq + freq
@property
def accounts(self) -> list[SummaryAccount]:
"""Returns the accounts by the order of their frequencies.
:return: The accounts by the order of their frequencies.
"""
return sorted(self.__account_dict.values(), key=lambda x: -x.freq)
@property
def account_codes(self) -> list[str]:
"""Returns the account codes by the order of their frequencies.
:return: The account codes by the order of their frequencies.
"""
return [x.code for x in self.accounts]
class SummaryType:
"""A summary type"""
def __init__(self, type_id: t.Literal["general", "travel", "bus"]):
"""Constructs a summary type.
:param type_id: The type ID, either "general", "travel", or "bus".
"""
self.id: t.Literal["general", "travel", "bus"] = type_id
"""The type ID."""
self.__tag_dict: dict[str, SummaryTag] = {}
"""A dictionary from the tag name to their corresponding tag."""
def add_tag(self, name: str, account: Account, freq: int) -> None:
"""Adds a tag.
:param name: The tag name.
:param account: The associated account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
if name not in self.__tag_dict:
self.__tag_dict[name] = SummaryTag(name)
self.__tag_dict[name].add_account(account, freq)
@property
def tags(self) -> list[SummaryTag]:
"""Returns the tags by the order of their frequencies.
:return: The tags by the order of their frequencies.
"""
return sorted(self.__tag_dict.values(), key=lambda x: -x.freq)
class SummaryEntryType:
"""A summary type"""
def __init__(self, entry_type_id: t.Literal["debit", "credit"]):
"""Constructs a summary entry type.
:param entry_type_id: The entry type ID, either "debit" or "credit".
"""
self.type: t.Literal["debit", "credit"] = entry_type_id
"""The entry type."""
self.general: SummaryType = SummaryType("general")
"""The general tags."""
self.travel: SummaryType = SummaryType("travel")
"""The travel tags."""
self.bus: SummaryType = SummaryType("bus")
"""The bus tags."""
self.__type_dict: dict[t.Literal["general", "travel", "bus"],
SummaryType] \
= {x.id: x for x in {self.general, self.travel, self.bus}}
"""A dictionary from the type ID to the corresponding tags."""
def add_tag(self, tag_type: t.Literal["general", "travel", "bus"],
name: str, account: Account, freq: int) -> None:
"""Adds a tag.
:param tag_type: The tag type, either "general", "travel", or "bus".
:param name: The name.
:param account: The associated account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
self.__type_dict[tag_type].add_tag(name, account, freq)
@property
def accounts(self) -> list[SummaryAccount]:
"""Returns the suggested accounts of all tags in the summary helper in
the entry type, in their frequency order.
:return: The suggested accounts of all tags, in their frequency order.
"""
accounts: dict[int, SummaryAccount] = {}
freq: dict[int, int] = {}
for tag_type in self.__type_dict.values():
for tag in tag_type.tags:
for account in tag.accounts:
accounts[account.id] = account
if account.id not in freq:
freq[account.id] = 0
freq[account.id] \
= freq[account.id] + account.freq
return [accounts[y] for y in sorted(freq.keys(),
key=lambda x: -freq[x])]
class SummaryHelper:
"""The summary helper."""
def __init__(self):
"""Constructs the summary helper."""
self.debit: SummaryEntryType = SummaryEntryType("debit")
"""The debit tags."""
self.credit: SummaryEntryType = SummaryEntryType("credit")
"""The credit tags."""
entry_type: sa.Label = sa.case((JournalEntry.is_debit, "debit"),
else_="credit").label("entry_type")
tag_type: sa.Label = sa.case(
(JournalEntry.summary.like("_%—_%—_%→_%"), "bus"),
(sa.or_(JournalEntry.summary.like("_%—_%→_%"),
JournalEntry.summary.like("_%—_%↔_%")), "travel"),
else_="general").label("tag_type")
tag: sa.Label = get_prefix(JournalEntry.summary, "").label("tag")
select: sa.Select = sa.Select(entry_type, tag_type, tag,
JournalEntry.account_id,
sa.func.count().label("freq"))\
.filter(JournalEntry.summary.is_not(None),
JournalEntry.summary.like("_%—_%"))\
.group_by(entry_type, tag_type, tag, JournalEntry.account_id)
result: list[sa.Row] = db.session.execute(select).all()
accounts: dict[int, Account] \
= {x.id: x for x in Account.query
.filter(Account.id.in_({x.account_id for x in result})).all()}
entry_type_dict: dict[t.Literal["debit", "credit"], SummaryEntryType] \
= {x.type: x for x in {self.debit, self.credit}}
for row in result:
entry_type_dict[row.entry_type].add_tag(
row.tag_type, row.tag, accounts[row.account_id], row.freq)
def get_prefix(string: str | sa.Column, separator: str | sa.Column) \
-> sa.Function:
"""Returns the SQL function to find the prefix of a string.
:param string: The string.
:param separator: The separator.
:return: The position of the substring, starting from 1.
"""
return sa.func.substr(string, 0, get_position(string, separator))
def get_position(string: str | sa.Column, substring: str | sa.Column) \
-> sa.Function:
"""Returns the SQL function to find the position of a substring.
:param string: The string.
:param substring: The substring.
:return: The position of the substring, starting from 1.
"""
if db.engine.name == "postgresql":
return sa.func.strpos(string, substring)
return sa.func.instr(string, substring)

View File

@ -8,8 +8,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-02-27 18:59+0800\n"
"PO-Revision-Date: 2023-02-27 18:59+0800\n"
"POT-Creation-Date: 2023-03-01 00:51+0800\n"
"PO-Revision-Date: 2023-03-01 00:51+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language: zh_Hant\n"
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
@ -126,33 +126,52 @@ msgstr "貨幣刪掉了"
msgid "Please fill in the title."
msgstr "請填上標題。"
#: src/accounting/static/js/transaction-form.js:308
#: src/accounting/static/js/transaction-form.js:764
#: src/accounting/transaction/forms.py:46
#: src/accounting/static/js/summary-helper.js:441
#: src/accounting/static/js/summary-helper.js:512
msgid "Please fill in the tag."
msgstr "請填上標籤。"
#: src/accounting/static/js/summary-helper.js:460
#: src/accounting/static/js/summary-helper.js:550
msgid "Please fill in the origin."
msgstr "請填上起點。"
#: src/accounting/static/js/summary-helper.js:479
#: src/accounting/static/js/summary-helper.js:569
msgid "Please fill in the destination."
msgstr "請填上終點。"
#: src/accounting/static/js/summary-helper.js:531
msgid "Please fill in the route."
msgstr "請填上路線名稱。"
#: src/accounting/static/js/transaction-form.js:289
#: src/accounting/static/js/transaction-form.js:611
#: src/accounting/transaction/forms.py:47
msgid "Please select the account."
msgstr "請選擇科目。"
#: src/accounting/static/js/transaction-form.js:344
#: src/accounting/static/js/transaction-form.js:769
#: src/accounting/static/js/transaction-form.js:324
#: src/accounting/static/js/transaction-form.js:616
msgid "Please fill in the amount."
msgstr "請填上金額。"
#: src/accounting/static/js/transaction-form.js:641
#: src/accounting/static/js/transaction-form.js:488
msgid "Please fill in the date."
msgstr "請填上日期。"
#: src/accounting/static/js/transaction-form.js:676
#: src/accounting/transaction/forms.py:56
#: src/accounting/static/js/transaction-form.js:523
#: src/accounting/transaction/forms.py:57
msgid "Please add some currencies."
msgstr "請加上貨幣。"
#: src/accounting/static/js/transaction-form.js:742
#: src/accounting/transaction/forms.py:77
#: src/accounting/static/js/transaction-form.js:589
#: src/accounting/transaction/forms.py:78
msgid "Please add some journal entries."
msgstr "請加上分錄。"
#: src/accounting/static/js/transaction-form.js:807
#: src/accounting/transaction/forms.py:670
#: src/accounting/static/js/transaction-form.js:654
#: src/accounting/transaction/forms.py:672
msgid "The totals of the debit and credit amounts do not match."
msgstr "借方貸方合計不符。 "
@ -167,7 +186,7 @@ msgstr "新增科目"
#: src/accounting/templates/accounting/currency/detail.html:31
#: src/accounting/templates/accounting/currency/include/form.html:33
#: src/accounting/templates/accounting/transaction/include/detail.html:31
#: src/accounting/templates/accounting/transaction/include/form.html:34
#: src/accounting/templates/accounting/transaction/include/form.html:36
#: src/accounting/templates/accounting/transaction/order.html:36
msgid "Back"
msgstr "回上頁"
@ -196,10 +215,10 @@ msgstr "科目刪除確認"
#: src/accounting/templates/accounting/account/detail.html:70
#: src/accounting/templates/accounting/account/include/form.html:91
#: src/accounting/templates/accounting/currency/detail.html:66
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:27
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:27
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:27
#: src/accounting/templates/accounting/transaction/include/detail.html:71
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:28
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:30
msgid "Close"
msgstr "關閉"
@ -210,10 +229,10 @@ msgstr "你確定要刪掉這個科目嗎?"
#: src/accounting/templates/accounting/account/detail.html:76
#: src/accounting/templates/accounting/account/include/form.html:112
#: src/accounting/templates/accounting/currency/detail.html:72
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:49
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:49
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:49
#: src/accounting/templates/accounting/transaction/include/detail.html:77
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:52
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:54
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:175
msgid "Cancel"
msgstr "取消"
@ -255,7 +274,7 @@ msgstr "科目管理"
#: src/accounting/templates/accounting/account/list.html:32
#: src/accounting/templates/accounting/currency/list.html:32
#: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:75
#: src/accounting/templates/accounting/transaction/include/form.html:60
#: src/accounting/templates/accounting/transaction/include/form.html:62
#: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:75
#: src/accounting/templates/accounting/transaction/list.html:37
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:77
@ -276,8 +295,7 @@ msgstr "桌機版檢索"
#: src/accounting/templates/accounting/base-account/list.html:34
#: src/accounting/templates/accounting/currency/list.html:40
#: src/accounting/templates/accounting/currency/list.html:52
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:34
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:34
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:34
#: src/accounting/templates/accounting/transaction/list.html:62
#: src/accounting/templates/accounting/transaction/list.html:74
msgid "Search"
@ -294,8 +312,7 @@ msgstr "行動版檢索"
#: src/accounting/templates/accounting/account/order.html:81
#: src/accounting/templates/accounting/base-account/list.html:51
#: src/accounting/templates/accounting/currency/list.html:77
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:46
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:46
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:46
#: src/accounting/templates/accounting/transaction/list.html:93
#: src/accounting/templates/accounting/transaction/order.html:80
msgid "There is no data."
@ -309,8 +326,9 @@ msgstr "%(base)s下的科目"
#: src/accounting/templates/accounting/account/include/form.html:75
#: src/accounting/templates/accounting/account/order.html:62
#: src/accounting/templates/accounting/currency/include/form.html:57
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:53
#: src/accounting/templates/accounting/transaction/include/form.html:76
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:55
#: src/accounting/templates/accounting/transaction/include/form.html:78
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:176
#: src/accounting/templates/accounting/transaction/order.html:61
msgid "Save"
msgstr "儲存"
@ -337,8 +355,7 @@ msgstr "選擇基本科目"
#: src/accounting/templates/accounting/account/include/form.html:114
#: src/accounting/templates/accounting/account/include/form.html:116
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:50
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:50
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:50
msgid "Clear"
msgstr "清除"
@ -432,7 +449,7 @@ msgstr "改轉帳"
#: src/accounting/templates/accounting/transaction/expense/detail.html:37
#: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:45
#: src/accounting/templates/accounting/transaction/include/form.html:52
#: src/accounting/templates/accounting/transaction/include/form.html:54
#: src/accounting/templates/accounting/transaction/income/detail.html:37
#: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:45
msgid "Content"
@ -462,6 +479,14 @@ msgstr "編輯%(txn)s"
msgid "Currency"
msgstr "貨幣"
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:26
msgid "Select Account"
msgstr "選擇科目"
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:44
msgid "More…"
msgstr "更多…"
#: src/accounting/templates/accounting/transaction/include/add-new-material-fab.html:26
msgid "Cash expense"
msgstr "現金支出"
@ -470,19 +495,6 @@ msgstr "現金支出"
msgid "Cash income"
msgstr "現金收入"
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:26
msgid "Select Credit Account"
msgstr "選擇貸方科目科目"
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:44
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:44
msgid "More…"
msgstr "更多…"
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:26
msgid "Select Debit Account"
msgstr "選擇借方科目"
#: src/accounting/templates/accounting/transaction/include/detail.html:70
msgid "Delete Transaction Confirmation"
msgstr "傳票刪除確認"
@ -500,21 +512,66 @@ msgid "Account"
msgstr "科目"
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:41
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:28
msgid "Summary"
msgstr "摘要"
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:47
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:49
msgid "Amount"
msgstr "金額"
#: src/accounting/templates/accounting/transaction/include/form.html:46
#: src/accounting/templates/accounting/transaction/include/form.html:48
msgid "Date"
msgstr "日期"
#: src/accounting/templates/accounting/transaction/include/form.html:69
#: src/accounting/templates/accounting/transaction/include/form.html:71
msgid "Note"
msgstr "備註"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:39
msgid "General"
msgstr "一般"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:44
msgid "Travel"
msgstr "差旅"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:49
msgid "Bus"
msgstr "公車"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:54
msgid "Regular"
msgstr "帳單"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:59
msgid "Number"
msgstr "數量"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:67
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:84
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:119
msgid "Tag"
msgstr "標籤"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:99
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:140
msgid "From"
msgstr "從"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:108
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:145
msgid "To"
msgstr "至"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:124
msgid "Route"
msgstr "路線"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:160
msgid "The number of items"
msgstr "數量"
#: src/accounting/templates/accounting/transaction/income/create.html:24
msgid "Add a New Cash Income Transaction"
msgstr "新增現金收入傳票"
@ -533,27 +590,27 @@ msgstr "借方"
msgid "Credit"
msgstr "貸方"
#: src/accounting/transaction/forms.py:44
#: src/accounting/transaction/forms.py:45
msgid "Please select the currency."
msgstr "請選擇貨幣。"
#: src/accounting/transaction/forms.py:67
#: src/accounting/transaction/forms.py:68
msgid "The currency does not exist."
msgstr "沒有這個貨幣。"
#: src/accounting/transaction/forms.py:88
#: src/accounting/transaction/forms.py:89
msgid "The account does not exist."
msgstr "沒有這個科目。"
#: src/accounting/transaction/forms.py:99
#: src/accounting/transaction/forms.py:100
msgid "Please fill in a positive amount."
msgstr "金額請填正數。"
#: src/accounting/transaction/forms.py:113
#: src/accounting/transaction/forms.py:114
msgid "This account is not for debit entries."
msgstr "科目不是借方科目。"
#: src/accounting/transaction/forms.py:200
#: src/accounting/transaction/forms.py:201
msgid "This account is not for credit entries."
msgstr "科目不是貸方科目。"

View File

@ -0,0 +1,327 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/28
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The test for the summary helper.
"""
import unittest
from datetime import date
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import create_app
from testlib import get_client
from testlib_txn import Accounts, NEXT_URI, add_txn
class SummeryHelperTestCase(unittest.TestCase):
"""The summary helper test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount, Transaction, \
JournalEntry
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
Transaction.query.delete()
JournalEntry.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor")
def test_summary_helper(self) -> None:
"""Test the summary helper.
:return: None.
"""
from accounting.transaction.summary_helper import SummaryHelper
for form in get_form_data(self.csrf_token):
add_txn(self.client, form)
with self.app.app_context():
helper: SummaryHelper = SummaryHelper()
# Debit-General
self.assertEqual(len(helper.debit.general.tags), 2)
self.assertEqual(helper.debit.general.tags[0].name, "Lunch")
self.assertEqual(len(helper.debit.general.tags[0].accounts), 2)
self.assertEqual(helper.debit.general.tags[0].accounts[0].code,
Accounts.MEAL)
self.assertEqual(helper.debit.general.tags[0].accounts[1].code,
Accounts.PAYABLE)
self.assertEqual(helper.debit.general.tags[1].name, "Dinner")
self.assertEqual(len(helper.debit.general.tags[1].accounts), 2)
self.assertEqual(helper.debit.general.tags[1].accounts[0].code,
Accounts.MEAL)
self.assertEqual(helper.debit.general.tags[1].accounts[1].code,
Accounts.PAYABLE)
# Debit-Travel
self.assertEqual(len(helper.debit.travel.tags), 3)
self.assertEqual(helper.debit.travel.tags[0].name, "Bike")
self.assertEqual(len(helper.debit.travel.tags[0].accounts), 1)
self.assertEqual(helper.debit.travel.tags[0].accounts[0].code,
Accounts.TRAVEL)
self.assertEqual(helper.debit.travel.tags[1].name, "Taxi")
self.assertEqual(len(helper.debit.travel.tags[1].accounts), 1)
self.assertEqual(helper.debit.travel.tags[1].accounts[0].code,
Accounts.TRAVEL)
self.assertEqual(helper.debit.travel.tags[2].name, "Airplane")
self.assertEqual(len(helper.debit.travel.tags[2].accounts), 1)
self.assertEqual(helper.debit.travel.tags[2].accounts[0].code,
Accounts.TRAVEL)
# Debit-Bus
self.assertEqual(len(helper.debit.bus.tags), 2)
self.assertEqual(helper.debit.bus.tags[0].name, "Train")
self.assertEqual(len(helper.debit.bus.tags[0].accounts), 1)
self.assertEqual(helper.debit.bus.tags[0].accounts[0].code,
Accounts.TRAVEL)
self.assertEqual(helper.debit.bus.tags[1].name, "Bus")
self.assertEqual(len(helper.debit.bus.tags[1].accounts), 1)
self.assertEqual(helper.debit.bus.tags[1].accounts[0].code,
Accounts.TRAVEL)
# Credit-General
self.assertEqual(len(helper.credit.general.tags), 2)
self.assertEqual(helper.credit.general.tags[0].name, "Lunch")
self.assertEqual(len(helper.credit.general.tags[0].accounts), 3)
self.assertEqual(helper.credit.general.tags[0].accounts[0].code,
Accounts.PAYABLE)
self.assertEqual(helper.credit.general.tags[0].accounts[1].code,
Accounts.BANK)
self.assertEqual(helper.credit.general.tags[0].accounts[2].code,
Accounts.CASH)
self.assertEqual(helper.credit.general.tags[1].name, "Dinner")
self.assertEqual(len(helper.credit.general.tags[1].accounts), 2)
self.assertEqual(helper.credit.general.tags[1].accounts[0].code,
Accounts.BANK)
self.assertEqual(helper.credit.general.tags[1].accounts[1].code,
Accounts.PAYABLE)
# Credit-Travel
self.assertEqual(len(helper.credit.travel.tags), 2)
self.assertEqual(helper.credit.travel.tags[0].name, "Bike")
self.assertEqual(len(helper.credit.travel.tags[0].accounts), 2)
self.assertEqual(helper.credit.travel.tags[0].accounts[0].code,
Accounts.PAYABLE)
self.assertEqual(helper.credit.travel.tags[0].accounts[1].code,
Accounts.PREPAID)
self.assertEqual(helper.credit.travel.tags[1].name, "Taxi")
self.assertEqual(len(helper.credit.travel.tags[1].accounts), 2)
self.assertEqual(helper.credit.travel.tags[1].accounts[0].code,
Accounts.PAYABLE)
self.assertEqual(helper.credit.travel.tags[1].accounts[1].code,
Accounts.CASH)
# Credit-Bus
self.assertEqual(len(helper.credit.bus.tags), 2)
self.assertEqual(helper.credit.bus.tags[0].name, "Train")
self.assertEqual(len(helper.credit.bus.tags[0].accounts), 2)
self.assertEqual(helper.credit.bus.tags[0].accounts[0].code,
Accounts.PREPAID)
self.assertEqual(helper.credit.bus.tags[0].accounts[1].code,
Accounts.PAYABLE)
self.assertEqual(helper.credit.bus.tags[1].name, "Bus")
self.assertEqual(len(helper.credit.bus.tags[1].accounts), 1)
self.assertEqual(helper.credit.bus.tags[1].accounts[0].code,
Accounts.PREPAID)
def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"""Returns the form data for multiple transaction forms.
:param csrf_token: The CSRF token.
:return: A list of the form data.
"""
txn_date: str = date.today().isoformat()
return [{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-credit-0-account_code": Accounts.SERVICE,
"currency-0-credit-0-summary": " Salary ",
"currency-0-credit-0-amount": "2500"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL,
"currency-0-debit-0-summary": " Lunch—Fish ",
"currency-0-debit-0-amount": "4.7",
"currency-0-credit-0-account_code": Accounts.BANK,
"currency-0-credit-0-summary": " Lunch—Fish ",
"currency-0-credit-0-amount": "4.7",
"currency-0-debit-1-account_code": Accounts.MEAL,
"currency-0-debit-1-summary": " Lunch—Fries ",
"currency-0-debit-1-amount": "2.15",
"currency-0-credit-1-account_code": Accounts.PAYABLE,
"currency-0-credit-1-summary": " Lunch—Fries ",
"currency-0-credit-1-amount": "2.15",
"currency-0-debit-2-account_code": Accounts.MEAL,
"currency-0-debit-2-summary": " Dinner—Hamburger ",
"currency-0-debit-2-amount": "4.25",
"currency-0-credit-2-account_code": Accounts.BANK,
"currency-0-credit-2-summary": " Dinner—Hamburger ",
"currency-0-credit-2-amount": "4.25"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL,
"currency-0-debit-0-summary": " Lunch—Salad ",
"currency-0-debit-0-amount": "4.99",
"currency-0-credit-0-account_code": Accounts.CASH,
"currency-0-credit-0-summary": " Lunch—Salad ",
"currency-0-credit-0-amount": "4.99",
"currency-0-debit-1-account_code": Accounts.MEAL,
"currency-0-debit-1-summary": " Dinner—Steak ",
"currency-0-debit-1-amount": "8.28",
"currency-0-credit-1-account_code": Accounts.PAYABLE,
"currency-0-credit-1-summary": " Dinner—Steak ",
"currency-0-credit-1-amount": "8.28"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL,
"currency-0-debit-0-summary": " Lunch—Pizza ",
"currency-0-debit-0-amount": "5.49",
"currency-0-credit-0-account_code": Accounts.PAYABLE,
"currency-0-credit-0-summary": " Lunch—Pizza ",
"currency-0-credit-0-amount": "5.49",
"currency-0-debit-1-account_code": Accounts.MEAL,
"currency-0-debit-1-summary": " Lunch—Noodles ",
"currency-0-debit-1-amount": "7.47",
"currency-0-credit-1-account_code": Accounts.PAYABLE,
"currency-0-credit-1-summary": " Lunch—Noodles ",
"currency-0-credit-1-amount": "7.47"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL,
"currency-0-debit-0-summary": " Airplane—Lake City↔Hill Town ",
"currency-0-debit-0-amount": "800"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL,
"currency-0-debit-0-summary": " Bus—323—Downtown→Museum ",
"currency-0-debit-0-amount": "2.5",
"currency-0-credit-0-account_code": Accounts.PREPAID,
"currency-0-credit-0-summary": " Bus—323—Downtown→Museum ",
"currency-0-credit-0-amount": "2.5",
"currency-0-debit-1-account_code": Accounts.TRAVEL,
"currency-0-debit-1-summary": " Train—Blue—Museum→Central ",
"currency-0-debit-1-amount": "3.2",
"currency-0-credit-1-account_code": Accounts.PREPAID,
"currency-0-credit-1-summary": " Train—Blue—Museum→Central ",
"currency-0-credit-1-amount": "3.2",
"currency-0-debit-2-account_code": Accounts.TRAVEL,
"currency-0-debit-2-summary": " Train—Green—Central→Mall ",
"currency-0-debit-2-amount": "3.2",
"currency-0-credit-2-account_code": Accounts.PREPAID,
"currency-0-credit-2-summary": " Train—Green—Central→Mall ",
"currency-0-credit-2-amount": "3.2",
"currency-0-debit-3-account_code": Accounts.TRAVEL,
"currency-0-debit-3-summary": " Train—Red—Mall→Museum ",
"currency-0-debit-3-amount": "4.4",
"currency-0-credit-3-account_code": Accounts.PAYABLE,
"currency-0-credit-3-summary": " Train—Red—Mall→Museum ",
"currency-0-credit-3-amount": "4.4"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL,
"currency-0-debit-0-summary": " Taxi—Museum→Office ",
"currency-0-debit-0-amount": "15.5",
"currency-0-credit-0-account_code": Accounts.CASH,
"currency-0-credit-0-summary": " Taxi—Museum→Office ",
"currency-0-credit-0-amount": "15.5",
"currency-0-debit-1-account_code": Accounts.TRAVEL,
"currency-0-debit-1-summary": " Taxi—Office→Restaurant ",
"currency-0-debit-1-amount": "12",
"currency-0-credit-1-account_code": Accounts.PAYABLE,
"currency-0-credit-1-summary": " Taxi—Office→Restaurant ",
"currency-0-credit-1-amount": "12",
"currency-0-debit-2-account_code": Accounts.TRAVEL,
"currency-0-debit-2-summary": " Taxi—Restaurant→City Hall ",
"currency-0-debit-2-amount": "8",
"currency-0-credit-2-account_code": Accounts.PAYABLE,
"currency-0-credit-2-summary": " Taxi—Restaurant→City Hall ",
"currency-0-credit-2-amount": "8",
"currency-0-debit-3-account_code": Accounts.TRAVEL,
"currency-0-debit-3-summary": " Bike—City Hall→Office ",
"currency-0-debit-3-amount": "3.5",
"currency-0-credit-3-account_code": Accounts.PAYABLE,
"currency-0-credit-3-summary": " Bike—City Hall→Office ",
"currency-0-credit-3-amount": "3.5",
"currency-0-debit-4-account_code": Accounts.TRAVEL,
"currency-0-debit-4-summary": " Bike—Restaurant→Office ",
"currency-0-debit-4-amount": "4",
"currency-0-credit-4-account_code": Accounts.PAYABLE,
"currency-0-credit-4-summary": " Bike—Restaurant→Office ",
"currency-0-credit-4-amount": "4",
"currency-0-debit-5-account_code": Accounts.TRAVEL,
"currency-0-debit-5-summary": " Bike—Office→Theatre ",
"currency-0-debit-5-amount": "1.5",
"currency-0-credit-5-account_code": Accounts.PAYABLE,
"currency-0-credit-5-summary": " Bike—Office→Theatre ",
"currency-0-credit-5-amount": "1.5",
"currency-0-debit-6-account_code": Accounts.TRAVEL,
"currency-0-debit-6-summary": " Bike—Theatre→Home ",
"currency-0-debit-6-amount": "5.5",
"currency-0-credit-6-account_code": Accounts.PREPAID,
"currency-0-credit-6-summary": " Bike—Theatre→Home ",
"currency-0-credit-6-amount": "5.5"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.PAYABLE,
"currency-0-debit-0-summary": " Dinner—Steak ",
"currency-0-debit-0-amount": "8.28",
"currency-0-credit-0-account_code": Accounts.BANK,
"currency-0-credit-0-summary": " Dinner—Steak ",
"currency-0-credit-0-amount": "8.28",
"currency-0-debit-1-account_code": Accounts.PAYABLE,
"currency-0-debit-1-summary": " Lunch—Pizza ",
"currency-0-debit-1-amount": "5.49",
"currency-0-credit-1-account_code": Accounts.BANK,
"currency-0-credit-1-summary": " Lunch—Pizza ",
"currency-0-credit-1-amount": "5.49"}]

View File

@ -31,7 +31,7 @@ from testlib import get_client
from testlib_txn import Accounts, get_add_form, get_unchanged_update_form, \
get_update_form, match_txn_detail, set_negative_amount, \
remove_debit_in_a_currency, remove_credit_in_a_currency, NEXT_URI, \
NON_EMPTY_NOTE, EMPTY_NOTE
NON_EMPTY_NOTE, EMPTY_NOTE, add_txn
PREFIX: str = "/accounting/transactions"
"""The URL prefix of the transaction management."""
@ -75,7 +75,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
:return: None.
"""
client, csrf_token = get_client(self.app, "nobody")
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
add_form: dict[str, str] = self.__get_add_form()
add_form["csrf_token"] = csrf_token
update_form: dict[str, str] = self.__get_update_form(txn_id)
@ -110,7 +110,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
:return: None.
"""
client, csrf_token = get_client(self.app, "viewer")
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
add_form: dict[str, str] = self.__get_add_form()
add_form["csrf_token"] = csrf_token
update_form: dict[str, str] = self.__get_update_form(txn_id)
@ -144,7 +144,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
:return: None.
"""
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
add_form: dict[str, str] = self.__get_add_form()
update_form: dict[str, str] = self.__get_update_form(txn_id)
response: httpx.Response
@ -326,7 +326,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction, TransactionCurrency
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
edit_uri: str = f"{PREFIX}/{txn_id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_id}/update"
@ -479,7 +479,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_id}/update"
txn: Transaction
@ -513,7 +513,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
editor_username, editor2_username = "editor", "editor2"
client, csrf_token = get_client(self.app, editor2_username)
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
@ -542,7 +542,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
:return: None.
"""
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
delete_uri: str = f"{PREFIX}/{txn_id}/delete"
response: httpx.Response
@ -562,17 +562,6 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
"next": NEXT_URI})
self.assertEqual(response.status_code, 404)
def __add_txn(self) -> int:
"""Adds a transaction.
:return: The newly-added transaction ID..
"""
store_uri: str = f"{PREFIX}/store/income"
form: dict[str, str] = self.__get_add_form()
response: httpx.Response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
return match_txn_detail(response.headers["Location"])
def __get_add_form(self) -> dict[str, str]:
"""Returns the form data to add a new transaction.
@ -647,7 +636,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
:return: None.
"""
client, csrf_token = get_client(self.app, "nobody")
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
add_form: dict[str, str] = self.__get_add_form()
add_form["csrf_token"] = csrf_token
update_form: dict[str, str] = self.__get_update_form(txn_id)
@ -682,7 +671,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
:return: None.
"""
client, csrf_token = get_client(self.app, "viewer")
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
add_form: dict[str, str] = self.__get_add_form()
add_form["csrf_token"] = csrf_token
update_form: dict[str, str] = self.__get_update_form(txn_id)
@ -716,7 +705,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
:return: None.
"""
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
add_form: dict[str, str] = self.__get_add_form()
update_form: dict[str, str] = self.__get_update_form(txn_id)
response: httpx.Response
@ -901,7 +890,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction, TransactionCurrency
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
edit_uri: str = f"{PREFIX}/{txn_id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_id}/update"
@ -1058,7 +1047,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_id}/update"
txn: Transaction
@ -1092,7 +1081,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
editor_username, editor2_username = "editor", "editor2"
client, csrf_token = get_client(self.app, editor2_username)
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
@ -1121,7 +1110,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
:return: None.
"""
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
delete_uri: str = f"{PREFIX}/{txn_id}/delete"
response: httpx.Response
@ -1141,17 +1130,6 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
"next": NEXT_URI})
self.assertEqual(response.status_code, 404)
def __add_txn(self) -> int:
"""Adds a transaction.
:return: The newly-added transaction ID..
"""
store_uri: str = f"{PREFIX}/store/expense"
form: dict[str, str] = self.__get_add_form()
response: httpx.Response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
return match_txn_detail(response.headers["Location"])
def __get_add_form(self) -> dict[str, str]:
"""Returns the form data to add a new transaction.
@ -1226,7 +1204,7 @@ class TransferTransactionTestCase(unittest.TestCase):
:return: None.
"""
client, csrf_token = get_client(self.app, "nobody")
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
add_form: dict[str, str] = self.__get_add_form()
add_form["csrf_token"] = csrf_token
update_form: dict[str, str] = self.__get_update_form(txn_id)
@ -1261,7 +1239,7 @@ class TransferTransactionTestCase(unittest.TestCase):
:return: None.
"""
client, csrf_token = get_client(self.app, "viewer")
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
add_form: dict[str, str] = self.__get_add_form()
add_form["csrf_token"] = csrf_token
update_form: dict[str, str] = self.__get_update_form(txn_id)
@ -1295,7 +1273,7 @@ class TransferTransactionTestCase(unittest.TestCase):
:return: None.
"""
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
add_form: dict[str, str] = self.__get_add_form()
update_form: dict[str, str] = self.__get_update_form(txn_id)
response: httpx.Response
@ -1507,7 +1485,7 @@ class TransferTransactionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction, TransactionCurrency
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
edit_uri: str = f"{PREFIX}/{txn_id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_id}/update"
@ -1698,7 +1676,7 @@ class TransferTransactionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_id}/update"
txn: Transaction
@ -1732,7 +1710,7 @@ class TransferTransactionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
editor_username, editor2_username = "editor", "editor2"
client, csrf_token = get_client(self.app, editor2_username)
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
@ -1762,7 +1740,7 @@ class TransferTransactionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction, TransactionCurrency
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{txn_id}?as=income&next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_id}/update?as=income"
form_0: dict[str, str] = self.__get_update_form(txn_id)
@ -1861,7 +1839,7 @@ class TransferTransactionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction, TransactionCurrency
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{txn_id}?as=expense&next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_id}/update?as=expense"
form_0: dict[str, str] = self.__get_update_form(txn_id)
@ -1963,7 +1941,7 @@ class TransferTransactionTestCase(unittest.TestCase):
:return: None.
"""
txn_id: int = self.__add_txn()
txn_id: int = add_txn(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{txn_id}?next=%2F_next"
delete_uri: str = f"{PREFIX}/{txn_id}/delete"
response: httpx.Response
@ -1983,17 +1961,6 @@ class TransferTransactionTestCase(unittest.TestCase):
"next": NEXT_URI})
self.assertEqual(response.status_code, 404)
def __add_txn(self) -> int:
"""Adds a transaction.
:return: The newly-added transaction ID..
"""
store_uri: str = f"{PREFIX}/store/transfer"
form: dict[str, str] = self.__get_add_form()
response: httpx.Response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
return match_txn_detail(response.headers["Location"])
def __get_add_form(self) -> dict[str, str]:
"""Returns the form data to add a new transaction.
@ -2062,11 +2029,11 @@ class TransactionReorderTestCase(unittest.TestCase):
from accounting.models import Transaction
response: httpx.Response
id_1: int = self.__add_income_txn()
id_2: int = self.__add_expense_txn()
id_3: int = self.__add_transfer_txn()
id_4: int = self.__add_income_txn()
id_5: int = self.__add_expense_txn()
id_1: int = add_txn(self.client, self.__get_add_income_form())
id_2: int = add_txn(self.client, self.__get_add_expense_form())
id_3: int = add_txn(self.client, self.__get_add_transfer_form())
id_4: int = add_txn(self.client, self.__get_add_income_form())
id_5: int = add_txn(self.client, self.__get_add_expense_form())
with self.app.app_context():
txn_1: Transaction = db.session.get(Transaction, id_1)
@ -2108,11 +2075,11 @@ class TransactionReorderTestCase(unittest.TestCase):
from accounting.models import Transaction
response: httpx.Response
id_1: int = self.__add_income_txn()
id_2: int = self.__add_expense_txn()
id_3: int = self.__add_transfer_txn()
id_4: int = self.__add_income_txn()
id_5: int = self.__add_expense_txn()
id_1: int = add_txn(self.client, self.__get_add_income_form())
id_2: int = add_txn(self.client, self.__get_add_expense_form())
id_3: int = add_txn(self.client, self.__get_add_transfer_form())
id_4: int = add_txn(self.client, self.__get_add_income_form())
id_5: int = add_txn(self.client, self.__get_add_expense_form())
with self.app.app_context():
txn_date: date = db.session.get(Transaction, id_1).date
@ -2160,17 +2127,6 @@ class TransactionReorderTestCase(unittest.TestCase):
self.assertEqual(db.session.get(Transaction, id_4).no, 1)
self.assertEqual(db.session.get(Transaction, id_5).no, 5)
def __add_income_txn(self) -> int:
"""Adds a transaction.
:return: The newly-added transaction ID..
"""
store_uri: str = f"{PREFIX}/store/income"
form: dict[str, str] = self.__get_add_income_form()
response: httpx.Response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
return match_txn_detail(response.headers["Location"])
def __get_add_income_form(self) -> dict[str, str]:
"""Returns the form data to add a new transaction.
@ -2180,17 +2136,6 @@ class TransactionReorderTestCase(unittest.TestCase):
form = {x: form[x] for x in form if "-debit-" not in x}
return form
def __add_expense_txn(self) -> int:
"""Adds a transaction.
:return: The newly-added transaction ID..
"""
store_uri: str = f"{PREFIX}/store/expense"
form: dict[str, str] = self.__get_add_expense_form()
response: httpx.Response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
return match_txn_detail(response.headers["Location"])
def __get_add_expense_form(self) -> dict[str, str]:
"""Returns the form data to add a new transaction.
@ -2214,17 +2159,6 @@ class TransactionReorderTestCase(unittest.TestCase):
form = {x: form[x] for x in form if "-credit-" not in x}
return form
def __add_transfer_txn(self) -> int:
"""Adds a transaction.
:return: The newly-added transaction ID..
"""
store_uri: str = f"{PREFIX}/store/transfer"
form: dict[str, str] = self.__get_add_transfer_form()
response: httpx.Response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
return match_txn_detail(response.headers["Location"])
def __get_add_transfer_form(self) -> dict[str, str]:
"""Returns the form data to add a new transaction.

View File

@ -22,6 +22,7 @@ from decimal import Decimal
from datetime import date
from secrets import randbelow
import httpx
from flask import Flask
from test_site import db
@ -38,11 +39,14 @@ class Accounts:
"""The shortcuts to the common accounts."""
CASH: str = "1111-001"
BANK: str = "1113-001"
PREPAID: str = "1258-001"
PAYABLE: str = "2141-001"
SALES: str = "4111-001"
SERVICE: str = "4611-001"
AGENCY: str = "4711-001"
OFFICE: str = "5153-001"
TRAVEL: str = "5154-001"
OFFICE: str = "6153-001"
TRAVEL: str = "6154-001"
MEAL: str = "6172-001"
INTEREST: str = "4111-001"
DONATION: str = "7481-001"
RENT: str = "7482-001"
@ -381,6 +385,25 @@ def __get_currency_prefix(form: dict[str, str], code: str) -> str:
return m.group(1)
def add_txn(client: httpx.Client, form: dict[str, str]) -> int:
"""Adds a transfer transaction.
:param client: The client.
:param form: The form data.
:return: The newly-added transaction ID.
"""
prefix: str = "/accounting/transactions"
txn_type: str = "transfer"
if len({x for x in form if "-debit-" in x}) == 0:
txn_type = "income"
elif len({x for x in form if "-credit-" in x}) == 0:
txn_type = "expense"
store_uri = f"{prefix}/store/{txn_type}"
response: httpx.Response = client.post(store_uri, data=form)
assert response.status_code == 302
return match_txn_detail(response.headers["Location"])
def match_txn_detail(location: str) -> int:
"""Validates if the redirect location is the transaction detail, and
returns the transaction ID on success.