1196 lines
32 KiB
JavaScript
1196 lines
32 KiB
JavaScript
/* The Mia! Accounting Flask 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 {TabPlane}
|
||
*/
|
||
currentTab;
|
||
|
||
/**
|
||
* The description input
|
||
* @type {HTMLInputElement}
|
||
*/
|
||
description;
|
||
|
||
/**
|
||
* The button to the original line item selector
|
||
* @type {HTMLButtonElement}
|
||
*/
|
||
#offsetButton;
|
||
|
||
/**
|
||
* The number input
|
||
* @type {HTMLInputElement}
|
||
*/
|
||
number;
|
||
|
||
/**
|
||
* The note
|
||
* @type {HTMLInputElement}
|
||
*/
|
||
note;
|
||
|
||
/**
|
||
* The account buttons
|
||
* @type {HTMLButtonElement[]}
|
||
*/
|
||
#accountButtons;
|
||
|
||
/**
|
||
* The selected account button
|
||
* @type {HTMLButtonElement|null}
|
||
*/
|
||
#selectedAccount = null;
|
||
|
||
/**
|
||
* The tab planes
|
||
* @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, recurring: RecurringTransactionTab, annotation: AnnotationTab}}
|
||
*/
|
||
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.description = 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");
|
||
// noinspection JSValidateTypes
|
||
this.#accountButtons = Array.from(document.getElementsByClassName(this.prefix + "-account"));
|
||
|
||
for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RecurringTransactionTab, AnnotationTab]) {
|
||
const tab = new cls(this);
|
||
this.tabPlanes[tab.tabId()] = tab;
|
||
}
|
||
this.currentTab = this.tabPlanes.general;
|
||
this.#initializeSuggestedAccounts();
|
||
this.description.onchange = () => this.#onDescriptionChange();
|
||
this.#offsetButton.onclick = () => this.lineItemEditor.originalLineItemSelector.onOpen();
|
||
this.#form.onsubmit = () => {
|
||
if (this.currentTab.validate()) {
|
||
this.#submit();
|
||
}
|
||
return false;
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The callback when the description input is changed.
|
||
*
|
||
*/
|
||
#onDescriptionChange() {
|
||
this.description.value = this.description.value.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();
|
||
}
|
||
|
||
/**
|
||
* Filters the suggested accounts.
|
||
*
|
||
* @param tagButton {HTMLButtonElement} the tag button
|
||
*/
|
||
filterSuggestedAccounts(tagButton) {
|
||
this.clearSuggestedAccounts();
|
||
const suggested = JSON.parse(tagButton.dataset.accounts);
|
||
for (const accountButton of this.#accountButtons) {
|
||
if (suggested.includes(accountButton.dataset.code)) {
|
||
accountButton.classList.remove("d-none");
|
||
if (accountButton.dataset.code === suggested[0]) {
|
||
this.#selectAccount(accountButton);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clears the suggested accounts.
|
||
*
|
||
*/
|
||
clearSuggestedAccounts() {
|
||
for (const accountButton of this.#accountButtons) {
|
||
accountButton.classList.add("d-none");
|
||
}
|
||
this.#selectAccount(null);
|
||
}
|
||
|
||
/**
|
||
* Initializes the suggested accounts.
|
||
*
|
||
*/
|
||
#initializeSuggestedAccounts() {
|
||
for (const accountButton of this.#accountButtons) {
|
||
accountButton.onclick = () => this.#selectAccount(accountButton);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Select a suggested account.
|
||
*
|
||
* @param selectedAccountButton {HTMLButtonElement|null} the account button, or null to deselect the account
|
||
*/
|
||
#selectAccount(selectedAccountButton) {
|
||
for (const accountButton of this.#accountButtons) {
|
||
accountButton.classList.remove("btn-primary");
|
||
accountButton.classList.add("btn-outline-primary");
|
||
}
|
||
if (selectedAccountButton !== null) {
|
||
selectedAccountButton.classList.remove("btn-outline-primary");
|
||
selectedAccountButton.classList.add("btn-primary");
|
||
}
|
||
this.#selectedAccount = selectedAccountButton;
|
||
}
|
||
|
||
/**
|
||
* Submits the description.
|
||
*
|
||
*/
|
||
#submit() {
|
||
bootstrap.Modal.getOrCreateInstance(this.#modal).hide();
|
||
if (this.#selectedAccount !== null) {
|
||
this.lineItemEditor.saveDescriptionWithAccount(this.description.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text, this.#selectedAccount.classList.contains("accounting-account-is-need-offset"));
|
||
} else {
|
||
this.lineItemEditor.saveDescription(this.description.value);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The callback when the description editor is shown.
|
||
*
|
||
*/
|
||
onOpen() {
|
||
this.#reset();
|
||
this.description.value = this.lineItemEditor.description === null? "": this.lineItemEditor.description;
|
||
this.#onDescriptionChange();
|
||
}
|
||
|
||
/**
|
||
* Resets the description editor.
|
||
*
|
||
*/
|
||
#reset() {
|
||
this.description.value = "";
|
||
for (const tabPlane of Object.values(this.tabPlanes)) {
|
||
tabPlane.reset();
|
||
}
|
||
this.tabPlanes.general.switchToMe();
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A tab plane.
|
||
*
|
||
* @abstract
|
||
* @private
|
||
*/
|
||
class TabPlane {
|
||
|
||
/**
|
||
* 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 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 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.filterSuggestedAccounts(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.filterSuggestedAccounts(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.filterSuggestedAccounts(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 GeneralTagTab extends TagTabPlane {
|
||
|
||
/**
|
||
* 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.value.indexOf("—");
|
||
const prefix = this.tag.value === ""? "": this.tag.value + "—";
|
||
if (pos === -1) {
|
||
this.editor.description.value = prefix + this.editor.description.value;
|
||
} else {
|
||
this.editor.description.value = prefix + this.editor.description.value.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.value.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 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 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.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 description input.
|
||
*
|
||
* @return {boolean} true if the description input matches this tab, or false otherwise
|
||
* @override
|
||
*/
|
||
populate() {
|
||
const found = this.editor.description.value.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 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 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.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 description input.
|
||
*
|
||
* @return {boolean} true if the description input matches this tab, or false otherwise
|
||
* @override
|
||
*/
|
||
populate() {
|
||
const found = this.editor.description.value.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 RecurringTransactionTab extends TabPlane {
|
||
|
||
/**
|
||
* The month names
|
||
* @type {string[]}
|
||
*/
|
||
#monthNames;
|
||
|
||
/**
|
||
* The buttons of the recurring items
|
||
* @type {HTMLButtonElement[]}
|
||
*/
|
||
#itemButtons;
|
||
|
||
// noinspection JSValidateTypes
|
||
/**
|
||
* 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.value = this.#getDescription(itemButton);
|
||
this.editor.filterSuggestedAccounts(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.value) {
|
||
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.filterSuggestedAccounts(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 AnnotationTab extends TabPlane {
|
||
|
||
/**
|
||
* 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.value.match(/^(.*?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
|
||
if (found !== null) {
|
||
this.editor.description.value = found[1];
|
||
}
|
||
if (parseInt(this.editor.number.value) > 1) {
|
||
this.editor.description.value = this.editor.description.value + "×" + this.editor.number.value;
|
||
}
|
||
if (this.editor.note.value !== "") {
|
||
this.editor.description.value = this.editor.description.value + "(" + 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.value.match(/^(.*?)(?:[*×](\d+))?(?:\(([^()]+)\))?$/);
|
||
this.editor.description.value = found[1];
|
||
if (found[2] === undefined || parseInt(found[2]) === 1) {
|
||
this.editor.number.value = "";
|
||
} else {
|
||
this.editor.number.value = found[2];
|
||
this.editor.description.value = this.editor.description.value + "×" + this.editor.number.value;
|
||
}
|
||
if (found[3] === undefined) {
|
||
this.editor.note.value = "";
|
||
} else {
|
||
this.editor.note.value = found[3];
|
||
this.editor.description.value = this.editor.description.value + "(" + 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;
|
||
}
|
||
}
|