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 #} -
+