Added the option management, and moved the configuration of the default currency, the default account for the income and expenses log, and the recurring expenses and incomes to the options.

This commit is contained in:
2023-03-22 15:34:28 +08:00
parent fa3cdace7f
commit 761d5a5824
24 changed files with 1919 additions and 79 deletions

View File

@ -331,6 +331,14 @@ a.accounting-report-table-row {
margin-top: 0.2rem;
}
/* The illustration of the description template for the recurring transactions */
.accounting-recurring-description-template-illustration p {
margin: 0.2rem 0;
}
.accounting-recurring-description-template-illustration ul {
margin: 0;
}
/* The Material Design text field (floating form control in Bootstrap) */
.accounting-material-text-field {
position: relative;

View File

@ -0,0 +1,941 @@
/* The Mia! Accounting Flask Project
* account-form.js: The JavaScript for the account form
*/
/* Copyright (c) 2023 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/3/22
*/
"use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
OptionForm.initialize();
});
/**
* Escapes the HTML special characters and returns.
*
* @param s {string} the original string
* @returns {string} the string with HTML special character escaped
* @private
*/
function escapeHtml(s) {
return String(s)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;");
}
/**
* The option form.
*
* @private
*/
class OptionForm {
/**
* The form element
* @type {HTMLFormElement}
*/
#element;
/**
* The default currency
* @type {HTMLSelectElement}
*/
#defaultCurrency;
/**
* The error message for the default currency
* @type {HTMLDivElement}
*/
#defaultCurrencyError;
/**
* The default account for the income and expenses log
* @type {HTMLSelectElement}
*/
#defaultIeAccount;
/**
* The error message for the default account for the income and expenses log
* @type {HTMLDivElement}
*/
#defaultIeAccountError;
/**
* The recurring item template
* @type {string}
*/
recurringItemTemplate;
/**
* The recurring expenses or incomes sub-form
* @type {{expense: RecurringExpenseIncomeSubForm, income: RecurringExpenseIncomeSubForm}}
*/
#expenseIncome;
/**
* Constructs the option form.
*
*/
constructor() {
this.#element = document.getElementById("accounting-form");
this.#defaultCurrency = document.getElementById("accounting-default-currency");
this.#defaultCurrencyError = document.getElementById("accounting-default-currency-error");
this.#defaultIeAccount = document.getElementById("accounting-default-ie-account");
this.#defaultIeAccountError = document.getElementById("accounting-default-ie-account-error");
this.recurringItemTemplate = this.#element.dataset.recurringItemTemplate;
this.#expenseIncome = RecurringExpenseIncomeSubForm.getInstances(this);
this.#defaultCurrency.onchange = () => this.#validateDefaultCurrency();
this.#defaultIeAccount.onchange = () => this.#validateDefaultIeAccount();
this.#element.onsubmit = () => {
return this.#validate();
};
}
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validate() {
let isValid = true;
isValid = this.#validateDefaultCurrency() && isValid;
isValid = this.#validateDefaultIeAccount() && isValid;
isValid = this.#expenseIncome.expense.validate() && isValid;
isValid = this.#expenseIncome.income.validate() && isValid;
return isValid;
}
/**
* Validates the default currency.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validateDefaultCurrency() {
if (this.#defaultCurrency.value === "") {
this.#defaultCurrency.classList.add("is-invalid");
this.#defaultCurrencyError.innerText = A_("Please select the default currency.");
return false;
}
this.#defaultCurrency.classList.remove("is-invalid");
this.#defaultCurrencyError.innerText = "";
return true;
}
/**
* Validates the default account for the income and expenses log.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validateDefaultIeAccount() {
if (this.#defaultIeAccount.value === "") {
this.#defaultIeAccount.classList.add("is-invalid");
this.#defaultIeAccountError.innerText = A_("Please select the default account for the income and expenses log.");
return false;
}
this.#defaultIeAccount.classList.remove("is-invalid");
this.#defaultIeAccountError.innerText = "";
return true;
}
/**
* The option form
* @type {OptionForm}
*/
static #form;
/**
* Initializes the option form.
*
*/
static initialize() {
this.#form = new OptionForm();
}
}
/**
* The recurring expenses or incomes sub-form.
*
*/
class RecurringExpenseIncomeSubForm {
/**
* The option form
* @type {OptionForm}
*/
#form;
/**
* Either "expense" or "income"
* @type {string}
*/
expenseIncome;
/**
* The recurring item editor
* @type {RecurringItemEditor}
*/
editor;
/**
* The prefix of HTML ID and class
* @type {string}
*/
#prefix;
/**
* The recurring items list
* @type {HTMLUListElement}
*/
#itemList;
/**
* The recurring items
* @type {RecurringItemSubForm[]}
*/
#items;
/**
* The button to add a new recurring item
* @type {HTMLButtonElement}
*/
#addButton;
/**
* Constructs the recurring expenses or incomes.
*
* @param form {OptionForm} the option form
* @param expenseIncome {string} either "expense" or "income"
*/
constructor(form, expenseIncome) {
this.#form = form;
this.expenseIncome = expenseIncome;
this.editor = new RecurringItemEditor(this);
this.#prefix = "accounting-recurring-" + expenseIncome;
this.#itemList = document.getElementById(this.#prefix + "-list");
this.#items = Array.from(document.getElementsByClassName(this.#prefix + "-item")).map((element) => new RecurringItemSubForm(this, element));
this.#addButton = document.getElementById(this.#prefix + "-add");
this.#addButton.onclick = () => this.editor.onAddNew();
}
/**
* Adds a recurring item.
*
* @return {RecurringItemSubForm} the recurring item
*/
addItem() {
const newIndex = 1 + (this.#items.length === 0? 0: Math.max(...this.#items.map((item) => item.itemIndex)));
const html = this.#form.recurringItemTemplate
.replaceAll("EXPENSE_INCOME", escapeHtml(this.expenseIncome))
.replaceAll("ITEM_INDEX", escapeHtml(String(newIndex)));
this.#itemList.insertAdjacentHTML("beforeend", html);
const element = document.getElementById(this.#prefix + "-" + String(newIndex))
const item = new RecurringItemSubForm(this, element);
this.#items.push(item);
return item;
}
/**
* Deletes a recurring item sub-form.
*
* @param item {RecurringItemSubForm} the recurring item sub-form to delete
*/
deleteItem(item) {
const index = this.#items.indexOf(item);
this.#items.splice(index, 1);
}
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
*/
validate() {
let isValid = true;
for (const item of this.#items) {
isValid = item.validate() && isValid;
}
return isValid;
}
/**
* Returns the recurring expenses or incomes sub-form instances.
*
* @param form {OptionForm} the option form
* @return {{expense: RecurringExpenseIncomeSubForm, income: RecurringExpenseIncomeSubForm}}
*/
static getInstances(form) {
const subForms = {};
for (const expenseIncome of ["expense", "income"]) {
subForms[expenseIncome] = new RecurringExpenseIncomeSubForm(form, expenseIncome);
}
return subForms;
}
}
/**
* A recurring item sub-form.
*
*/
class RecurringItemSubForm {
/**
* The recurring expenses or incomes sub-form
* @type {RecurringExpenseIncomeSubForm}
*/
#expenseIncomeSubForm;
/**
* The element
* @type {HTMLLIElement}
*/
#element;
/**
* The item index
* @type {number}
*/
itemIndex;
/**
* The control
* @type {HTMLDivElement}
*/
#control;
/**
* The error message
* @type {HTMLDivElement}
*/
#error;
/**
* The name input
* @type {HTMLInputElement}
*/
#name;
/**
* The text display of the name
* @type {HTMLDivElement}
*/
#nameText;
/**
* The account code input
* @type {HTMLInputElement}
*/
#accountCode;
/**
* The text display of the account
* @type {HTMLDivElement}
*/
#accountText;
/**
* The description template input
* @type {HTMLInputElement}
*/
#descriptionTemplate;
/**
* The text display of the description template
* @type {HTMLDivElement}
*/
#descriptionTemplateText;
/**
* The button to delete this recurring item
* @type {HTMLButtonElement}
*/
deleteButton;
/**
* Constructs a recurring item sub-form.
*
* @param expenseIncomeSubForm {RecurringExpenseIncomeSubForm} the recurring expenses or incomes sub-form
* @param element {HTMLLIElement} the element
*/
constructor(expenseIncomeSubForm, element) {
this.#expenseIncomeSubForm = expenseIncomeSubForm
this.#element = element;
this.itemIndex = parseInt(element.dataset.itemIndex);
const prefix = "accounting-recurring-" + expenseIncomeSubForm.expenseIncome + "-" + element.dataset.itemIndex;
this.#control = document.getElementById(prefix + "-control");
this.#error = document.getElementById(prefix + "-error");
this.#name = document.getElementById(prefix + "-name");
this.#nameText = document.getElementById(prefix + "-name-text");
this.#accountCode = document.getElementById(prefix + "-account-code");
this.#accountText = document.getElementById(prefix + "-account-text");
this.#descriptionTemplate = document.getElementById(prefix + "-description-template");
this.#descriptionTemplateText = document.getElementById(prefix + "-description-template-text");
this.deleteButton = document.getElementById(prefix + "-delete");
this.#control.onclick = () => this.#expenseIncomeSubForm.editor.onEdit(this);
this.deleteButton.onclick = () => {
this.#element.parentElement.removeChild(this.#element);
this.#expenseIncomeSubForm.deleteItem(this);
};
}
/**
* Returns the name.
*
* @return {string|null} the name
*/
getName() {
return this.#name.value === ""? null: this.#name.value;
}
/**
* Returns the account code.
*
* @return {string|null} the account code
*/
getAccountCode() {
return this.#accountCode.value === ""? null: this.#accountCode.value;
}
/**
* Returns the account text.
*
* @return {string|null} the account text
*/
getAccountText() {
return this.#accountCode.dataset.text === ""? null: this.#accountCode.dataset.text;
}
/**
* Returns the description template.
*
* @return {string|null} the description template
*/
getDescriptionTemplate() {
return this.#descriptionTemplate.value === ""? null: this.#descriptionTemplate.value;
}
/**
* Saves the recurring item from the recurring item editor.
*
* @param editor {RecurringItemEditor} the recurring item editor
*/
save(editor) {
this.#name.value = editor.getName() === null? "": editor.getName();
this.#nameText.innerText = this.#name.value;
this.#accountCode.value = editor.accountCode;
this.#accountCode.dataset.text = editor.accountText;
this.#accountText.innerText = editor.accountText;
this.#descriptionTemplate.value = editor.getDescriptionTemplate() === null? "": editor.getDescriptionTemplate();
this.#descriptionTemplateText.innerText = this.#descriptionTemplate.value;
this.validate();
}
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
*/
validate() {
if (this.#name.value === "") {
this.#control.classList.add("is-invalid");
this.#error.innerText = A_("Please fill in the name.");
return false;
}
if (this.#accountCode.value === "") {
this.#control.classList.add("is-invalid");
this.#error.innerText = A_("Please select the account.");
return false;
}
if (this.#descriptionTemplate.value === "") {
this.#control.classList.add("is-invalid");
this.#error.innerText = A_("Please fill in the description template.");
return false;
}
this.#control.classList.remove("is-invalid");
this.#error.innerText = "";
return true;
}
}
/**
* The recurring item editor.
*
*/
class RecurringItemEditor {
/**
* The recurring expense or income sub-form
* @type {RecurringExpenseIncomeSubForm}
*/
#subForm;
/**
* Either "expense" or "income"
* @type {string}
*/
expenseIncome;
/**
* The form
* @type {HTMLFormElement}
*/
#form;
/**
* The modal
* @type {HTMLDivElement}
*/
#modal;
/**
* The name
* @type {HTMLInputElement}
*/
#name;
/**
* The error message of the name
* @type {HTMLDivElement}
*/
#nameError;
/**
* The control of the account
* @type {HTMLDivElement}
*/
#accountControl;
/**
* The text display of the account
* @type {HTMLDivElement}
*/
#accountContainer;
/**
* The error message of the account
* @type {HTMLDivElement}
*/
#accountError;
/**
* The description template
* @type {HTMLInputElement}
*/
#descriptionTemplate;
/**
* The error message of the description template
* @type {HTMLDivElement}
*/
#descriptionTemplateError;
/**
* The account selector
* @type {RecurringAccountSelector}
*/
#accountSelector;
/**
* The account code
* @type {string|null}
*/
accountCode = null;
/**
* The account text
* @type {string|null}
*/
accountText = null;
/**
* The recurring item sub-form
* @type {RecurringItemSubForm|null}
*/
#item = null;
/**
* Constructs the recurring item editor.
*
* @param subForm {RecurringExpenseIncomeSubForm} the recurring expense or income sub-form
*/
constructor(subForm) {
this.#subForm = subForm;
this.expenseIncome = subForm.expenseIncome;
const prefix = "accounting-recurring-item-editor-" + subForm.expenseIncome;
this.#form = document.getElementById(prefix);
this.#modal = document.getElementById(prefix + "-modal");
this.#name = document.getElementById(prefix + "-name");
this.#nameError = document.getElementById(prefix + "-name-error");
this.#accountControl = document.getElementById(prefix + "-account-control");
this.#accountContainer = document.getElementById(prefix + "-account");
this.#accountError = document.getElementById(prefix + "-account-error");
this.#descriptionTemplate = document.getElementById(prefix + "-description-template");
this.#descriptionTemplateError = document.getElementById(prefix + "-description-template-error");
this.#accountSelector = new RecurringAccountSelector(this);
this.#name.onchange = () => this.#validateName();
this.#accountControl.onclick = () => this.#accountSelector.clear();
this.#descriptionTemplate.onchange = () => this.#validateDescriptionTemplate();
this.#form.onsubmit = () => {
if (this.#validate()) {
if (this.#item === null) {
this.#item = this.#subForm.addItem();
}
this.#item.save(this);
bootstrap.Modal.getInstance(this.#modal).hide();
}
return false;
};
}
/**
* Returns the name.
*
* @return {string|null} the name
*/
getName() {
return this.#name.value === ""? null: this.#name.value;
}
/**
* Returns the description template.
*
* @return {string|null} the description template
*/
getDescriptionTemplate() {
return this.#descriptionTemplate.value === ""? null: this.#descriptionTemplate.value;
}
/**
* Saves the selected account.
*
* @param account {RecurringAccount} the selected account
*/
saveAccount(account) {
this.accountCode = account.code;
this.accountText = account.text;
this.#accountControl.classList.add("accounting-not-empty");
this.#accountContainer.innerText = account.text;
this.#validateAccount();
}
/**
* Clears account.
*
*/
clearAccount() {
this.accountCode = null;
this.accountText = null;
this.#accountControl.classList.remove("accounting-not-empty");
this.#accountContainer.innerText = "";
this.#validateAccount()
}
/**
* The callback when adding a new recurring item.
*
*/
onAddNew() {
this.#item = null;
this.#name.value = "";
this.#name.classList.remove("is-invalid");
this.#nameError.innerText = "";
this.accountCode = null;
this.accountText = null;
this.#accountControl.classList.remove("accounting-not-empty");
this.#accountControl.classList.remove("is-invalid");
this.#accountContainer.innerText = "";
this.#accountError.innerText = "";
this.#descriptionTemplate.value = "";
this.#descriptionTemplate.classList.remove("is-invalid");
this.#descriptionTemplateError.innerText = "";
}
/**
* The callback when editing a recurring item.
*
* @param item {RecurringItemSubForm} the recurring item to edit
*/
onEdit(item) {
this.#item = item;
this.#name.value = item.getName() === null? "": item.getName();
this.accountCode = item.getAccountCode();
this.accountText = item.getAccountText();
if (this.accountText === null) {
this.#accountControl.classList.remove("accounting-not-empty");
} else {
this.#accountControl.classList.add("accounting-not-empty");
}
this.#accountContainer.innerText = item.getAccountText() == null? "": item.getAccountText();
this.#descriptionTemplate.value = item.getDescriptionTemplate() === null? "": item.getDescriptionTemplate();
this.#validate();
}
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validate() {
let isValid = true;
isValid = this.#validateName() && isValid;
isValid = this.#validateAccount() && isValid;
isValid = this.#validateDescriptionTemplate() && isValid;
return isValid;
}
/**
* Validates the name.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validateName() {
this.#name.value = this.#name.value.trim();
if (this.#name.value === "") {
this.#name.classList.add("is-invalid");
this.#nameError.innerText = A_("Please fill in the name.");
return false;
}
this.#name.classList.remove("is-invalid");
this.#nameError.innerText = "";
return true;
}
/**
* Validates the account.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validateAccount() {
if (this.accountCode === null) {
this.#accountControl.classList.add("is-invalid");
this.#accountError.innerText = A_("Please select the account.");
return false;
}
this.#accountControl.classList.remove("is-invalid");
this.#accountError.innerText = "";
return true;
}
/**
* Validates the description template.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validateDescriptionTemplate() {
this.#descriptionTemplate.value = this.#descriptionTemplate.value.trim();
if (this.#descriptionTemplate.value === "") {
this.#descriptionTemplate.classList.add("is-invalid");
this.#descriptionTemplateError.innerText = A_("Please fill in the description template.");
return false;
}
this.#descriptionTemplate.classList.remove("is-invalid");
this.#descriptionTemplateError.innerText = "";
return true;
}
}
/**
* The account selector for the recurring item editor.
*
*/
class RecurringAccountSelector {
/**
* The recurring item editor
* @type {RecurringItemEditor}
*/
editor;
/**
* Either "expense" or "income"
* @type {string}
*/
#expenseIncome;
/**
* The query input
* @type {HTMLInputElement}
*/
#query;
/**
* The error message when the query has no result
* @type {HTMLParagraphElement}
*/
#queryNoResult;
/**
* The option list
* @type {HTMLUListElement}
*/
#optionList;
/**
* The account options
* @type {RecurringAccount[]}
*/
#options;
/**
* The button to clear the account
* @type {HTMLButtonElement}
*/
#clearButton;
/**
* Constructs the account selector for the recurring item editor.
*
* @param editor {RecurringItemEditor} the recurring item editor
*/
constructor(editor) {
this.editor = editor;
this.#expenseIncome = editor.expenseIncome;
const prefix = "accounting-recurring-accounting-selector-" + editor.expenseIncome;
this.#query = document.getElementById(prefix + "-query");
this.#queryNoResult = document.getElementById(prefix + "-option-no-result");
this.#optionList = document.getElementById(prefix + "-option-list");
this.#options = Array.from(document.getElementsByClassName(prefix + "-option")).map((element) => new RecurringAccount(this, element));
this.#clearButton = document.getElementById(prefix + "-clear");
this.#query.oninput = () => this.#filterOptions();
this.#clearButton.onclick = () => this.editor.clearAccount();
}
/**
* Clears the filter.
*
*/
clear() {
this.#query.value = "";
this.#filterOptions();
}
/**
* Filters the options.
*
*/
#filterOptions() {
let hasAnyMatched = false;
for (const option of this.#options) {
if (option.isMatches(this.#query.value)) {
option.setShown(true);
hasAnyMatched = true;
} else {
option.setShown(false);
}
}
if (!hasAnyMatched) {
this.#optionList.classList.add("d-none");
this.#queryNoResult.classList.remove("d-none");
} else {
this.#optionList.classList.remove("d-none");
this.#queryNoResult.classList.add("d-none");
}
}
}
/**
* An account in the account selector for the recurring item editor.
*
*/
class RecurringAccount {
/**
* The account selector for the recurring item editor
* @type {RecurringAccountSelector}
*/
#selector;
/**
* The element
* @type {HTMLLIElement}
*/
#element;
/**
* The account code
* @type {string}
*/
code;
/**
* The account text
* @type {string}
*/
text;
/**
* The values to query against
* @type {string[]}
*/
#queryValues;
/**
* Constructs the account in the account selector for the recurring item editor.
*
* @param selector {RecurringAccountSelector} the account selector
* @param element {HTMLLIElement} the element
*/
constructor(selector, element) {
this.#selector = selector;
this.#element = element;
this.code = element.dataset.code;
this.text = element.dataset.text;
this.#queryValues = JSON.parse(element.dataset.queryValues);
this.#element.onclick = () => this.#selector.editor.saveAccount(this);
}
/**
* Returns whether the account matches the query.
*
* @param query {string} the query term
* @return {boolean} true if the option matches, or false otherwise
*/
isMatches(query) {
if (query === "") {
return true;
}
for (const queryValue of this.#queryValues) {
if (queryValue.toLowerCase().includes(query.toLowerCase())) {
return true;
}
}
return false;
}
/**
* 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");
}
}
}