/* The Mia! Accounting Project * description-editor.js: The JavaScript for the description editor */ /* Copyright (c) 2023 imacat. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* Author: imacat@mail.imacat.idv.tw (imacat) * First written: 2023/2/28 */ "use strict"; /** * A description editor. * */ class DescriptionEditor { /** * The line item editor * @type {JournalEntryLineItemEditor} */ lineItemEditor; /** * The description editor form * @type {HTMLFormElement} */ #form; /** * The prefix of the HTML ID and class names * @type {string} */ prefix; /** * The modal of the description editor * @type {HTMLDivElement} */ #modal; /** * Either "debit" or "credit" * @type {string} */ debitCredit; /** * The current tab * @type {DescriptionEditorTabPlane} */ currentTab; /** * The description input * @type {HTMLInputElement} */ #descriptionInput; /** * The button to the original line item selector * @type {HTMLButtonElement} */ #offsetButton; /** * The number input * @type {HTMLInputElement} */ number; /** * The note * @type {HTMLInputElement} */ note; /** * The placeholder of the confirmed account * @type {DescriptionEditorConfirmedAccount} */ #confirmedAccountPlaceholder; /** * All the suggested accounts * @type {DescriptionEditorSuggestedAccount[]} */ #allSuggestedAccounts; /** * The current suggested accounts * @type {DescriptionEditorSuggestedAccount[]} */ #currentSuggestedAccounts; /** * The account that the user specified or confirmed * @type {DescriptionEditorConfirmedAccount|null} */ #confirmedAccount = null; /** * Whether the user has confirmed the account * @type {boolean} */ isAccountConfirmed = false; /** * The selected account. * @type {DescriptionEditorAccount|null} */ selectedAccount = null; /** * The tab planes * @type {{general: DescriptionEditorGeneralTagTab, travel: DescriptionEditorGeneralTripTab, bus: DescriptionEditorBusTripTab, recurring: DescriptionEditorRecurringTab, annotation: DescriptionEditorAnnotationTab}} */ tabPlanes = {}; /** * Constructs a description editor. * * @param lineItemEditor {JournalEntryLineItemEditor} the line item editor * @param debitCredit {string} either "debit" or "credit" */ constructor(lineItemEditor, debitCredit) { this.lineItemEditor = lineItemEditor; this.debitCredit = debitCredit; this.prefix = `accounting-description-editor-${debitCredit}`; this.#form = document.getElementById(this.prefix); this.#modal = document.getElementById(`${this.prefix}-modal`); this.#descriptionInput = document.getElementById(`${this.prefix}-description`); this.#offsetButton = document.getElementById(`${this.prefix}-offset`); this.number = document.getElementById(`${this.prefix}-annotation-number`); this.note = document.getElementById(`${this.prefix}-annotation-note`); this.#confirmedAccountPlaceholder = new DescriptionEditorConfirmedAccount(this, document.getElementById(`${this.prefix}-account-confirmed`)); this.#allSuggestedAccounts = Array.from(document.getElementsByClassName(`${this.prefix}-account`)).map((button) => new DescriptionEditorSuggestedAccount(this, button)); for (const cls of [DescriptionEditorGeneralTagTab, DescriptionEditorGeneralTripTab, DescriptionEditorBusTripTab, DescriptionEditorRecurringTab, DescriptionEditorAnnotationTab]) { const tab = new cls(this); this.tabPlanes[tab.tabId()] = tab; } this.currentTab = this.tabPlanes.general; this.#descriptionInput.onchange = () => this.#onDescriptionChange(); this.#offsetButton.onclick = () => this.lineItemEditor.originalLineItemSelector.onOpen(); this.#form.onsubmit = () => { if (this.currentTab.validate()) { this.#submit(); } return false; }; } /** * Returns the description. * * @return {string} the description */ get description() { return this.#descriptionInput.value; } /** * Sets the description. * * @param description {string} the description */ set description(description) { this.#descriptionInput.value = description; } /** * Returns the current account options. * * @return {DescriptionEditorAccount[]} the current account options. */ get #currentAccountOptions() { if (this.#confirmedAccount === null) { return this.#currentSuggestedAccounts; } return [this.#confirmedAccount].concat(this.#currentSuggestedAccounts); } /** * The callback when the description input is changed. * */ #onDescriptionChange() { this.#resetTabPlanes(); this.selectedAccount = null; this.description = this.description.trim(); for (const tabPlane of [this.tabPlanes.recurring, this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) { if (tabPlane.populate()) { break; } } this.tabPlanes.annotation.populate(); } /** * Resets the tab planes. * */ #resetTabPlanes() { for (const tabPlane of Object.values(this.tabPlanes)) { tabPlane.reset(); } this.tabPlanes.general.switchToMe(); } /** * Updates the current suggested accounts. * * @param tagButton {HTMLButtonElement} the tag button */ updateCurrentSuggestedAccounts(tagButton) { this.clearSuggestedAccounts(); const suggestedAccountCodes = JSON.parse(tagButton.dataset.accounts); this.#currentSuggestedAccounts = this.#allSuggestedAccounts.filter((account) => { if (this.#confirmedAccount !== null && account.code === this.#confirmedAccount.code) { return false; } return suggestedAccountCodes.includes(account.code); }); for (const account of this.#currentSuggestedAccounts) { account.setShown(true); } this.#selectSuggestedAccount(suggestedAccountCodes[0]); } /** * Selects the suggested account. * * @param code {string} the code of the most-frequent suggested account */ #selectSuggestedAccount(code) { if (this.isAccountConfirmed) { return; } for (const account of this.#currentAccountOptions) { if (account.code === code) { this.selectAccount(account); return; } } } /** * Clears the suggested accounts. * */ clearSuggestedAccounts() { for (const account of this.#allSuggestedAccounts) { account.setShown(false); account.setActive(false); } this.#currentSuggestedAccounts = []; } /** * Select an account. * * @param selectedAccount {DescriptionEditorAccount|null} the account, or null to deselect the account */ selectAccount(selectedAccount) { for (const account of this.#currentAccountOptions) { account.setActive(false); } if (selectedAccount !== null) { selectedAccount.setActive(true); } this.selectedAccount = selectedAccount; if (this.selectedAccount !== null) { this.isAccountConfirmed &&= this.selectedAccount.isConfirmedAccount; } } /** * Submits the description. * */ #submit() { bootstrap.Modal.getOrCreateInstance(this.#modal).hide(); this.lineItemEditor.saveDescription(this); } /** * The callback when the description editor is shown. * */ onOpen() { this.description = this.lineItemEditor.description === null? "": this.lineItemEditor.description; this.#setConfirmedAccount(); this.#onDescriptionChange(); if (this.isAccountConfirmed) { this.selectAccount(this.#confirmedAccount); } } /** * Sets the confirmed account. * */ #setConfirmedAccount() { this.isAccountConfirmed = this.lineItemEditor.isAccountConfirmed; this.#confirmedAccountPlaceholder.setShown(this.isAccountConfirmed); if (this.isAccountConfirmed) { this.#confirmedAccountPlaceholder.initializeFrom(this.lineItemEditor.account); this.#confirmedAccount = this.#confirmedAccountPlaceholder; } else { this.#confirmedAccount = null; } } /** * Returns the description editor instances. * * @param lineItemEditor {JournalEntryLineItemEditor} the line item editor * @return {{debit: DescriptionEditor, credit: DescriptionEditor}} */ static getInstances(lineItemEditor) { const editors = {} const forms = Array.from(document.getElementsByClassName("accounting-description-editor")); for (const form of forms) { editors[form.dataset.debitCredit] = new DescriptionEditor(lineItemEditor, form.dataset.debitCredit); } return editors; } } /** * An account option in the description editor. * */ class DescriptionEditorAccount extends JournalEntryAccount { /** * The account button * @type {HTMLButtonElement} */ #element; /** * Whether this is the account specified or confirmed by the user * @type {boolean} */ isConfirmedAccount = false; /** * Constructs an account option in the description editor. * * @param editor {DescriptionEditor} the description editor * @param code {string} the account code * @param title {string} the account title * @param text {string} the account text * @param isNeedOffset {boolean} true if the line items in the account needs offset, or false otherwise * @param button {HTMLButtonElement} the account button */ constructor(editor, code, title, text, isNeedOffset, button) { super(code, title, text, isNeedOffset); this.#element = button; this.#element.onclick = () => editor.selectAccount(this); } /** * Sets whether the option is shown. * * @param isShown {boolean} true to show, or false otherwise */ setShown(isShown) { if (isShown) { this.#element.classList.remove("d-none"); } else { this.#element.classList.add("d-none"); } } /** * Sets whether the option is active. * * @param isActive {boolean} true if active, or false otherwise */ setActive(isActive) { if (isActive) { this.#element.classList.add("btn-primary"); this.#element.classList.remove("btn-outline-primary"); } else { this.#element.classList.remove("btn-primary"); this.#element.classList.add("btn-outline-primary"); } } /** * Sets the content of the account button. * */ resetContent() { this.#element.innerText = this.text; } } /** * A suggested account. * */ class DescriptionEditorSuggestedAccount extends DescriptionEditorAccount { /** * Constructs a suggested account. * * @param editor {DescriptionEditor} the description editor * @param button {HTMLButtonElement} the account button */ constructor(editor, button) { super(editor, button.dataset.code, button.dataset.title, button.dataset.text, button.classList.contains("accounting-account-is-need-offset"), button); } } /** * The account option that is specified or confirmed by the user. * */ class DescriptionEditorConfirmedAccount extends DescriptionEditorAccount { /** * Constructs the account option that is specified or confirmed by the user. * * @param editor {DescriptionEditor} the description editor * @param button {HTMLButtonElement} the account button */ constructor(editor, button) { super(editor, "", "", "", false, button); this.isConfirmedAccount = true; } /** * Initializes the confirmed account from the line item editor. * * @param account {JournalEntryAccount} the confirmed account from the line item editor */ initializeFrom(account) { this.code = account.code; this.text = account.text; this.isNeedOffset = account.isNeedOffset; this.resetContent(); } } /** * A tab plane. * * @abstract * @private */ class DescriptionEditorTabPlane { /** * The parent description editor * @type {DescriptionEditor} */ editor; /** * The prefix of the HTML ID and class names * @type {string} */ prefix; /** * The tab * @type {HTMLSpanElement} */ #tab; /** * The page * @type {HTMLDivElement} */ #page; /** * Constructs a tab plane. * * @param editor {DescriptionEditor} the parent description editor */ constructor(editor) { this.editor = editor; this.prefix = `${this.editor.prefix}-${this.tabId()}`; this.#tab = document.getElementById(`${this.prefix}-tab`); this.#page = document.getElementById(`${this.prefix}-page`); this.#tab.onclick = () => this.switchToMe(); } /** * The tab ID * * @return {string} * @abstract */ tabId() { throw new Error("Method not implemented.") }; /** * Resets the tab plane input. * * @abstract */ reset() { throw new Error("Method not implemented."); } /** * Populates the tab plane with the description input. * * @return {boolean} true if the description 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.editor.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.editor.currentTab = this; } } /** * A tag plane with selectable tags. * * @abstract * @private */ class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane { /** * 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 editor {DescriptionEditor} the parent description editor * @override */ constructor(editor) { super(editor); 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(); this.tag.onchange = () => { this.onTagChange(); this.updateDescription(); }; } /** * The callback when the tag input is changed * */ onTagChange() { this.tag.value = this.tag.value.trim(); let isMatched = false; 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.editor.updateCurrentSuggestedAccounts(tagButton); isMatched = true; } else { tagButton.classList.remove("btn-primary"); tagButton.classList.add("btn-outline-primary"); } } if (!isMatched) { this.editor.clearSuggestedAccounts(); } this.validateTag(); } /** * Updates the description according to the input in the tab plane. * * @abstract */ updateDescription() { throw new Error("Method not implemented."); } /** * Switches to the tab plane. * */ switchToMe() { super.switchToMe(); for (const tagButton of this.tagButtons) { if (tagButton.classList.contains("btn-primary")) { this.editor.updateCurrentSuggestedAccounts(tagButton); return; } } this.editor.clearSuggestedAccounts(); } /** * Initializes the tag buttons. * */ initializeTagButtons() { for (const tagButton of this.tagButtons) { tagButton.onclick = () => { for (const otherButton of this.tagButtons) { otherButton.classList.remove("btn-primary"); otherButton.classList.add("btn-outline-primary"); } tagButton.classList.remove("btn-outline-primary"); tagButton.classList.add("btn-primary"); this.tag.value = tagButton.dataset.value; this.editor.updateCurrentSuggestedAccounts(tagButton); this.updateDescription(); }; } } /** * 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 DescriptionEditorGeneralTagTab extends DescriptionEditorTagTabPlane { /** * The tab ID * * @return {string} * @abstract */ tabId() { return "general"; }; /** * Updates the description according to the input in the tab plane. * * @override */ updateDescription() { const pos = this.editor.description.indexOf("—"); const prefix = this.tag.value === ""? "": `${this.tag.value}—`; if (pos === -1) { this.editor.description = `${prefix}${this.editor.description}`; } else { this.editor.description = `${prefix}${this.editor.description.substring(pos + 1)}`; } } /** * Populates the tab plane with the description input. * * @return {boolean} true if the description input matches this tab, or false otherwise * @override */ populate() { const found = this.editor.description.match(/^([^—]+)—/); if (found === null) { return false; } if (this.tag.value !== found[1]) { this.tag.value = found[1]; this.onTagChange(); } 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 DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane { /** * 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 editor {DescriptionEditor} the parent description editor * @override */ constructor(editor) { super(editor); 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`)); this.#from.onchange = () => { this.#from.value = this.#from.value.trim(); this.updateDescription(); this.validateFrom(); }; for (const directionButton of this.#directionButtons) { directionButton.onclick = () => { for (const otherButton of this.#directionButtons) { otherButton.classList.remove("btn-primary"); otherButton.classList.add("btn-outline-primary"); } directionButton.classList.remove("btn-outline-primary"); directionButton.classList.add("btn-primary"); this.updateDescription(); }; } this.#to.onchange = () => { this.#to.value = this.#to.value.trim(); this.updateDescription(); this.validateTo(); }; } /** * The tab ID * * @return {string} * @abstract */ tabId() { return "travel"; }; /** * Updates the description according to the input in the tab plane. * * @override */ updateDescription() { let direction; for (const directionButton of this.#directionButtons) { if (directionButton.classList.contains("btn-primary")) { direction = directionButton.dataset.arrow; break; } } this.editor.description = `${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 description input. * * @return {boolean} true if the description input matches this tab, or false otherwise * @override */ populate() { const found = this.editor.description.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/); if (found === null) { return false; } if (this.tag.value !== found[1]) { this.tag.value = found[1]; this.onTagChange(); } 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]; 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 DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane { /** * 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 editor {DescriptionEditor} the parent description editor * @override */ constructor(editor) { super(editor); 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`) this.#route.onchange = () => { this.#route.value = this.#route.value.trim(); this.updateDescription(); this.validateRoute(); }; this.#from.onchange = () => { this.#from.value = this.#from.value.trim(); this.updateDescription(); this.validateFrom(); }; this.#to.onchange = () => { this.#to.value = this.#to.value.trim(); this.updateDescription(); this.validateTo(); }; } /** * The tab ID * * @return {string} * @abstract */ tabId() { return "bus"; }; /** * Updates the description according to the input in the tab plane. * * @override */ updateDescription() { this.editor.description = `${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 description input. * * @return {boolean} true if the description input matches this tab, or false otherwise * @override */ populate() { const found = this.editor.description.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/); if (found === null) { return false; } if (this.tag.value !== found[1]) { this.tag.value = found[1]; this.onTagChange(); } this.#route.value = found[2]; this.#from.value = found[3]; this.#to.value = found[4]; 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 recurring transaction tab plane. * * @private */ class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane { /** * The month names * @type {string[]} */ #monthNames; /** * The buttons of the recurring items * @type {HTMLButtonElement[]} */ #itemButtons; /** * Constructs a tab plane. * * @param editor {DescriptionEditor} the parent description editor * @override */ constructor(editor) { super(editor); this.#monthNames = [ "", A_("January"), A_("February"), A_("March"), A_("April"), A_("May"), A_("June"), A_("July"), A_("August"), A_("September"), A_("October"), A_("November"), A_("December"), ]; // noinspection JSValidateTypes this.#itemButtons = Array.from(document.getElementsByClassName(`${this.prefix}-item`)); for (const itemButton of this.#itemButtons) { itemButton.onclick = () => { this.reset(); itemButton.classList.add("btn-primary"); itemButton.classList.remove("btn-outline-primary"); this.editor.description = this.#getDescription(itemButton); this.editor.updateCurrentSuggestedAccounts(itemButton); }; } } /** * Returns the description for a recurring item. * * @param itemButton {HTMLButtonElement} the recurring item * @return {string} the description of the recurring item */ #getDescription(itemButton) { const today = new Date(this.editor.lineItemEditor.form.date); const thisMonth = today.getMonth() + 1; const lastMonth = (thisMonth + 10) % 12 + 1; const lastBimonthlyFrom = ((thisMonth + thisMonth % 2 + 8) % 12 + 1); const lastBimonthlyTo = ((thisMonth + thisMonth % 2 + 9) % 12 + 1); return itemButton.dataset.template .replaceAll("{this_month_number}", String(thisMonth)) .replaceAll("{this_month_name}", this.#monthNames[thisMonth]) .replaceAll("{last_month_number}", String(lastMonth)) .replaceAll("{last_month_name}", this.#monthNames[lastMonth]) .replaceAll("{last_bimonthly_number}", `${String(lastBimonthlyFrom)}–${String(lastBimonthlyTo)}`) .replaceAll("{last_bimonthly_name}", `${this.#monthNames[lastBimonthlyFrom]}–${this.#monthNames[lastBimonthlyTo]}`); } /** * The tab ID * * @return {string} * @abstract */ tabId() { return "recurring"; }; /** * Resets the tab plane input. * * @override */ reset() { for (const itemButton of this.#itemButtons) { itemButton.classList.remove("btn-primary"); itemButton.classList.add("btn-outline-primary"); } } /** * Populates the tab plane with the description input. * * @return {boolean} true if the description input matches this tab, or false otherwise * @override */ populate() { for (const itemButton of this.#itemButtons) { if (this.#getDescription(itemButton) === this.editor.description) { itemButton.classList.add("btn-primary"); itemButton.classList.remove("btn-outline-primary"); this.switchToMe(); return true; } } return false; } /** * Switches to the tab plane. * */ switchToMe() { super.switchToMe(); for (const itemButton of this.#itemButtons) { if (itemButton.classList.contains("btn-primary")) { this.editor.updateCurrentSuggestedAccounts(itemButton); return; } } this.editor.clearSuggestedAccounts(); } /** * Validates the input in the tab plane. * * @return {boolean} true if valid, or false otherwise * @override */ validate() { return true; } } /** * The annotation tab plane. * * @private */ class DescriptionEditorAnnotationTab extends DescriptionEditorTabPlane { /** * Constructs a tab plane. * * @param editor {DescriptionEditor} the parent description editor * @override */ constructor(editor) { super(editor); this.editor.number.onchange = () => this.updateDescription(); this.editor.note.onchange = () => { this.editor.note.value = this.editor.note.value.trim(); this.updateDescription(); }; } /** * The tab ID * * @return {string} * @abstract */ tabId() { return "annotation"; }; /** * Updates the description according to the input in the tab plane. * * @override */ updateDescription() { const found = this.editor.description.match(/^(.*?)(?:[*×]\d+)?(?:\([^()]+\))?$/); if (found !== null) { this.editor.description = found[1]; } if (parseInt(this.editor.number.value) > 1) { this.editor.description = `${this.editor.description}×${this.editor.number.value}`; } if (this.editor.note.value !== "") { this.editor.description = `${this.editor.description}(${this.editor.note.value})`; } } /** * Resets the tab plane input. * * @override */ reset() { this.editor.number.value = ""; this.editor.note.value = ""; } /** * Populates the tab plane with the description input. * * @return {boolean} true if the description input matches this tab, or false otherwise * @override */ populate() { const found = this.editor.description.match(/^(.*?)(?:[*×](\d+))?(?:\(([^()]+)\))?$/); this.editor.description = found[1]; if (found[2] === undefined || parseInt(found[2]) === 1) { this.editor.number.value = ""; } else { this.editor.number.value = found[2]; this.editor.description = `${this.editor.description}×${this.editor.number.value}`; } if (found[3] === undefined) { this.editor.note.value = ""; } else { this.editor.note.value = found[3]; this.editor.description = `${this.editor.description}(${this.editor.note.value})`; } return true; } /** * Validates the input in the tab plane. * * @return {boolean} true if valid, or false otherwise * @override */ validate() { return true; } }