From 9a41cb10a189faf418436f25ebfcc0d3de3d50f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BE=9D=E7=91=AA=E8=B2=93?= Date: Fri, 3 Mar 2023 13:18:29 +0800 Subject: [PATCH] Rewrote the summary helper, added the TabPlane classes so that the internal states of the summary helper is stored in the tab plane objects instead of passing the as parameters and variables. --- src/accounting/static/js/summary-helper.js | 1716 ++++++++++------- .../include/summary-helper-modal.html | 54 +- 2 files changed, 1055 insertions(+), 715 deletions(-) diff --git a/src/accounting/static/js/summary-helper.js b/src/accounting/static/js/summary-helper.js index bc85229..7374dd0 100644 --- a/src/accounting/static/js/summary-helper.js +++ b/src/accounting/static/js/summary-helper.js @@ -32,6 +32,18 @@ document.addEventListener("DOMContentLoaded", function () { */ class SummaryHelper { + /** + * The summary helper form + * @type {HTMLFormElement} + */ + #form; + + /** + * The modal of the summary helper + * @type {HTMLFormElement} + */ + #modal; + /** * The entry type, either "debit" or "credit" * @type {string} @@ -42,13 +54,79 @@ class SummaryHelper { * The prefix of the HTML ID and class * @type {string} */ - #prefix; + prefix; /** - * The default tab ID - * @type {string} + * The current tab. + * @type {TabPlane} */ - #defaultTabId; + currentTab; + + /** + * The summary input. + * @type {HTMLInputElement} + */ + summary; + + /** + * The number input. + * @type {HTMLInputElement} + */ + number; + + /** + * The account buttons + * @type {HTMLButtonElement[]} + */ + #accountButtons; + + /** + * The selected account button + * @type {HTMLButtonElement|null} + */ + #selectedAccount = null; + + /** + * The modal of the journal entry form + * @type {HTMLDivElement} + */ + #entryFormModal; + + /** + * The control of the account on the journal entry form + * @type {HTMLDivElement} + */ + #formAccountControl; + + /** + * The account on the journal entry form + * @type {HTMLDivElement} + */ + #formAccount; + + /** + * The control of the summary on the journal entry form + * @type {HTMLDivElement} + */ + #formSummaryControl; + + /** + * The summary on the journal entry form + * @type {HTMLDivElement} + */ + #formSummary; + + /** + * The tab plane classes + * @type {typeof TabPlane[]} + */ + #TAB_CLASES = [GeneralTagTab, GeneralTripTab, BusTripTab, RegularPaymentTab, NumberTab] + + /** + * The tab planes + * @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, regular: RegularPaymentTab, number: NumberTab}} + */ + tabPlanes = {}; /** * Constructs a summary helper. @@ -56,323 +134,34 @@ class SummaryHelper { * @param form {HTMLFormElement} the summary helper form */ constructor(form) { + this.#form = form; this.#entryType = form.dataset.entryType; - this.#prefix = "accounting-summary-helper-" + form.dataset.entryType; - this.#defaultTabId = form.dataset.defaultTabId; - this.#init(); - } + this.prefix = "accounting-summary-helper-" + form.dataset.entryType; + this.#modal = document.getElementById(this.prefix + "-modal"); + this.summary = document.getElementById(this.prefix + "-summary"); + this.number = document.getElementById(this.prefix + "-number-number"); + // noinspection JSValidateTypes + this.#accountButtons = Array.from(document.getElementsByClassName(this.prefix + "-account")); - /** - * 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); - } + // Things from the entry form + this.#entryFormModal = document.getElementById("accounting-entry-form-modal"); + this.#formAccountControl = document.getElementById("accounting-entry-form-account-control"); + this.#formAccount = document.getElementById("accounting-entry-form-account"); + this.#formSummaryControl = document.getElementById("accounting-entry-form-summary-control"); + this.#formSummary = document.getElementById("accounting-entry-form-summary"); + + for (const cls of this.#TAB_CLASES) { + const tab = new cls(this); + this.tabPlanes[tab.tabId()] = tab; } - this.#initializeGeneralTagHelper(); - this.#initializeGeneralTripHelper(); - this.#initializeBusTripHelper(); - this.#initializeNumberHelper(); + this.currentTab = this.tabPlanes.general; 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); + this.summary.onchange = function () { + helper.#onSummaryChange(); }; - } - - /** - * 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()) { + this.#form.onsubmit = function () { + if (helper.currentTab.validate()) { helper.#submit(); } return false; @@ -380,203 +169,72 @@ class SummaryHelper { } /** - * Validates the form. + * The callback when the summary input is changed. * - * @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; + #onSummaryChange() { + for (const tab of [this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) { + if (tab.populate()) { + break; + } + } + this.tabPlanes.number.populate(); + } + + /** + * Filters the suggested accounts. + * + * @param tagButton {HTMLButtonElement|null} the tag button + */ + filterSuggestedAccounts(tagButton) { + for (const accountButton of this.#accountButtons) { + accountButton.classList.add("d-none"); + } + if (tagButton === null) { + this.#selectAccount(null); + return; + } + const suggested = JSON.parse(tagButton.dataset.accounts); + let selectedAccountButton = null; + for (const accountButton of this.#accountButtons) { + if (suggested.includes(accountButton.dataset.code)) { + accountButton.classList.remove("d-none"); + if (accountButton.dataset.code === suggested[0]) { + selectedAccountButton = accountButton; } } } - return isValid; + this.#selectAccount(selectedAccountButton); } /** - * Validates a general tag. + * Initializes the suggested accounts. * - * @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; + #initializeSuggestedAccounts() { + const helper = this; + for (const accountButton of this.#accountButtons) { + accountButton.onclick = function () { + helper.#selectAccount(accountButton); + }; } - field.classList.remove("is-invalid"); - error.innerText = ""; - return true; } /** - * Validates the origin of a general trip. + * Select a suggested account. * - * @return {boolean} true if valid, or false otherwise + * @param selectedAccountButton {HTMLButtonElement|null} the account button, or null to deselect the account */ - #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; + #selectAccount(selectedAccountButton) { + for (const accountButton of this.#accountButtons) { + accountButton.classList.remove("btn-primary"); + accountButton.classList.add("btn-outline-primary"); } - 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; + if (selectedAccountButton !== null) { + selectedAccountButton.classList.remove("btn-outline-primary"); + selectedAccountButton.classList.add("btn-primary"); } - 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; + this.#selectedAccount = selectedAccountButton; } /** @@ -584,52 +242,31 @@ class SummaryHelper { * */ #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"); + if (this.summary.value === "") { + this.#formSummaryControl.classList.remove("accounting-not-empty"); } else { - formSummaryControl.classList.add("accounting-not-empty"); + this.#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; + if (this.#selectedAccount !== null) { + this.#formAccountControl.classList.add("accounting-not-empty"); + this.#formAccount.dataset.code = this.#selectedAccount.dataset.code; + this.#formAccount.dataset.text = this.#selectedAccount.dataset.text; + this.#formAccount.innerText = this.#selectedAccount.dataset.text; } - formSummary.dataset.value = summary.value; - formSummary.innerText = summary.value; - bootstrap.Modal.getInstance(helperModal).hide(); - bootstrap.Modal.getOrCreateInstance(entryModal).show(); + this.#formSummary.dataset.value = this.summary.value; + this.#formSummary.innerText = this.summary.value; + bootstrap.Modal.getOrCreateInstance(this.#modal).hide(); + bootstrap.Modal.getOrCreateInstance(this.#entryFormModal).show(); } /** - * Initializes the summary helper when it is shown. + * The callback when the summary helper 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"; - } - } + #onOpen() { this.#reset(); - if (!isNew) { - summary.value = formSummary.dataset.value; - this.#parseAndPopulate(); - } + this.summary.value = this.#formSummary.dataset.value; + this.#onSummaryChange(); } /** @@ -637,151 +274,11 @@ class SummaryHelper { * */ #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"); + this.summary.value = ""; + for (const tab of Object.values(this.tabPlanes)) { + tab.reset(); } - 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"); + this.tabPlanes.general.switchToMe(); } /** @@ -796,23 +293,15 @@ class SummaryHelper { */ static initialize() { const forms = Array.from(document.getElementsByClassName("accounting-summary-helper")); + const entryForm = document.getElementById("accounting-entry-form"); + const formSummaryControl = document.getElementById("accounting-entry-form-summary-control"); 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; + const helpers = this; formSummaryControl.onclick = function () { - helpers[entryForm.dataset.entryType].initShow(false); + helpers.#helpers[entryForm.dataset.entryType].#onOpen(); }; } @@ -822,6 +311,857 @@ class SummaryHelper { * @param entryType {string} the entry type, either "debit" or "credit" */ static initializeNewJournalEntry(entryType) { - this.#helpers[entryType].initShow(true); + this.#helpers[entryType].#onOpen(); + } +} + +/** + * A tab plane. + * + * @abstract + * @private + */ +class TabPlane { + + /** + * The parent summary helper + * @type {SummaryHelper} + */ + helper; + + /** + * The prefix of the HTML ID and classes + * @type {string} + */ + prefix; + + /** + * The tab + * @type {HTMLSpanElement} + */ + #tab; + + /** + * The page + * @type {HTMLDivElement} + */ + #page; + + /** + * Constructs a tab plane. + * + * @param helper {SummaryHelper} the parent summary helper + */ + constructor(helper) { + this.helper = helper; + this.prefix = this.helper.prefix + "-" + this.tabId(); + this.#tab = document.getElementById(this.prefix + "-tab"); + this.#page = document.getElementById(this.prefix + "-page"); + const tabPlane = this; + this.#tab.onclick = function () { + tabPlane.switchToMe(); + }; + } + + /** + * The tab ID + * + * @return {string} + * @abstract + */ + tabId() { throw new Error("Method not implemented.") }; + + /** + * Resets the tab plane input. + * + * @abstract + */ + reset() { throw new Error("Method not implemented."); } + + /** + * Populates the tab plane with the summary input. + * + * @return {boolean} true if the summary input matches this tab, or false otherwise + * @abstract + */ + populate() { throw new Error("Method not implemented."); } + + /** + * Validates the input in the tab plane. + * + * @return {boolean} true if valid, or false otherwise + * @abstract + */ + validate() { throw new Error("Method not implemented."); } + + /** + * Switches to the tab plane. + * + */ + switchToMe() { + for (const tabPlane of Object.values(this.helper.tabPlanes)) { + tabPlane.#tab.classList.remove("active") + tabPlane.#tab.ariaCurrent = "false"; + tabPlane.#page.classList.add("d-none"); + tabPlane.#page.ariaCurrent = "false"; + } + this.#tab.classList.add("active"); + this.#tab.ariaCurrent = "page"; + this.#page.classList.remove("d-none"); + this.#page.ariaCurrent = "page"; + this.helper.currentTab = this; + } +} + +/** + * A tag plane with selectable tags. + * + * @abstract + * @private + */ +class TagTabPlane extends TabPlane { + + /** + * The tag input + * @type {HTMLInputElement} + */ + tag; + + /** + * The error message for the tag input + * @type {HTMLDivElement} + */ + tagError; + + /** + * The tag buttons + * @type {HTMLButtonElement[]} + */ + tagButtons; + + /** + * Constructs a tab plane. + * + * @param helper {SummaryHelper} the parent summary helper + * @override + */ + constructor(helper) { + super(helper); + this.tag = document.getElementById(this.prefix + "-tag"); + this.tagError = document.getElementById(this.prefix + "-tag-error"); + // noinspection JSValidateTypes + this.tagButtons = Array.from(document.getElementsByClassName(this.prefix + "-btn-tag")); + this.initializeTagButtons(); + const tabPlane = this; + this.tag.onchange = function () { + let isMatched = false; + for (const tagButton of tabPlane.tagButtons) { + if (tagButton.dataset.value === tabPlane.tag.value) { + tagButton.classList.remove("btn-outline-primary"); + tagButton.classList.add("btn-primary"); + tabPlane.helper.filterSuggestedAccounts(tagButton); + isMatched = true; + } else { + tagButton.classList.remove("btn-primary"); + tagButton.classList.add("btn-outline-primary"); + } + } + if (!isMatched) { + tabPlane.helper.filterSuggestedAccounts(null); + } + tabPlane.updateSummary(); + tabPlane.validateTag(); + }; + } + + /** + * Updates the summary according to the input in the tab plane. + * + * @abstract + */ + updateSummary() { throw new Error("Method not implemented."); } + + /** + * Switches to the tab plane. + * + */ + switchToMe() { + super.switchToMe(); + let selectedTagButton = null; + for (const tagButton of this.tagButtons) { + if (tagButton.classList.contains("btn-primary")) { + selectedTagButton = tagButton; + break; + } + } + this.helper.filterSuggestedAccounts(selectedTagButton); + } + + /** + * Initializes the tag buttons. + * + */ + initializeTagButtons() { + const tabPlane = this; + for (const tagButton of tabPlane.tagButtons) { + tagButton.onclick = function () { + for (const otherButton of tabPlane.tagButtons) { + otherButton.classList.remove("btn-primary"); + otherButton.classList.add("btn-outline-primary"); + } + tagButton.classList.remove("btn-outline-primary"); + tagButton.classList.add("btn-primary"); + tabPlane.tag.value = tagButton.dataset.value; + tabPlane.helper.filterSuggestedAccounts(tagButton); + tabPlane.updateSummary(); + }; + } + } + + /** + * Validates the tag input. + * + * @return {boolean} true if valid, or false otherwise + */ + validateTag() { + this.tag.value = this.tag.value.trim(); + this.tag.classList.remove("is-invalid"); + this.tagError.innerText = ""; + return true; + } + + /** + * Validates a required field. + * + * @param field {HTMLInputElement} the input field + * @param errorContainer {HTMLDivElement} the error message container + * @param errorMessage {string} the error message + * @return {boolean} true if valid, or false otherwise + */ + validateRequiredField(field, errorContainer, errorMessage) { + field.value = field.value.trim(); + if (field.value === "") { + field.classList.add("is-invalid"); + errorContainer.innerText = errorMessage; + return false; + } + field.classList.remove("is-invalid"); + errorContainer.innerText = ""; + return true; + } + + /** + * Resets the tab plane input. + * + * @override + */ + reset() { + this.tag.value = ""; + this.tag.classList.remove("is-invalid"); + this.tagError.innerText = ""; + for (const tagButton of this.tagButtons) { + tagButton.classList.remove("btn-primary"); + tagButton.classList.add("btn-outline-primary"); + } + } +} + +/** + * The general tag tab plane. + * + * @private + */ +class GeneralTagTab extends TagTabPlane { + + /** + * The tab ID + * + * @return {string} + * @abstract + */ + tabId() { + return "general"; + }; + + /** + * Updates the summary according to the input in the tab plane. + * + * @override + */ + updateSummary() { + const pos = this.helper.summary.value.indexOf("—"); + const prefix = this.tag.value === ""? "": this.tag.value + "—"; + if (pos === -1) { + this.helper.summary.value = prefix + this.helper.summary.value; + } else { + this.helper.summary.value = prefix + this.helper.summary.value.substring(pos + 1); + } + } + + /** + * Populates the tab plane with the summary input. + * + * @return {boolean} true if the summary input matches this tab, or false otherwise + * @override + */ + populate() { + const found = this.helper.summary.value.match(/^([^—]+)—.+?(?:×\d+)?$/); + if (found === null) { + return false; + } + this.tag.value = found[1]; + for (const tagButton of this.tagButtons) { + if (tagButton.dataset.value === this.tag.value) { + tagButton.classList.remove("btn-outline-primary"); + tagButton.classList.add("btn-primary"); + this.helper.filterSuggestedAccounts(tagButton); + } + } + this.switchToMe(); + return true; + } + + /** + * Validates the input in the tab plane. + * + * @return {boolean} true if valid, or false otherwise + */ + validate() { + return this.validateTag(); + } +} + +/** + * The general trip tab plane. + * + * @private + */ +class GeneralTripTab extends TagTabPlane { + + /** + * The origin + * @type {HTMLInputElement} + */ + #from; + + /** + * The error of the origin + * @type {HTMLDivElement} + */ + #fromError; + + /** + * The destination + * @type {HTMLInputElement} + */ + #to; + + /** + * The error of the destination + * @type {HTMLDivElement} + */ + #toError; + + /** + * The direction buttons + * @type {HTMLButtonElement[]} + */ + #directionButtons; + + /** + * Constructs a tab plane. + * + * @param helper {SummaryHelper} the parent summary helper + * @override + */ + constructor(helper) { + super(helper); + this.#from = document.getElementById(this.prefix + "-from"); + this.#fromError = document.getElementById(this.prefix + "-from-error"); + this.#to = document.getElementById(this.prefix + "-to"); + this.#toError = document.getElementById(this.prefix + "-to-error") + // noinspection JSValidateTypes + this.#directionButtons = Array.from(document.getElementsByClassName(this.prefix + "-direction")); + const tabPlane = this; + this.#from.onchange = function () { + tabPlane.updateSummary(); + tabPlane.validateFrom(); + }; + for (const directionButton of this.#directionButtons) { + directionButton.onclick = function () { + for (const otherButton of tabPlane.#directionButtons) { + otherButton.classList.remove("btn-primary"); + otherButton.classList.add("btn-outline-primary"); + } + directionButton.classList.remove("btn-outline-primary"); + directionButton.classList.add("btn-primary"); + tabPlane.updateSummary(); + }; + } + this.#to.onchange = function () { + tabPlane.updateSummary(); + tabPlane.validateTo(); + }; + } + + /** + * The tab ID + * + * @return {string} + * @abstract + */ + tabId() { + return "travel"; + }; + + /** + * Updates the summary according to the input in the tab plane. + * + * @override + */ + updateSummary() { + let direction; + for (const directionButton of this.#directionButtons) { + if (directionButton.classList.contains("btn-primary")) { + direction = directionButton.dataset.arrow; + break; + } + } + this.helper.summary.value = this.tag.value + "—" + this.#from.value + direction + this.#to.value; + } + + /** + * Resets the tab plane input. + * + * @override + */ + reset() { + super.reset(); + this.#from.value = ""; + this.#from.classList.remove("is-invalid"); + this.#fromError.innerText = ""; + for (const directionButton of this.#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.#to.value = ""; + this.#to.classList.remove("is-invalid"); + this.#toError.innerText = ""; + } + + /** + * Populates the tab plane with the summary input. + * + * @return {boolean} true if the summary input matches this tab, or false otherwise + * @override + */ + populate() { + const found = this.helper.summary.value.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:×\d+)?$/); + if (found === null) { + return false; + } + this.tag.value = found[1]; + this.#from.value = found[2]; + for (const directionButton of this.#directionButtons) { + if (directionButton.dataset.arrow === found[3]) { + directionButton.classList.remove("btn-outline-primary"); + directionButton.classList.add("btn-primary"); + } else { + directionButton.classList.add("btn-outline-primary"); + directionButton.classList.remove("btn-primary"); + } + } + this.#to.value = found[4]; + for (const tagButton of this.tagButtons) { + if (tagButton.dataset.value === this.tag.value) { + tagButton.classList.remove("btn-outline-primary"); + tagButton.classList.add("btn-primary"); + this.helper.filterSuggestedAccounts(tagButton); + } + } + this.switchToMe(); + return true; + } + + /** + * Validates the input in the tab plane. + * + * @return {boolean} true if valid, or false otherwise + * @override + */ + validate() { + let isValid = true; + isValid = this.validateTag() && isValid; + isValid = this.validateFrom() && isValid; + isValid = this.validateTo() && isValid; + return isValid; + } + + /** + * Validates the tag input. + * + * @return {boolean} true if valid, or false otherwise + * @override + */ + validateTag() { + return this.validateRequiredField(this.tag, this.tagError, A_("Please fill in the tag.")); + } + + /** + * Validates the origin. + * + * @return {boolean} true if valid, or false otherwise + * @override + */ + validateFrom() { + return this.validateRequiredField(this.#from, this.#fromError, A_("Please fill in the origin.")); + } + + /** + * Validates the destination. + * + * @return {boolean} true if valid, or false otherwise + * @override + */ + validateTo() { + return this.validateRequiredField(this.#to, this.#toError, A_("Please fill in the destination.")); + } +} + +/** + * The bus trip tab plane. + * + * @private + */ +class BusTripTab extends TagTabPlane { + + /** + * The route + * @type {HTMLInputElement} + */ + #route; + + /** + * The error of the route + * @type {HTMLDivElement} + */ + #routeError; + + /** + * The origin + * @type {HTMLInputElement} + */ + #from; + + /** + * The error of the origin + * @type {HTMLDivElement} + */ + #fromError; + + /** + * The destination + * @type {HTMLInputElement} + */ + #to; + + /** + * The error of the destination + * @type {HTMLDivElement} + */ + #toError; + + /** + * Constructs a tab plane. + * + * @param helper {SummaryHelper} the parent summary helper + * @override + */ + constructor(helper) { + super(helper); + this.#route = document.getElementById(this.prefix + "-route"); + this.#routeError = document.getElementById(this.prefix + "-route-error"); + this.#from = document.getElementById(this.prefix + "-from"); + this.#fromError = document.getElementById(this.prefix + "-from-error"); + this.#to = document.getElementById(this.prefix + "-to"); + this.#toError = document.getElementById(this.prefix + "-to-error") + const tabPlane = this; + this.#route.onchange = function () { + tabPlane.updateSummary(); + tabPlane.validateRoute(); + }; + this.#from.onchange = function () { + tabPlane.updateSummary(); + tabPlane.validateFrom(); + }; + this.#to.onchange = function () { + tabPlane.updateSummary(); + tabPlane.validateTo(); + }; + } + + /** + * The tab ID + * + * @return {string} + * @abstract + */ + tabId() { + return "bus"; + }; + + /** + * Updates the summary according to the input in the tab plane. + * + * @override + */ + updateSummary() { + this.helper.summary.value = this.tag.value + "—" + this.#route.value + "—" + this.#from.value + "→" + this.#to.value; + } + + /** + * Resets the tab plane input. + * + * @override + */ + reset() { + super.reset(); + this.#route.value = ""; + this.#route.classList.remove("is-invalid"); + this.#routeError.innerText = ""; + this.#from.value = ""; + this.#from.classList.remove("is-invalid"); + this.#fromError.innerText = ""; + this.#to.value = ""; + this.#to.classList.remove("is-invalid"); + this.#toError.innerText = ""; + } + + /** + * Populates the tab plane with the summary input. + * + * @return {boolean} true if the summary input matches this tab, or false otherwise + * @override + */ + populate() { + const found = this.helper.summary.value.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:×\d+)?$/); + if (found === null) { + return false; + } + this.tag.value = found[1]; + this.#route.value = found[2]; + this.#from.value = found[3]; + this.#to.value = found[4]; + for (const tagButton of this.tagButtons) { + if (tagButton.dataset.value === this.tag.value) { + tagButton.classList.remove("btn-outline-primary"); + tagButton.classList.add("btn-primary"); + this.helper.filterSuggestedAccounts(tagButton); + break; + } + } + this.switchToMe(); + return true; + } + + /** + * Validates the input in the tab plane. + * + * @return {boolean} true if valid, or false otherwise + */ + validate() { + let isValid = true; + isValid = this.validateTag() && isValid; + isValid = this.validateRoute() && isValid; + isValid = this.validateFrom() && isValid; + isValid = this.validateTo() && isValid; + return isValid; + } + + /** + * Validates the tag input. + * + * @return {boolean} true if valid, or false otherwise + * @override + */ + validateTag() { + return this.validateRequiredField(this.tag, this.tagError, A_("Please fill in the tag.")); + } + + /** + * Validates the route. + * + * @return {boolean} true if valid, or false otherwise + * @override + */ + validateRoute() { + return this.validateRequiredField(this.#route, this.#routeError, A_("Please fill in the route.")); + } + + /** + * Validates the origin. + * + * @return {boolean} true if valid, or false otherwise + * @override + */ + validateFrom() { + return this.validateRequiredField(this.#from, this.#fromError, A_("Please fill in the origin.")); + } + + /** + * Validates the destination. + * + * @return {boolean} true if valid, or false otherwise + * @override + */ + validateTo() { + return this.validateRequiredField(this.#to, this.#toError, A_("Please fill in the destination.")); + } +} + +/** + * The regular payment tab plane. + * + * @private + */ +class RegularPaymentTab extends TabPlane { + + /** + * The payment buttons + * @type {HTMLButtonElement[]} + */ + #payments; + + // noinspection JSValidateTypes + /** + * Constructs a tab plane. + * + * @param helper {SummaryHelper} the parent summary helper + * @override + */ + constructor(helper) { + super(helper); + // noinspection JSValidateTypes + this.#payments = Array.from(document.getElementsByClassName(this.prefix + "-payment")); + } + + /** + * The tab ID + * + * @return {string} + * @abstract + */ + tabId() { + return "regular"; + }; + + /** + * Resets the tab plane input. + * + * @override + */ + reset() { + for (const payment of this.#payments) { + payment.classList.remove("btn-primary"); + payment.classList.add("btn-outline-primary"); + } + } + + /** + * Populates the tab plane with the summary input. + * + * @return {boolean} true if the summary input matches this tab, or false otherwise + * @override + */ + populate() { + return false; + } + + /** + * Validates the input in the tab plane. + * + * @return {boolean} true if valid, or false otherwise + * @override + */ + validate() { + return true; + } +} + +/** + * The number tab plane. + * + * @private + */ +class NumberTab extends TabPlane { + + /** + * Constructs a tab plane. + * + * @param helper {SummaryHelper} the parent summary helper + * @override + */ + constructor(helper) { + super(helper); + const tabPlane = this; + this.helper.number.onchange = function () { + const found = tabPlane.helper.summary.value.match(/^(.+)×(\d+)$/); + if (found !== null) { + tabPlane.helper.summary.value = found[1]; + } + if (parseInt(tabPlane.helper.number.value) > 1) { + tabPlane.helper.summary.value = tabPlane.helper.summary.value + "×" + tabPlane.helper.number.value; + } + }; + } + + /** + * The tab ID + * + * @return {string} + * @abstract + */ + tabId() { + return "number"; + }; + + /** + * Resets the tab plane input. + * + * @override + */ + reset() { + this.helper.number.value = ""; + } + + /** + * Populates the tab plane with the summary input. + * + * @return {boolean} true if the summary input matches this tab, or false otherwise + * @override + */ + populate() { + const found = this.helper.summary.value.match(/^.+×(\d+)$/); + if (found === null) { + this.helper.number.value = ""; + } else { + this.helper.number.value = found[1]; + } + return true; + } + + /** + * Validates the input in the tab plane. + * + * @return {boolean} true if valid, or false otherwise + * @override + */ + validate() { + return true; } } diff --git a/src/accounting/templates/accounting/transaction/include/summary-helper-modal.html b/src/accounting/templates/accounting/transaction/include/summary-helper-modal.html index 544c0c9..e65a302 100644 --- a/src/accounting/templates/accounting/transaction/include/summary-helper-modal.html +++ b/src/accounting/templates/accounting/transaction/include/summary-helper-modal.html @@ -19,7 +19,7 @@ entry-form-modal.html: The modal of the summary helper Author: imacat@mail.imacat.idv.tw (imacat) First written: 2023/2/28 #} -
+