Compare commits
73 Commits
Author | SHA1 | Date | |
---|---|---|---|
a17395b43e | |||
17c8d9d1a9 | |||
fa94cd407e | |||
9a704c8185 | |||
8286c0c6d8 | |||
f7efacad75 | |||
9263ae0274 | |||
78a9d7794c | |||
f3ae37a409 | |||
ddc1081252 | |||
202d51a032 | |||
562bc47be7 | |||
f3d43a66cc | |||
c3fc6d9a87 | |||
e1a0380628 | |||
f2a2fcdd32 | |||
ab29166f1e | |||
8033921181 | |||
08732c1e66 | |||
4adc464d3d | |||
2f9d2e36cb | |||
5bb10bf6ba | |||
06e7b6ddff | |||
20e1982984 | |||
a70720be50 | |||
cb6de08152 | |||
211821b4d7 | |||
0faca49540 | |||
14e79df571 | |||
04fbb725d2 | |||
a1d6844e52 | |||
94391b02a6 | |||
1cb8a7563e | |||
63f0f28948 | |||
3431922f12 | |||
d5a9e1af18 | |||
73f5d63f44 | |||
bf2c7bb785 | |||
93ba086548 | |||
5c4f6017b8 | |||
cb16b2f0ff | |||
d2f11e8779 | |||
4ccaf01b3c | |||
7c512b1c15 | |||
dc432da398 | |||
c8504bcbf5 | |||
c865141583 | |||
8c1ecd6eac | |||
e8e4100677 | |||
6a8773c531 | |||
30e0c7682c | |||
eb5a7bef7e | |||
8a174d8847 | |||
7459afd63a | |||
a9afc385e9 | |||
a8be739ec7 | |||
0130bc58a9 | |||
821059fa80 | |||
5b4f57d0b3 | |||
4bfac2d545 | |||
f105f0cf7b | |||
5e320729d7 | |||
7515032082 | |||
361b18e411 | |||
7d084e570e | |||
cb397910f8 | |||
5f8b0dec98 | |||
8398d1e8bb | |||
562801692a | |||
faee1e61c6 | |||
57a4177037 | |||
fa1dedf207 | |||
7ed13dc0af |
@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath('../../src/'))
|
||||
project = 'Mia! Accounting Flask'
|
||||
copyright = '2023, imacat'
|
||||
author = 'imacat'
|
||||
release = '0.9.0'
|
||||
release = '0.10.0'
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||
|
@ -17,7 +17,7 @@
|
||||
|
||||
[metadata]
|
||||
name = mia-accounting-flask
|
||||
version = 0.9.0
|
||||
version = 0.10.0
|
||||
author = imacat
|
||||
author_email = imacat@mail.imacat.idv.tw
|
||||
description = The Mia! Accounting Flask project.
|
||||
|
@ -36,12 +36,14 @@ class DescriptionAccount:
|
||||
:param account: The account.
|
||||
:param freq: The frequency of the tag with the account.
|
||||
"""
|
||||
self.account: Account = account
|
||||
self.__account: Account = account
|
||||
"""The account."""
|
||||
self.id: int = account.id
|
||||
"""The account ID."""
|
||||
self.code: str = account.code
|
||||
"""The account code."""
|
||||
self.is_need_offset: bool = account.is_need_offset
|
||||
"""Whether the journal entry line items of this account need offset."""
|
||||
self.freq: int = freq
|
||||
"""The frequency of the tag with the account."""
|
||||
|
||||
@ -50,7 +52,7 @@ class DescriptionAccount:
|
||||
|
||||
:return: The string representation of the account.
|
||||
"""
|
||||
return str(self.account)
|
||||
return str(self.__account)
|
||||
|
||||
def add_freq(self, freq: int) -> None:
|
||||
"""Adds the frequency of an account.
|
||||
|
@ -21,7 +21,6 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
import typing as t
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
@ -760,7 +759,7 @@ class JournalEntryLineItem(db.Model):
|
||||
setattr(self, "__net_balance", net_balance)
|
||||
|
||||
@property
|
||||
def query_values(self) -> tuple[list[str], list[str]]:
|
||||
def query_values(self) -> list[str]:
|
||||
"""Returns the values to be queried.
|
||||
|
||||
:return: The values to be queried.
|
||||
@ -770,19 +769,11 @@ class JournalEntryLineItem(db.Model):
|
||||
frac: Decimal = (value - whole).normalize()
|
||||
return str(whole) + str(abs(frac))[1:]
|
||||
|
||||
journal_entry_day: date = self.journal_entry.date
|
||||
description: str = "" if self.description is None else self.description
|
||||
return ([description],
|
||||
[str(journal_entry_day.year),
|
||||
"{}/{}".format(journal_entry_day.year,
|
||||
journal_entry_day.month),
|
||||
"{}/{}".format(journal_entry_day.month,
|
||||
journal_entry_day.day),
|
||||
"{}/{}/{}".format(journal_entry_day.year,
|
||||
journal_entry_day.month,
|
||||
journal_entry_day.day),
|
||||
format_amount(self.amount),
|
||||
format_amount(self.net_balance)])
|
||||
return ["{}/{}/{}".format(self.journal_entry.date.year,
|
||||
self.journal_entry.date.month,
|
||||
self.journal_entry.date.day),
|
||||
"" if self.description is None else self.description,
|
||||
format_amount(self.amount)]
|
||||
|
||||
|
||||
class Option(db.Model):
|
||||
|
@ -114,10 +114,19 @@ class AccountForm {
|
||||
};
|
||||
this.#baseControl.onclick = () => {
|
||||
this.#baseControl.classList.add("accounting-not-empty");
|
||||
this.#baseAccountSelector.onOpen(this.#baseCode.value);
|
||||
this.#baseAccountSelector.onOpen();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base code.
|
||||
*
|
||||
* @return {string|null}
|
||||
*/
|
||||
get baseCode() {
|
||||
return this.#baseCode.value === ""? null: this.#baseCode.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback when the base account selector is closed.
|
||||
*
|
||||
@ -129,15 +138,14 @@ class AccountForm {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the base account.
|
||||
* Saves the selected base account.
|
||||
*
|
||||
* @param code {string} the base account code
|
||||
* @param text {string} the text for the base account
|
||||
* @param account {BaseAccountOption} the selected base account
|
||||
*/
|
||||
setBaseAccount(code, text) {
|
||||
this.#baseCode.value = code;
|
||||
this.#base.innerText = text;
|
||||
if (["1", "2", "3"].includes(code.substring(0, 1))) {
|
||||
saveBaseAccount(account) {
|
||||
this.#baseCode.value = account.code;
|
||||
this.#base.innerText = account.text;
|
||||
if (["1", "2", "3"].includes(account.code.substring(0, 1))) {
|
||||
this.#isNeedOffsetControl.classList.remove("d-none");
|
||||
this.#isNeedOffset.disabled = false;
|
||||
} else {
|
||||
@ -225,7 +233,7 @@ class BaseAccountSelector {
|
||||
* The account form
|
||||
* @type {AccountForm}
|
||||
*/
|
||||
#form;
|
||||
form;
|
||||
|
||||
/**
|
||||
* The selector modal
|
||||
@ -253,7 +261,7 @@ class BaseAccountSelector {
|
||||
|
||||
/**
|
||||
* The options
|
||||
* @type {HTMLLIElement[]}
|
||||
* @type {BaseAccountOption[]}
|
||||
*/
|
||||
#options;
|
||||
|
||||
@ -269,83 +277,54 @@ class BaseAccountSelector {
|
||||
* @param form {AccountForm} the form
|
||||
*/
|
||||
constructor(form) {
|
||||
this.#form = form;
|
||||
this.#modal = document.getElementById("accounting-base-selector-modal");
|
||||
this.#query = document.getElementById("accounting-base-selector-query");
|
||||
this.#optionList = document.getElementById("accounting-base-selector-option-list");
|
||||
// noinspection JSValidateTypes
|
||||
this.#options = Array.from(document.getElementsByClassName("accounting-base-selector-option"));
|
||||
this.#clearButton = document.getElementById("accounting-base-selector-clear");
|
||||
this.#queryNoResult = document.getElementById("accounting-base-selector-option-no-result");
|
||||
this.#modal.addEventListener("hidden.bs.modal", () => {
|
||||
this.#form.onBaseAccountSelectorClosed();
|
||||
});
|
||||
for (const option of this.#options) {
|
||||
option.onclick = () => {
|
||||
this.#form.setBaseAccount(option.dataset.code, option.dataset.text);
|
||||
};
|
||||
}
|
||||
this.#clearButton.onclick = () => {
|
||||
this.#form.clearBaseAccount();
|
||||
};
|
||||
this.#initializeBaseAccountQuery();
|
||||
this.form = form;
|
||||
const prefix = "accounting-base-selector";
|
||||
this.#modal = document.getElementById(`${prefix}-modal`);
|
||||
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 BaseAccountOption(this, element));
|
||||
this.#clearButton = document.getElementById(`${prefix}-clear`);
|
||||
|
||||
this.#modal.addEventListener("hidden.bs.modal", () => this.form.onBaseAccountSelectorClosed());
|
||||
this.#query.oninput = () => this.#filterOptions();
|
||||
this.#clearButton.onclick = () => this.form.clearBaseAccount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the query.
|
||||
* Filters the options.
|
||||
*
|
||||
*/
|
||||
#initializeBaseAccountQuery() {
|
||||
this.#query.addEventListener("input", () => {
|
||||
if (this.#query.value === "") {
|
||||
#filterOptions() {
|
||||
let isAnyMatched = false;
|
||||
for (const option of this.#options) {
|
||||
option.classList.remove("d-none");
|
||||
}
|
||||
this.#optionList.classList.remove("d-none");
|
||||
this.#queryNoResult.classList.add("d-none");
|
||||
return
|
||||
}
|
||||
let hasAnyMatched = false;
|
||||
for (const option of this.#options) {
|
||||
const queryValues = JSON.parse(option.dataset.queryValues);
|
||||
let isMatched = false;
|
||||
for (const queryValue of queryValues) {
|
||||
if (queryValue.toLowerCase().includes(this.#query.value.toLowerCase())) {
|
||||
isMatched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isMatched) {
|
||||
option.classList.remove("d-none");
|
||||
hasAnyMatched = true;
|
||||
if (option.isMatched(this.#query.value)) {
|
||||
option.setShown(true);
|
||||
isAnyMatched = true;
|
||||
} else {
|
||||
option.classList.add("d-none");
|
||||
option.setShown(false);
|
||||
}
|
||||
}
|
||||
if (!hasAnyMatched) {
|
||||
if (!isAnyMatched) {
|
||||
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback when the base account selector is shown.
|
||||
*
|
||||
* @param baseCode {string} the active base code
|
||||
*/
|
||||
onOpen(baseCode) {
|
||||
onOpen() {
|
||||
this.#query.value = "";
|
||||
this.#filterOptions();
|
||||
for (const option of this.#options) {
|
||||
if (option.dataset.code === baseCode) {
|
||||
option.classList.add("active");
|
||||
} else {
|
||||
option.classList.remove("active");
|
||||
option.setActive(option.code === this.form.baseCode);
|
||||
}
|
||||
}
|
||||
if (baseCode === "") {
|
||||
if (this.form.baseCode === null) {
|
||||
this.#clearButton.classList.add("btn-secondary")
|
||||
this.#clearButton.classList.remove("btn-danger");
|
||||
this.#clearButton.disabled = true;
|
||||
@ -356,3 +335,93 @@ class BaseAccountSelector {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A base account option.
|
||||
*
|
||||
*/
|
||||
class BaseAccountOption {
|
||||
|
||||
/**
|
||||
* 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 base account selector.
|
||||
*
|
||||
* @param selector {BaseAccountSelector} the base account selector
|
||||
* @param element {HTMLLIElement} the element
|
||||
*/
|
||||
constructor(selector, element) {
|
||||
this.#element = element;
|
||||
this.code = element.dataset.code;
|
||||
this.text = element.dataset.text;
|
||||
this.#queryValues = JSON.parse(element.dataset.queryValues);
|
||||
|
||||
this.#element.onclick = () => selector.form.saveBaseAccount(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the account matches the query.
|
||||
*
|
||||
* @param query {string} the query term
|
||||
* @return {boolean} true if the option matches, or false otherwise
|
||||
*/
|
||||
isMatched(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");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the option is active.
|
||||
*
|
||||
* @param isActive {boolean} true if active, or false otherwise
|
||||
*/
|
||||
setActive(isActive) {
|
||||
if (isActive) {
|
||||
this.#element.classList.add("active");
|
||||
} else {
|
||||
this.#element.classList.remove("active");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -29,10 +29,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const onReorder = () => {
|
||||
const accounts = Array.from(list.children);
|
||||
for (let i = 0; i < accounts.length; i++) {
|
||||
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
|
||||
const code = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-code");
|
||||
const no = document.getElementById(`accounting-order-${accounts[i].dataset.id}-no`);
|
||||
const code = document.getElementById(`accounting-order-${accounts[i].dataset.id}-code`);
|
||||
no.value = String(i + 1);
|
||||
code.innerText = list.dataset.baseCode + "-" + ("000" + (i + 1)).slice(-3);
|
||||
const zeroPaddedNo = `000${no.value}`.slice(-3)
|
||||
code.innerText = `${list.dataset.baseCode}-${zeroPaddedNo}`;
|
||||
}
|
||||
};
|
||||
initializeDragAndDropReordering(list, onReorder);
|
||||
|
@ -1,224 +0,0 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
* account-selector.js: The JavaScript for the account selector
|
||||
*/
|
||||
|
||||
/* 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";
|
||||
|
||||
/**
|
||||
* The account selector.
|
||||
*
|
||||
*/
|
||||
class AccountSelector {
|
||||
|
||||
/**
|
||||
* The line item editor
|
||||
* @type {JournalEntryLineItemEditor}
|
||||
*/
|
||||
#lineItemEditor;
|
||||
|
||||
/**
|
||||
* Either "debit" or "credit"
|
||||
* @type {string}
|
||||
*/
|
||||
#debitCredit;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* @type {string}
|
||||
*/
|
||||
#prefix;
|
||||
|
||||
/**
|
||||
* The button to clear the account
|
||||
* @type {HTMLButtonElement}
|
||||
*/
|
||||
#clearButton
|
||||
|
||||
/**
|
||||
* 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 options
|
||||
* @type {HTMLLIElement[]}
|
||||
*/
|
||||
#options;
|
||||
|
||||
/**
|
||||
* The more item to show all accounts
|
||||
* @type {HTMLLIElement}
|
||||
*/
|
||||
#more;
|
||||
|
||||
/**
|
||||
* Constructs an account selector.
|
||||
*
|
||||
* @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-account-selector-" + debitCredit;
|
||||
this.#query = document.getElementById(this.#prefix + "-query");
|
||||
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
|
||||
this.#optionList = document.getElementById(this.#prefix + "-option-list");
|
||||
// noinspection JSValidateTypes
|
||||
this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
|
||||
this.#more = document.getElementById(this.#prefix + "-more");
|
||||
this.#clearButton = document.getElementById(this.#prefix + "-btn-clear");
|
||||
this.#more.onclick = () => {
|
||||
this.#more.classList.add("d-none");
|
||||
this.#filterOptions();
|
||||
};
|
||||
this.#clearButton.onclick = () => this.#lineItemEditor.clearAccount();
|
||||
for (const option of this.#options) {
|
||||
option.onclick = () => this.#lineItemEditor.saveAccount(option.dataset.code, option.dataset.text, option.classList.contains("accounting-account-is-need-offset"));
|
||||
}
|
||||
this.#query.addEventListener("input", () => {
|
||||
this.#filterOptions();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the options.
|
||||
*
|
||||
*/
|
||||
#filterOptions() {
|
||||
const codesInUse = this.#getCodesUsedInForm();
|
||||
let shouldAnyShow = false;
|
||||
for (const option of this.#options) {
|
||||
const shouldShow = this.#shouldOptionShow(option, this.#more, codesInUse, this.#query);
|
||||
if (shouldShow) {
|
||||
option.classList.remove("d-none");
|
||||
shouldAnyShow = true;
|
||||
} else {
|
||||
option.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
if (!shouldAnyShow && this.#more.classList.contains("d-none")) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the account codes that are used in the form.
|
||||
*
|
||||
* @return {string[]} the account codes that are used in the form
|
||||
*/
|
||||
#getCodesUsedInForm() {
|
||||
const inUse = this.#lineItemEditor.form.getAccountCodesUsed(this.#debitCredit);
|
||||
if (this.#lineItemEditor.accountCode !== null) {
|
||||
inUse.push(this.#lineItemEditor.accountCode);
|
||||
}
|
||||
return inUse
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether an option should show.
|
||||
*
|
||||
* @param option {HTMLLIElement} the option
|
||||
* @param more {HTMLLIElement} the more element
|
||||
* @param inUse {string[]} the account codes that are used in the form
|
||||
* @param query {HTMLInputElement} the query element, if any
|
||||
* @return {boolean} true if the option should show, or false otherwise
|
||||
*/
|
||||
#shouldOptionShow(option, more, inUse, query) {
|
||||
const isQueryMatched = () => {
|
||||
if (query.value === "") {
|
||||
return true;
|
||||
}
|
||||
const queryValues = JSON.parse(option.dataset.queryValues);
|
||||
for (const queryValue of queryValues) {
|
||||
if (queryValue.toLowerCase().includes(query.value.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const isMoreMatched = () => {
|
||||
if (more.classList.contains("d-none")) {
|
||||
return true;
|
||||
}
|
||||
return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code);
|
||||
};
|
||||
return isMoreMatched() && isQueryMatched();
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback when the account selector is shown.
|
||||
*
|
||||
*/
|
||||
onOpen() {
|
||||
this.#query.value = "";
|
||||
this.#more.classList.remove("d-none");
|
||||
this.#filterOptions();
|
||||
for (const option of this.#options) {
|
||||
if (option.dataset.code === this.#lineItemEditor.accountCode) {
|
||||
option.classList.add("active");
|
||||
} else {
|
||||
option.classList.remove("active");
|
||||
}
|
||||
}
|
||||
if (this.#lineItemEditor.accountCode === null) {
|
||||
this.#clearButton.classList.add("btn-secondary");
|
||||
this.#clearButton.classList.remove("btn-danger");
|
||||
this.#clearButton.disabled = true;
|
||||
} else {
|
||||
this.#clearButton.classList.add("btn-danger");
|
||||
this.#clearButton.classList.remove("btn-secondary");
|
||||
this.#clearButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the account selector instances.
|
||||
*
|
||||
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
|
||||
* @return {{debit: AccountSelector, credit: AccountSelector}}
|
||||
*/
|
||||
static getInstances(lineItemEditor) {
|
||||
const selectors = {}
|
||||
const modals = Array.from(document.getElementsByClassName("accounting-account-selector"));
|
||||
for (const modal of modals) {
|
||||
selectors[modal.dataset.debitCredit] = new AccountSelector(lineItemEditor, modal.dataset.debitCredit);
|
||||
}
|
||||
return selectors;
|
||||
}
|
||||
}
|
@ -128,7 +128,7 @@ class CurrencyForm {
|
||||
}
|
||||
const original = this.#code.dataset.original;
|
||||
if (original === "" || this.#code.value !== original) {
|
||||
const response = await fetch(this.#code.dataset.existsUrl + "?q=" + encodeURIComponent(this.#code.value));
|
||||
const response = await fetch(`${this.#code.dataset.existsUrl}?q=${encodeURIComponent(this.#code.value)}`);
|
||||
const data = await response.json();
|
||||
if (data["exists"]) {
|
||||
this.#code.classList.add("is-invalid");
|
||||
|
@ -41,7 +41,7 @@ class DescriptionEditor {
|
||||
#form;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* The prefix of the HTML ID and class names
|
||||
* @type {string}
|
||||
*/
|
||||
prefix;
|
||||
@ -60,7 +60,7 @@ class DescriptionEditor {
|
||||
|
||||
/**
|
||||
* The current tab
|
||||
* @type {TabPlane}
|
||||
* @type {DescriptionEditorTabPlane}
|
||||
*/
|
||||
currentTab;
|
||||
|
||||
@ -68,7 +68,7 @@ class DescriptionEditor {
|
||||
* The description input
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
description;
|
||||
#descriptionInput;
|
||||
|
||||
/**
|
||||
* The button to the original line item selector
|
||||
@ -89,20 +89,44 @@ class DescriptionEditor {
|
||||
note;
|
||||
|
||||
/**
|
||||
* The account buttons
|
||||
* @type {HTMLButtonElement[]}
|
||||
* The placeholder of the confirmed account
|
||||
* @type {DescriptionEditorConfirmedAccount}
|
||||
*/
|
||||
#accountButtons;
|
||||
#confirmedAccountPlaceholder;
|
||||
|
||||
/**
|
||||
* The selected account button
|
||||
* @type {HTMLButtonElement|null}
|
||||
* All the suggested accounts
|
||||
* @type {DescriptionEditorSuggestedAccount[]}
|
||||
*/
|
||||
#selectedAccount = null;
|
||||
#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: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, recurring: RecurringTransactionTab, annotation: AnnotationTab}}
|
||||
* @type {{general: DescriptionEditorGeneralTagTab, travel: DescriptionEditorGeneralTripTab, bus: DescriptionEditorBusTripTab, recurring: DescriptionEditorRecurringTab, annotation: DescriptionEditorAnnotationTab}}
|
||||
*/
|
||||
tabPlanes = {};
|
||||
|
||||
@ -115,23 +139,22 @@ class DescriptionEditor {
|
||||
constructor(lineItemEditor, debitCredit) {
|
||||
this.lineItemEditor = lineItemEditor;
|
||||
this.debitCredit = debitCredit;
|
||||
this.prefix = "accounting-description-editor-" + 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"));
|
||||
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 [GeneralTagTab, GeneralTripTab, BusTripTab, RecurringTransactionTab, AnnotationTab]) {
|
||||
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.#initializeSuggestedAccounts();
|
||||
this.description.onchange = () => this.#onDescriptionChange();
|
||||
this.#descriptionInput.onchange = () => this.#onDescriptionChange();
|
||||
this.#offsetButton.onclick = () => this.lineItemEditor.originalLineItemSelector.onOpen();
|
||||
this.#form.onsubmit = () => {
|
||||
if (this.currentTab.validate()) {
|
||||
@ -141,12 +164,44 @@ class DescriptionEditor {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.description.value = this.description.value.trim();
|
||||
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;
|
||||
@ -156,20 +211,49 @@ class DescriptionEditor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the suggested accounts.
|
||||
* 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
|
||||
*/
|
||||
filterSuggestedAccounts(tagButton) {
|
||||
updateCurrentSuggestedAccounts(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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -179,37 +263,29 @@ class DescriptionEditor {
|
||||
*
|
||||
*/
|
||||
clearSuggestedAccounts() {
|
||||
for (const accountButton of this.#accountButtons) {
|
||||
accountButton.classList.add("d-none");
|
||||
for (const account of this.#allSuggestedAccounts) {
|
||||
account.setShown(false);
|
||||
account.setActive(false);
|
||||
}
|
||||
this.#selectAccount(null);
|
||||
this.#currentSuggestedAccounts = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the suggested accounts.
|
||||
* Select an account.
|
||||
*
|
||||
* @param selectedAccount {DescriptionEditorAccount|null} the account, or null to deselect the account
|
||||
*/
|
||||
#initializeSuggestedAccounts() {
|
||||
for (const accountButton of this.#accountButtons) {
|
||||
accountButton.onclick = () => this.#selectAccount(accountButton);
|
||||
selectAccount(selectedAccount) {
|
||||
for (const account of this.#currentAccountOptions) {
|
||||
account.setActive(false);
|
||||
}
|
||||
if (selectedAccount !== null) {
|
||||
selectedAccount.setActive(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
this.selectedAccount = selectedAccount;
|
||||
if (this.selectedAccount !== null) {
|
||||
this.isAccountConfirmed &&= this.selectedAccount.isConfirmedAccount;
|
||||
}
|
||||
if (selectedAccountButton !== null) {
|
||||
selectedAccountButton.classList.remove("btn-outline-primary");
|
||||
selectedAccountButton.classList.add("btn-primary");
|
||||
}
|
||||
this.#selectedAccount = selectedAccountButton;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -218,11 +294,7 @@ class DescriptionEditor {
|
||||
*/
|
||||
#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);
|
||||
}
|
||||
this.lineItemEditor.saveDescription(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -230,21 +302,27 @@ class DescriptionEditor {
|
||||
*
|
||||
*/
|
||||
onOpen() {
|
||||
this.#reset();
|
||||
this.description.value = this.lineItemEditor.description === null? "": this.lineItemEditor.description;
|
||||
this.description = this.lineItemEditor.description === null? "": this.lineItemEditor.description;
|
||||
this.#setConfirmedAccount();
|
||||
this.#onDescriptionChange();
|
||||
if (this.isAccountConfirmed) {
|
||||
this.selectAccount(this.#confirmedAccount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the description editor.
|
||||
* Sets the confirmed account.
|
||||
*
|
||||
*/
|
||||
#reset() {
|
||||
this.description.value = "";
|
||||
for (const tabPlane of Object.values(this.tabPlanes)) {
|
||||
tabPlane.reset();
|
||||
#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;
|
||||
}
|
||||
this.tabPlanes.general.switchToMe();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -263,13 +341,130 @@ class DescriptionEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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, text, isNeedOffset, button) {
|
||||
super(code, 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.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 TabPlane {
|
||||
class DescriptionEditorTabPlane {
|
||||
|
||||
/**
|
||||
* The parent description editor
|
||||
@ -278,7 +473,7 @@ class TabPlane {
|
||||
editor;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and classes
|
||||
* The prefix of the HTML ID and class names
|
||||
* @type {string}
|
||||
*/
|
||||
prefix;
|
||||
@ -302,9 +497,9 @@ class TabPlane {
|
||||
*/
|
||||
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.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();
|
||||
}
|
||||
|
||||
@ -364,7 +559,7 @@ class TabPlane {
|
||||
* @abstract
|
||||
* @private
|
||||
*/
|
||||
class TagTabPlane extends TabPlane {
|
||||
class DescriptionEditorTagTabPlane extends DescriptionEditorTabPlane {
|
||||
|
||||
/**
|
||||
* The tag input
|
||||
@ -392,10 +587,10 @@ class TagTabPlane extends TabPlane {
|
||||
*/
|
||||
constructor(editor) {
|
||||
super(editor);
|
||||
this.tag = document.getElementById(this.prefix + "-tag");
|
||||
this.tagError = document.getElementById(this.prefix + "-tag-error");
|
||||
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.tagButtons = Array.from(document.getElementsByClassName(`${this.prefix}-btn-tag`));
|
||||
this.initializeTagButtons();
|
||||
this.tag.onchange = () => {
|
||||
this.onTagChange();
|
||||
@ -414,7 +609,7 @@ class TagTabPlane extends TabPlane {
|
||||
if (tagButton.dataset.value === this.tag.value) {
|
||||
tagButton.classList.remove("btn-outline-primary");
|
||||
tagButton.classList.add("btn-primary");
|
||||
this.editor.filterSuggestedAccounts(tagButton);
|
||||
this.editor.updateCurrentSuggestedAccounts(tagButton);
|
||||
isMatched = true;
|
||||
} else {
|
||||
tagButton.classList.remove("btn-primary");
|
||||
@ -442,7 +637,7 @@ class TagTabPlane extends TabPlane {
|
||||
super.switchToMe();
|
||||
for (const tagButton of this.tagButtons) {
|
||||
if (tagButton.classList.contains("btn-primary")) {
|
||||
this.editor.filterSuggestedAccounts(tagButton);
|
||||
this.editor.updateCurrentSuggestedAccounts(tagButton);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -463,7 +658,7 @@ class TagTabPlane extends TabPlane {
|
||||
tagButton.classList.remove("btn-outline-primary");
|
||||
tagButton.classList.add("btn-primary");
|
||||
this.tag.value = tagButton.dataset.value;
|
||||
this.editor.filterSuggestedAccounts(tagButton);
|
||||
this.editor.updateCurrentSuggestedAccounts(tagButton);
|
||||
this.updateDescription();
|
||||
};
|
||||
}
|
||||
@ -522,7 +717,7 @@ class TagTabPlane extends TabPlane {
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class GeneralTagTab extends TagTabPlane {
|
||||
class DescriptionEditorGeneralTagTab extends DescriptionEditorTagTabPlane {
|
||||
|
||||
/**
|
||||
* The tab ID
|
||||
@ -540,12 +735,12 @@ class GeneralTagTab extends TagTabPlane {
|
||||
* @override
|
||||
*/
|
||||
updateDescription() {
|
||||
const pos = this.editor.description.value.indexOf("—");
|
||||
const prefix = this.tag.value === ""? "": this.tag.value + "—";
|
||||
const pos = this.editor.description.indexOf("—");
|
||||
const prefix = this.tag.value === ""? "": `${this.tag.value}—`;
|
||||
if (pos === -1) {
|
||||
this.editor.description.value = prefix + this.editor.description.value;
|
||||
this.editor.description = `${prefix}${this.editor.description}`;
|
||||
} else {
|
||||
this.editor.description.value = prefix + this.editor.description.value.substring(pos + 1);
|
||||
this.editor.description = `${prefix}${this.editor.description.substring(pos + 1)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -556,7 +751,7 @@ class GeneralTagTab extends TagTabPlane {
|
||||
* @override
|
||||
*/
|
||||
populate() {
|
||||
const found = this.editor.description.value.match(/^([^—]+)—/);
|
||||
const found = this.editor.description.match(/^([^—]+)—/);
|
||||
if (found === null) {
|
||||
return false;
|
||||
}
|
||||
@ -583,7 +778,7 @@ class GeneralTagTab extends TagTabPlane {
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class GeneralTripTab extends TagTabPlane {
|
||||
class DescriptionEditorGeneralTripTab extends DescriptionEditorTagTabPlane {
|
||||
|
||||
/**
|
||||
* The origin
|
||||
@ -623,12 +818,12 @@ class GeneralTripTab extends TagTabPlane {
|
||||
*/
|
||||
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")
|
||||
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.#directionButtons = Array.from(document.getElementsByClassName(`${this.prefix}-direction`));
|
||||
this.#from.onchange = () => {
|
||||
this.#from.value = this.#from.value.trim();
|
||||
this.updateDescription();
|
||||
@ -675,7 +870,7 @@ class GeneralTripTab extends TagTabPlane {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.editor.description.value = this.tag.value + "—" + this.#from.value + direction + this.#to.value;
|
||||
this.editor.description = `${this.tag.value}—${this.#from.value}${direction}${this.#to.value}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -709,7 +904,7 @@ class GeneralTripTab extends TagTabPlane {
|
||||
* @override
|
||||
*/
|
||||
populate() {
|
||||
const found = this.editor.description.value.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
|
||||
const found = this.editor.description.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
|
||||
if (found === null) {
|
||||
return false;
|
||||
}
|
||||
@ -782,7 +977,7 @@ class GeneralTripTab extends TagTabPlane {
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class BusTripTab extends TagTabPlane {
|
||||
class DescriptionEditorBusTripTab extends DescriptionEditorTagTabPlane {
|
||||
|
||||
/**
|
||||
* The route
|
||||
@ -828,12 +1023,12 @@ class BusTripTab extends TagTabPlane {
|
||||
*/
|
||||
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 = 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();
|
||||
@ -867,7 +1062,7 @@ class BusTripTab extends TagTabPlane {
|
||||
* @override
|
||||
*/
|
||||
updateDescription() {
|
||||
this.editor.description.value = this.tag.value + "—" + this.#route.value + "—" + this.#from.value + "→" + this.#to.value;
|
||||
this.editor.description = `${this.tag.value}—${this.#route.value}—${this.#from.value}→${this.#to.value}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -895,7 +1090,7 @@ class BusTripTab extends TagTabPlane {
|
||||
* @override
|
||||
*/
|
||||
populate() {
|
||||
const found = this.editor.description.value.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
|
||||
const found = this.editor.description.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
|
||||
if (found === null) {
|
||||
return false;
|
||||
}
|
||||
@ -970,7 +1165,7 @@ class BusTripTab extends TagTabPlane {
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class RecurringTransactionTab extends TabPlane {
|
||||
class DescriptionEditorRecurringTab extends DescriptionEditorTabPlane {
|
||||
|
||||
/**
|
||||
* The month names
|
||||
@ -984,7 +1179,6 @@ class RecurringTransactionTab extends TabPlane {
|
||||
*/
|
||||
#itemButtons;
|
||||
|
||||
// noinspection JSValidateTypes
|
||||
/**
|
||||
* Constructs a tab plane.
|
||||
*
|
||||
@ -1000,14 +1194,14 @@ class RecurringTransactionTab extends TabPlane {
|
||||
A_("September"), A_("October"), A_("November"), A_("December"),
|
||||
];
|
||||
// noinspection JSValidateTypes
|
||||
this.#itemButtons = Array.from(document.getElementsByClassName(this.prefix + "-item"));
|
||||
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);
|
||||
this.editor.description = this.#getDescription(itemButton);
|
||||
this.editor.updateCurrentSuggestedAccounts(itemButton);
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1019,7 +1213,7 @@ class RecurringTransactionTab extends TabPlane {
|
||||
* @return {string} the description of the recurring item
|
||||
*/
|
||||
#getDescription(itemButton) {
|
||||
const today = new Date(this.editor.lineItemEditor.form.getDate());
|
||||
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);
|
||||
@ -1029,8 +1223,8 @@ class RecurringTransactionTab extends TabPlane {
|
||||
.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]);
|
||||
.replaceAll("{last_bimonthly_number}", `${String(lastBimonthlyFrom)}–${String(lastBimonthlyTo)}`)
|
||||
.replaceAll("{last_bimonthly_name}", `${this.#monthNames[lastBimonthlyFrom]}–${this.#monthNames[lastBimonthlyTo]}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1063,7 +1257,7 @@ class RecurringTransactionTab extends TabPlane {
|
||||
*/
|
||||
populate() {
|
||||
for (const itemButton of this.#itemButtons) {
|
||||
if (this.#getDescription(itemButton) === this.editor.description.value) {
|
||||
if (this.#getDescription(itemButton) === this.editor.description) {
|
||||
itemButton.classList.add("btn-primary");
|
||||
itemButton.classList.remove("btn-outline-primary");
|
||||
this.switchToMe();
|
||||
@ -1081,7 +1275,7 @@ class RecurringTransactionTab extends TabPlane {
|
||||
super.switchToMe();
|
||||
for (const itemButton of this.#itemButtons) {
|
||||
if (itemButton.classList.contains("btn-primary")) {
|
||||
this.editor.filterSuggestedAccounts(itemButton);
|
||||
this.editor.updateCurrentSuggestedAccounts(itemButton);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -1104,7 +1298,7 @@ class RecurringTransactionTab extends TabPlane {
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class AnnotationTab extends TabPlane {
|
||||
class DescriptionEditorAnnotationTab extends DescriptionEditorTabPlane {
|
||||
|
||||
/**
|
||||
* Constructs a tab plane.
|
||||
@ -1137,15 +1331,15 @@ class AnnotationTab extends TabPlane {
|
||||
* @override
|
||||
*/
|
||||
updateDescription() {
|
||||
const found = this.editor.description.value.match(/^(.*?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
|
||||
const found = this.editor.description.match(/^(.*?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
|
||||
if (found !== null) {
|
||||
this.editor.description.value = found[1];
|
||||
this.editor.description = found[1];
|
||||
}
|
||||
if (parseInt(this.editor.number.value) > 1) {
|
||||
this.editor.description.value = this.editor.description.value + "×" + this.editor.number.value;
|
||||
this.editor.description = `${this.editor.description}×${this.editor.number.value}`;
|
||||
}
|
||||
if (this.editor.note.value !== "") {
|
||||
this.editor.description.value = this.editor.description.value + "(" + this.editor.note.value + ")";
|
||||
this.editor.description = `${this.editor.description}(${this.editor.note.value})`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1166,19 +1360,19 @@ class AnnotationTab extends TabPlane {
|
||||
* @override
|
||||
*/
|
||||
populate() {
|
||||
const found = this.editor.description.value.match(/^(.*?)(?:[*×](\d+))?(?:\(([^()]+)\))?$/);
|
||||
this.editor.description.value = found[1];
|
||||
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.value = this.editor.description.value + "×" + this.editor.number.value;
|
||||
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.value = this.editor.description.value + "(" + this.editor.note.value + ")";
|
||||
this.editor.description = `${this.editor.description}(${this.editor.note.value})`;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
312
src/accounting/static/js/journal-entry-account-selector.js
Normal file
312
src/accounting/static/js/journal-entry-account-selector.js
Normal file
@ -0,0 +1,312 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
* journal-entry-account-selector.js: The JavaScript for the account selector of the journal entry 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/2/28
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* The account selector.
|
||||
*
|
||||
*/
|
||||
class JournalEntryAccountSelector {
|
||||
|
||||
/**
|
||||
* The line item editor
|
||||
* @type {JournalEntryLineItemEditor}
|
||||
*/
|
||||
lineItemEditor;
|
||||
|
||||
/**
|
||||
* Either "debit" or "credit"
|
||||
* @type {string}
|
||||
*/
|
||||
#debitCredit;
|
||||
|
||||
/**
|
||||
* The button to clear the account
|
||||
* @type {HTMLButtonElement}
|
||||
*/
|
||||
#clearButton
|
||||
|
||||
/**
|
||||
* 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 options
|
||||
* @type {JournalEntryAccountOption[]}
|
||||
*/
|
||||
#options;
|
||||
|
||||
/**
|
||||
* The more item to show all accounts
|
||||
* @type {HTMLLIElement}
|
||||
*/
|
||||
#more;
|
||||
|
||||
/**
|
||||
* Whether to show all accounts
|
||||
* @type {boolean}
|
||||
*/
|
||||
#isShowMore = false;
|
||||
|
||||
/**
|
||||
* Constructs an account selector.
|
||||
*
|
||||
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
|
||||
* @param debitCredit {string} either "debit" or "credit"
|
||||
*/
|
||||
constructor(lineItemEditor, debitCredit) {
|
||||
this.lineItemEditor = lineItemEditor
|
||||
this.#debitCredit = debitCredit;
|
||||
const prefix = `accounting-account-selector-${debitCredit}`;
|
||||
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 JournalEntryAccountOption(this, element));
|
||||
this.#more = document.getElementById(`${prefix}-more`);
|
||||
this.#clearButton = document.getElementById(`${prefix}-btn-clear`);
|
||||
|
||||
this.#more.onclick = () => {
|
||||
this.#isShowMore = true;
|
||||
this.#more.classList.add("d-none");
|
||||
this.#filterOptions();
|
||||
};
|
||||
this.#query.oninput = () => this.#filterOptions();
|
||||
this.#clearButton.onclick = () => this.lineItemEditor.clearAccount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the options.
|
||||
*
|
||||
*/
|
||||
#filterOptions() {
|
||||
const codesInUse = this.#getCodesUsedInForm();
|
||||
let isAnyMatched = false;
|
||||
for (const option of this.#options) {
|
||||
if (option.isMatched(this.#isShowMore, codesInUse, this.#query.value)) {
|
||||
option.setShown(true);
|
||||
isAnyMatched = true;
|
||||
} else {
|
||||
option.setShown(false);
|
||||
}
|
||||
}
|
||||
if (!isAnyMatched) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the account codes that are used in the form.
|
||||
*
|
||||
* @return {string[]} the account codes that are used in the form
|
||||
*/
|
||||
#getCodesUsedInForm() {
|
||||
const inUse = this.lineItemEditor.form.getAccountCodesUsed(this.#debitCredit);
|
||||
if (this.lineItemEditor.account !== null) {
|
||||
inUse.push(this.lineItemEditor.account.code);
|
||||
}
|
||||
return inUse
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback when the account selector is shown.
|
||||
*
|
||||
*/
|
||||
onOpen() {
|
||||
this.#query.value = "";
|
||||
this.#isShowMore = false;
|
||||
this.#more.classList.remove("d-none");
|
||||
this.#filterOptions();
|
||||
for (const option of this.#options) {
|
||||
option.setActive(this.lineItemEditor.account !== null && option.code === this.lineItemEditor.account.code);
|
||||
}
|
||||
if (this.lineItemEditor.account === null) {
|
||||
this.#clearButton.classList.add("btn-secondary");
|
||||
this.#clearButton.classList.remove("btn-danger");
|
||||
this.#clearButton.disabled = true;
|
||||
} else {
|
||||
this.#clearButton.classList.add("btn-danger");
|
||||
this.#clearButton.classList.remove("btn-secondary");
|
||||
this.#clearButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the account selector instances.
|
||||
*
|
||||
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
|
||||
* @return {{debit: JournalEntryAccountSelector, credit: JournalEntryAccountSelector}}
|
||||
*/
|
||||
static getInstances(lineItemEditor) {
|
||||
const selectors = {}
|
||||
const modals = Array.from(document.getElementsByClassName("accounting-account-selector"));
|
||||
for (const modal of modals) {
|
||||
selectors[modal.dataset.debitCredit] = new JournalEntryAccountSelector(lineItemEditor, modal.dataset.debitCredit);
|
||||
}
|
||||
return selectors;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An account option
|
||||
*
|
||||
*/
|
||||
class JournalEntryAccountOption {
|
||||
|
||||
/**
|
||||
* The element
|
||||
* @type {HTMLLIElement}
|
||||
*/
|
||||
#element;
|
||||
|
||||
/**
|
||||
* The account code
|
||||
* @type {string}
|
||||
*/
|
||||
code;
|
||||
|
||||
/**
|
||||
* The account text
|
||||
* @type {string}
|
||||
*/
|
||||
text;
|
||||
|
||||
/**
|
||||
* Whether the account is in use
|
||||
* @type {boolean}
|
||||
*/
|
||||
#isInUse;
|
||||
|
||||
/**
|
||||
* Whether line items in the account need offset
|
||||
* @type {boolean}
|
||||
*/
|
||||
isNeedOffset;
|
||||
|
||||
/**
|
||||
* The values to query against
|
||||
* @type {string[]}
|
||||
*/
|
||||
#queryValues;
|
||||
|
||||
/**
|
||||
* Constructs the account in the account selector.
|
||||
*
|
||||
* @param selector {JournalEntryAccountSelector} the account selector
|
||||
* @param element {HTMLLIElement} the element
|
||||
*/
|
||||
constructor(selector, element) {
|
||||
this.#element = element;
|
||||
this.code = element.dataset.code;
|
||||
this.text = element.dataset.text;
|
||||
this.#isInUse = element.classList.contains("accounting-account-is-in-use");
|
||||
this.isNeedOffset = element.classList.contains("accounting-account-is-need-offset");
|
||||
this.#queryValues = JSON.parse(element.dataset.queryValues);
|
||||
|
||||
this.#element.onclick = () => selector.lineItemEditor.saveAccount(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the account matches the query.
|
||||
*
|
||||
* @param isShowMore {boolean} true to show all accounts, or false to show only those in use
|
||||
* @param codesInUse {string[]} the account codes that are used in the form
|
||||
* @param query {string} the query term
|
||||
* @return {boolean} true if the option matches, or false otherwise
|
||||
*/
|
||||
isMatched(isShowMore, codesInUse, query) {
|
||||
return this.#isInUseMatched(isShowMore, codesInUse) && this.#isQueryMatched(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the account matches the "in-use" condition.
|
||||
*
|
||||
* @param isShowMore {boolean} true to show all accounts, or false to show only those in use
|
||||
* @param codesInUse {string[]} the account codes that are used in the form
|
||||
* @return {boolean} true if the option matches, or false otherwise
|
||||
*/
|
||||
#isInUseMatched(isShowMore, codesInUse) {
|
||||
return isShowMore || this.#isInUse || codesInUse.includes(this.code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the account matches the query term.
|
||||
*
|
||||
* @param query {string} the query term
|
||||
* @return {boolean} true if the option matches, or false otherwise
|
||||
*/
|
||||
#isQueryMatched(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");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the option is active.
|
||||
*
|
||||
* @param isActive {boolean} true if active, or false otherwise
|
||||
*/
|
||||
setActive(isActive) {
|
||||
if (isActive) {
|
||||
this.#element.classList.add("active");
|
||||
} else {
|
||||
this.#element.classList.remove("active");
|
||||
}
|
||||
}
|
||||
}
|
@ -111,6 +111,7 @@ class JournalEntryForm {
|
||||
constructor() {
|
||||
this.#element = document.getElementById("accounting-form");
|
||||
this.lineItemTemplate = this.#element.dataset.lineItemTemplate;
|
||||
this.lineItemEditor = new JournalEntryLineItemEditor(this);
|
||||
this.#date = document.getElementById("accounting-date");
|
||||
this.#dateError = document.getElementById("accounting-date-error");
|
||||
this.#currencyControl = document.getElementById("accounting-currencies");
|
||||
@ -121,14 +122,13 @@ class JournalEntryForm {
|
||||
this.#addCurrencyButton = document.getElementById("accounting-add-currency");
|
||||
this.#note = document.getElementById("accounting-note");
|
||||
this.#noteError = document.getElementById("accounting-note-error");
|
||||
this.lineItemEditor = new JournalEntryLineItemEditor(this);
|
||||
|
||||
this.#addCurrencyButton.onclick = () => {
|
||||
const newIndex = 1 + (this.#currencies.length === 0? 0: Math.max(...this.#currencies.map((currency) => currency.index)));
|
||||
const html = this.#element.dataset.currencyTemplate
|
||||
.replaceAll("CURRENCY_INDEX", escapeHtml(String(newIndex)));
|
||||
this.#currencyList.insertAdjacentHTML("beforeend", html);
|
||||
const element = document.getElementById("accounting-currency-" + String(newIndex));
|
||||
const element = document.getElementById(`accounting-currency-${String(newIndex)}`);
|
||||
this.#currencies.push(new CurrencySubForm(this, element));
|
||||
this.#resetDeleteCurrencyButtons();
|
||||
this.#initializeDragAndDropReordering();
|
||||
@ -159,7 +159,7 @@ class JournalEntryForm {
|
||||
*/
|
||||
#resetDeleteCurrencyButtons() {
|
||||
if (this.#currencies.length === 1) {
|
||||
this.#currencies[0].deleteButton.classList.add("d-none");
|
||||
this.#currencies[0].setDeleteButtonShown(false);
|
||||
} else {
|
||||
for (const currency of this.#currencies) {
|
||||
let isAnyLineItemMatched = false;
|
||||
@ -169,11 +169,7 @@ class JournalEntryForm {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isAnyLineItemMatched) {
|
||||
currency.deleteButton.classList.add("d-none");
|
||||
} else {
|
||||
currency.deleteButton.classList.remove("d-none");
|
||||
}
|
||||
currency.setDeleteButtonShown(!isAnyLineItemMatched);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -184,10 +180,8 @@ class JournalEntryForm {
|
||||
*/
|
||||
#initializeDragAndDropReordering() {
|
||||
initializeDragAndDropReordering(this.#currencyList, () => {
|
||||
const currencyId = Array.from(this.#currencyList.children).map((currency) => currency.id);
|
||||
this.#currencies.sort((a, b) => currencyId.indexOf(a.element.id) - currencyId.indexOf(b.element.id));
|
||||
for (let i = 0; i < this.#currencies.length; i++) {
|
||||
this.#currencies[i].no.value = String(i + 1);
|
||||
for (const currency of this.#currencies) {
|
||||
currency.resetNo();
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -213,8 +207,8 @@ class JournalEntryForm {
|
||||
* @return {string[]} the account codes used in the form
|
||||
*/
|
||||
getAccountCodesUsed(debitCredit) {
|
||||
return this.getLineItems(debitCredit).map((lineItem) => lineItem.getAccountCode())
|
||||
.filter((code) => code !== null);
|
||||
return this.getLineItems(debitCredit).filter((lineItem) => lineItem.account !== null)
|
||||
.map((lineItem) => lineItem.account.code);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -222,7 +216,7 @@ class JournalEntryForm {
|
||||
*
|
||||
* @return {string} the date
|
||||
*/
|
||||
getDate() {
|
||||
get date() {
|
||||
return this.#date.value;
|
||||
}
|
||||
|
||||
@ -233,7 +227,7 @@ class JournalEntryForm {
|
||||
updateMinDate() {
|
||||
let lastOriginalLineItemDate = null;
|
||||
for (const lineItem of this.getLineItems()) {
|
||||
const date = lineItem.getOriginalLineItemDate();
|
||||
const date = lineItem.originalLineItemDate;
|
||||
if (date !== null) {
|
||||
if (lastOriginalLineItemDate === null || lastOriginalLineItemDate < date) {
|
||||
lastOriginalLineItemDate = date;
|
||||
@ -349,7 +343,7 @@ class CurrencySubForm {
|
||||
* The element
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
element;
|
||||
#element;
|
||||
|
||||
/**
|
||||
* The journal entry form
|
||||
@ -363,12 +357,6 @@ class CurrencySubForm {
|
||||
*/
|
||||
index;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* @type {string}
|
||||
*/
|
||||
#prefix;
|
||||
|
||||
/**
|
||||
* The control
|
||||
* @type {HTMLDivElement}
|
||||
@ -385,7 +373,7 @@ class CurrencySubForm {
|
||||
* The number
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
no;
|
||||
#no;
|
||||
|
||||
/**
|
||||
* The currency code
|
||||
@ -403,7 +391,7 @@ class CurrencySubForm {
|
||||
* The button to delete the currency
|
||||
* @type {HTMLButtonElement}
|
||||
*/
|
||||
deleteButton;
|
||||
#deleteButton;
|
||||
|
||||
/**
|
||||
* The debit sub-form
|
||||
@ -424,36 +412,54 @@ class CurrencySubForm {
|
||||
* @param element {HTMLDivElement} the currency sub-form element
|
||||
*/
|
||||
constructor(form, element) {
|
||||
this.element = element;
|
||||
this.#element = element;
|
||||
this.form = form;
|
||||
this.index = parseInt(this.element.dataset.index);
|
||||
this.#prefix = "accounting-currency-" + String(this.index);
|
||||
this.#control = document.getElementById(this.#prefix + "-control");
|
||||
this.#error = document.getElementById(this.#prefix + "-error");
|
||||
this.no = document.getElementById(this.#prefix + "-no");
|
||||
this.#code = document.getElementById(this.#prefix + "-code");
|
||||
this.#codeSelect = document.getElementById(this.#prefix + "-code-select");
|
||||
this.deleteButton = document.getElementById(this.#prefix + "-delete");
|
||||
const debitElement = document.getElementById(this.#prefix + "-debit");
|
||||
this.index = parseInt(this.#element.dataset.index);
|
||||
const prefix = `accounting-currency-${String(this.index)}`;
|
||||
this.#control = document.getElementById(`${prefix}-control`);
|
||||
this.#error = document.getElementById(`${prefix}-error`);
|
||||
this.#no = document.getElementById(`${prefix}-no`);
|
||||
this.#code = document.getElementById(`${prefix}-code`);
|
||||
this.#codeSelect = document.getElementById(`${prefix}-code-select`);
|
||||
this.#deleteButton = document.getElementById(`${prefix}-delete`);
|
||||
const debitElement = document.getElementById(`${prefix}-debit`);
|
||||
this.#debit = debitElement === null? null: new DebitCreditSubForm(this, debitElement, "debit");
|
||||
const creditElement = document.getElementById(this.#prefix + "-credit");
|
||||
const creditElement = document.getElementById(`${prefix}-credit`);
|
||||
this.#credit = creditElement == null? null: new DebitCreditSubForm(this, creditElement, "credit");
|
||||
this.#codeSelect.onchange = () => this.#code.value = this.#codeSelect.value;
|
||||
this.deleteButton.onclick = () => {
|
||||
this.element.parentElement.removeChild(this.element);
|
||||
this.#deleteButton.onclick = () => {
|
||||
this.#element.parentElement.removeChild(this.#element);
|
||||
this.form.deleteCurrency(this);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the order number.
|
||||
*
|
||||
*/
|
||||
resetNo() {
|
||||
const siblings = Array.from(this.#element.parentElement.children);
|
||||
this.#no.value = String(siblings.indexOf(this.#element) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currency code.
|
||||
*
|
||||
* @return {string} the currency code
|
||||
*/
|
||||
getCurrencyCode() {
|
||||
get currencyCode() {
|
||||
return this.#code.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the delete button is shown.
|
||||
*
|
||||
* @param isShown {boolean} true to show, or false otherwise
|
||||
*/
|
||||
setDeleteButtonShown(isShown) {
|
||||
setElementShown(this.#deleteButton, isShown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the line items in the form.
|
||||
*
|
||||
@ -479,7 +485,7 @@ class CurrencySubForm {
|
||||
updateCodeSelectorStatus() {
|
||||
let isEnabled = true;
|
||||
for (const lineItem of this.getLineItems()) {
|
||||
if (lineItem.getOriginalLineItemId() !== null) {
|
||||
if (lineItem.originalLineItemId !== null) {
|
||||
isEnabled = false;
|
||||
break;
|
||||
}
|
||||
@ -511,7 +517,7 @@ class CurrencySubForm {
|
||||
*/
|
||||
validateBalance() {
|
||||
if (this.#debit !== null && this.#credit !== null) {
|
||||
if (!this.#debit.getTotal().equals(this.#credit.getTotal())) {
|
||||
if (!this.#debit.total.equals(this.#credit.total)) {
|
||||
this.#control.classList.add("is-invalid");
|
||||
this.#error.innerText = A_("The totals of the debit and credit amounts do not match.");
|
||||
return false;
|
||||
@ -541,6 +547,12 @@ class DebitCreditSubForm {
|
||||
*/
|
||||
#element;
|
||||
|
||||
/**
|
||||
* The content
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#content;
|
||||
|
||||
/**
|
||||
* The currencyIndex
|
||||
* @type {number}
|
||||
@ -554,7 +566,7 @@ class DebitCreditSubForm {
|
||||
debitCredit;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* The prefix of the HTML ID and class names
|
||||
* @type {string}
|
||||
*/
|
||||
#prefix;
|
||||
@ -601,32 +613,45 @@ class DebitCreditSubForm {
|
||||
this.#element = element;
|
||||
this.#currencyIndex = currency.index;
|
||||
this.debitCredit = debitCredit;
|
||||
this.#prefix = "accounting-currency-" + String(this.#currencyIndex) + "-" + debitCredit;
|
||||
this.#error = document.getElementById(this.#prefix + "-error");
|
||||
this.#lineItemList = document.getElementById(this.#prefix + "-list");
|
||||
// noinspection JSValidateTypes
|
||||
this.#prefix = `accounting-currency-${String(this.#currencyIndex)}-${debitCredit}`;
|
||||
this.#content = document.getElementById(`${this.#prefix}-content`);
|
||||
this.#error = document.getElementById(`${this.#prefix}-error`);
|
||||
this.#lineItemList = document.getElementById(`${this.#prefix}-list`);
|
||||
this.lineItems = Array.from(document.getElementsByClassName(this.#prefix)).map((element) => new LineItemSubForm(this, element));
|
||||
this.#total = document.getElementById(this.#prefix + "-total");
|
||||
this.#addLineItemButton = document.getElementById(this.#prefix + "-add-line-item");
|
||||
this.#total = document.getElementById(`${this.#prefix}-total`);
|
||||
this.#addLineItemButton = document.getElementById(`${this.#prefix}-add-line-item`);
|
||||
|
||||
this.#resetContent();
|
||||
this.#addLineItemButton.onclick = () => this.currency.form.lineItemEditor.onAddNew(this);
|
||||
this.#resetDeleteLineItemButtons();
|
||||
this.#initializeDragAndDropReordering();
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback when the line item editor is closed.
|
||||
*
|
||||
*/
|
||||
onLineItemEditorClosed() {
|
||||
if (this.lineItems.length === 0) {
|
||||
this.#element.classList.remove("accounting-not-empty");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new line item sub-form
|
||||
*
|
||||
* @returns {LineItemSubForm} the newly-added line item sub-form
|
||||
*/
|
||||
addLineItem() {
|
||||
const newIndex = 1 + (this.lineItems.length === 0? 0: Math.max(...this.lineItems.map((lineItem) => lineItem.lineItemIndex)));
|
||||
const newIndex = 1 + (this.lineItems.length === 0? 0: Math.max(...this.lineItems.map((lineItem) => lineItem.index)));
|
||||
const html = this.currency.form.lineItemTemplate
|
||||
.replaceAll("CURRENCY_INDEX", escapeHtml(String(this.#currencyIndex)))
|
||||
.replaceAll("DEBIT_CREDIT", escapeHtml(this.debitCredit))
|
||||
.replaceAll("LINE_ITEM_INDEX", escapeHtml(String(newIndex)));
|
||||
this.#lineItemList.insertAdjacentHTML("beforeend", html);
|
||||
const lineItem = new LineItemSubForm(this, document.getElementById(this.#prefix + "-" + String(newIndex)));
|
||||
const lineItem = new LineItemSubForm(this, document.getElementById(`${this.#prefix}-${String(newIndex)}`));
|
||||
this.lineItems.push(lineItem);
|
||||
this.#resetContent();
|
||||
this.#resetDeleteLineItemButtons();
|
||||
this.#initializeDragAndDropReordering();
|
||||
this.validate();
|
||||
@ -644,6 +669,7 @@ class DebitCreditSubForm {
|
||||
this.updateTotal();
|
||||
this.currency.updateCodeSelectorStatus();
|
||||
this.currency.form.updateMinDate();
|
||||
this.#resetContent();
|
||||
this.#resetDeleteLineItemButtons();
|
||||
}
|
||||
|
||||
@ -653,16 +679,36 @@ class DebitCreditSubForm {
|
||||
*/
|
||||
#resetDeleteLineItemButtons() {
|
||||
if (this.lineItems.length === 1) {
|
||||
this.lineItems[0].deleteButton.classList.add("d-none");
|
||||
this.lineItems[0].setDeleteButtonShown(false);
|
||||
} else {
|
||||
for (const lineItem of this.lineItems) {
|
||||
if (lineItem.isMatched) {
|
||||
lineItem.deleteButton.classList.add("d-none");
|
||||
lineItem.setDeleteButtonShown(!lineItem.isMatched);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the layout of the content.
|
||||
*
|
||||
*/
|
||||
#resetContent() {
|
||||
if (this.lineItems.length === 0) {
|
||||
this.#element.classList.remove("accounting-not-empty");
|
||||
this.#element.classList.add("accounting-clickable");
|
||||
this.#element.dataset.bsToggle = "modal"
|
||||
this.#element.dataset.bsTarget = `#${this.currency.form.lineItemEditor.modal.id}`;
|
||||
this.#element.onclick = () => {
|
||||
this.#element.classList.add("accounting-not-empty");
|
||||
this.currency.form.lineItemEditor.onAddNew(this);
|
||||
};
|
||||
} else {
|
||||
lineItem.deleteButton.classList.remove("d-none");
|
||||
}
|
||||
}
|
||||
this.#element.classList.add("accounting-not-empty");
|
||||
this.#element.classList.remove("accounting-clickable");
|
||||
delete this.#element.dataset.bsToggle;
|
||||
delete this.#element.dataset.bsTarget;
|
||||
this.#element.onclick = null;
|
||||
}
|
||||
setElementShown(this.#content, this.lineItems.length !== 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -670,10 +716,10 @@ class DebitCreditSubForm {
|
||||
*
|
||||
* @return {Decimal} the total amount
|
||||
*/
|
||||
getTotal() {
|
||||
get total() {
|
||||
let total = new Decimal("0");
|
||||
for (const lineItem of this.lineItems) {
|
||||
const amount = lineItem.getAmount();
|
||||
const amount = lineItem.amount;
|
||||
if (amount !== null) {
|
||||
total = total.plus(amount);
|
||||
}
|
||||
@ -686,7 +732,7 @@ class DebitCreditSubForm {
|
||||
*
|
||||
*/
|
||||
updateTotal() {
|
||||
this.#total.innerText = formatDecimal(this.getTotal());
|
||||
this.#total.innerText = formatDecimal(this.total);
|
||||
this.currency.validateBalance();
|
||||
}
|
||||
|
||||
@ -696,10 +742,8 @@ class DebitCreditSubForm {
|
||||
*/
|
||||
#initializeDragAndDropReordering() {
|
||||
initializeDragAndDropReordering(this.#lineItemList, () => {
|
||||
const lineItemId = Array.from(this.#lineItemList.children).map((lineItem) => lineItem.id);
|
||||
this.lineItems.sort((a, b) => lineItemId.indexOf(a.element.id) - lineItemId.indexOf(b.element.id));
|
||||
for (let i = 0; i < this.lineItems.length; i++) {
|
||||
this.lineItems[i].no.value = String(i + 1);
|
||||
for (const lineItem of this.lineItems) {
|
||||
lineItem.resetNo();
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -735,6 +779,53 @@ class DebitCreditSubForm {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A journal entry account.
|
||||
*
|
||||
*/
|
||||
class JournalEntryAccount {
|
||||
|
||||
/**
|
||||
* The account code
|
||||
* @type {string}
|
||||
*/
|
||||
code;
|
||||
|
||||
/**
|
||||
* The account text
|
||||
* @type {string}
|
||||
*/
|
||||
text;
|
||||
|
||||
/**
|
||||
* Whether the line items in the account needs offset
|
||||
* @type {boolean}
|
||||
*/
|
||||
isNeedOffset;
|
||||
|
||||
/**
|
||||
* Constructs a journal entry account.
|
||||
*
|
||||
* @param code {string} the account code
|
||||
* @param text {string} the account text
|
||||
* @param isNeedOffset {boolean} true if the line items in the account needs offset, or false otherwise
|
||||
*/
|
||||
constructor(code, text, isNeedOffset) {
|
||||
this.code = code;
|
||||
this.text = text;
|
||||
this.isNeedOffset = isNeedOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of the account.
|
||||
*
|
||||
* @return {JournalEntryAccount} the copy of the account
|
||||
*/
|
||||
copy() {
|
||||
return new JournalEntryAccount(this.code, this.text, this.isNeedOffset);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The line item sub-form.
|
||||
*
|
||||
@ -751,7 +842,7 @@ class LineItemSubForm {
|
||||
* The element
|
||||
* @type {HTMLLIElement}
|
||||
*/
|
||||
element;
|
||||
#element;
|
||||
|
||||
/**
|
||||
* Either "debit" or "credit"
|
||||
@ -763,7 +854,7 @@ class LineItemSubForm {
|
||||
* The line item index
|
||||
* @type {number}
|
||||
*/
|
||||
lineItemIndex;
|
||||
index;
|
||||
|
||||
/**
|
||||
* Whether this is an original line item with offsets
|
||||
@ -771,12 +862,6 @@ class LineItemSubForm {
|
||||
*/
|
||||
isMatched;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* @type {string}
|
||||
*/
|
||||
#prefix;
|
||||
|
||||
/**
|
||||
* The control
|
||||
* @type {HTMLDivElement}
|
||||
@ -793,7 +878,7 @@ class LineItemSubForm {
|
||||
* The number
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
no;
|
||||
#no;
|
||||
|
||||
/**
|
||||
* The account code
|
||||
@ -853,7 +938,7 @@ class LineItemSubForm {
|
||||
* The button to delete line item
|
||||
* @type {HTMLButtonElement}
|
||||
*/
|
||||
deleteButton;
|
||||
#deleteButton;
|
||||
|
||||
/**
|
||||
* Constructs the line item sub-form.
|
||||
@ -863,38 +948,38 @@ class LineItemSubForm {
|
||||
*/
|
||||
constructor(debitCredit, element) {
|
||||
this.debitCreditSubForm = debitCredit;
|
||||
this.element = element;
|
||||
this.#element = element;
|
||||
this.debitCredit = element.dataset.debitCredit;
|
||||
this.lineItemIndex = parseInt(element.dataset.lineItemIndex);
|
||||
this.index = parseInt(element.dataset.lineItemIndex);
|
||||
this.isMatched = element.classList.contains("accounting-matched-line-item");
|
||||
this.#prefix = "accounting-currency-" + element.dataset.currencyIndex + "-" + this.debitCredit + "-" + this.lineItemIndex;
|
||||
this.#control = document.getElementById(this.#prefix + "-control");
|
||||
this.#error = document.getElementById(this.#prefix + "-error");
|
||||
this.no = document.getElementById(this.#prefix + "-no");
|
||||
this.#accountCode = document.getElementById(this.#prefix + "-account-code");
|
||||
this.#accountText = document.getElementById(this.#prefix + "-account-text");
|
||||
this.#description = document.getElementById(this.#prefix + "-description");
|
||||
this.#descriptionText = document.getElementById(this.#prefix + "-description-text");
|
||||
this.#originalLineItemId = document.getElementById(this.#prefix + "-original-line-item-id");
|
||||
this.#originalLineItemText = document.getElementById(this.#prefix + "-original-line-item-text");
|
||||
this.#offsets = document.getElementById(this.#prefix + "-offsets");
|
||||
this.#amount = document.getElementById(this.#prefix + "-amount");
|
||||
this.#amountText = document.getElementById(this.#prefix + "-amount-text");
|
||||
this.deleteButton = document.getElementById(this.#prefix + "-delete");
|
||||
const prefix = `accounting-currency-${element.dataset.currencyIndex}-${this.debitCredit}-${String(this.index)}`;
|
||||
this.#control = document.getElementById(`${prefix}-control`);
|
||||
this.#error = document.getElementById(`${prefix}-error`);
|
||||
this.#no = document.getElementById(`${prefix}-no`);
|
||||
this.#accountCode = document.getElementById(`${prefix}-account-code`);
|
||||
this.#accountText = document.getElementById(`${prefix}-account-text`);
|
||||
this.#description = document.getElementById(`${prefix}-description`);
|
||||
this.#descriptionText = document.getElementById(`${prefix}-description-text`);
|
||||
this.#originalLineItemId = document.getElementById(`${prefix}-original-line-item-id`);
|
||||
this.#originalLineItemText = document.getElementById(`${prefix}-original-line-item-text`);
|
||||
this.#offsets = document.getElementById(`${prefix}-offsets`);
|
||||
this.#amount = document.getElementById(`${prefix}-amount`);
|
||||
this.#amountText = document.getElementById(`${prefix}-amount-text`);
|
||||
this.#deleteButton = document.getElementById(`${prefix}-delete`);
|
||||
this.#control.onclick = () => this.debitCreditSubForm.currency.form.lineItemEditor.onEdit(this);
|
||||
this.deleteButton.onclick = () => {
|
||||
this.element.parentElement.removeChild(this.element);
|
||||
this.#deleteButton.onclick = () => {
|
||||
this.#element.parentElement.removeChild(this.#element);
|
||||
this.debitCreditSubForm.deleteLineItem(this);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the line item needs offset.
|
||||
* Reset the order number.
|
||||
*
|
||||
* @return {boolean} true if the line item needs offset, or false otherwise
|
||||
*/
|
||||
isNeedOffset() {
|
||||
return "isNeedOffset" in this.element.dataset;
|
||||
resetNo() {
|
||||
const siblings = Array.from(this.#element.parentElement.children);
|
||||
this.#no.value = String(siblings.indexOf(this.#element) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -902,7 +987,7 @@ class LineItemSubForm {
|
||||
*
|
||||
* @return {string|null} the ID of the original line item
|
||||
*/
|
||||
getOriginalLineItemId() {
|
||||
get originalLineItemId() {
|
||||
return this.#originalLineItemId.value === ""? null: this.#originalLineItemId.value;
|
||||
}
|
||||
|
||||
@ -911,7 +996,7 @@ class LineItemSubForm {
|
||||
*
|
||||
* @return {string|null} the date of the original line item
|
||||
*/
|
||||
getOriginalLineItemDate() {
|
||||
get originalLineItemDate() {
|
||||
return this.#originalLineItemId.dataset.date === ""? null: this.#originalLineItemId.dataset.date;
|
||||
}
|
||||
|
||||
@ -920,7 +1005,7 @@ class LineItemSubForm {
|
||||
*
|
||||
* @return {string|null} the text of the original line item
|
||||
*/
|
||||
getOriginalLineItemText() {
|
||||
get originalLineItemText() {
|
||||
return this.#originalLineItemId.dataset.text === ""? null: this.#originalLineItemId.dataset.text;
|
||||
}
|
||||
|
||||
@ -929,26 +1014,17 @@ class LineItemSubForm {
|
||||
*
|
||||
* @return {string|null} the description
|
||||
*/
|
||||
getDescription() {
|
||||
get description() {
|
||||
return this.#description.value === ""? null: this.#description.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the account code.
|
||||
* Returns the account.
|
||||
*
|
||||
* @return {string|null} the account code
|
||||
* @return {JournalEntryAccount|null} the account
|
||||
*/
|
||||
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;
|
||||
get account() {
|
||||
return this.#accountCode.value === null? null: new JournalEntryAccount(this.#accountCode.value, this.#accountCode.dataset.text, this.#accountCode.classList.contains("accounting-account-is-need-offset"));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -956,7 +1032,7 @@ class LineItemSubForm {
|
||||
*
|
||||
* @return {Decimal|null} the amount
|
||||
*/
|
||||
getAmount() {
|
||||
get amount() {
|
||||
return this.#amount.value === ""? null: new Decimal(this.#amount.value);
|
||||
}
|
||||
|
||||
@ -965,10 +1041,19 @@ class LineItemSubForm {
|
||||
*
|
||||
* @return {Decimal|null} the minimal amount
|
||||
*/
|
||||
getAmountMin() {
|
||||
get amountMin() {
|
||||
return this.#amount.dataset.min === ""? null: new Decimal(this.#amount.dataset.min);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the delete button is shown.
|
||||
*
|
||||
* @param isShown {boolean} true to show, or false otherwise
|
||||
*/
|
||||
setDeleteButtonShown(isShown) {
|
||||
setElementShown(this.#deleteButton, isShown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the form.
|
||||
*
|
||||
@ -996,24 +1081,24 @@ class LineItemSubForm {
|
||||
* @param editor {JournalEntryLineItemEditor} the line item editor
|
||||
*/
|
||||
save(editor) {
|
||||
if (editor.isNeedOffset) {
|
||||
this.#offsets.classList.remove("d-none");
|
||||
} else {
|
||||
this.#offsets.classList.add("d-none");
|
||||
}
|
||||
setElementShown(this.#offsets, editor.account.isNeedOffset);
|
||||
this.#originalLineItemId.value = editor.originalLineItemId === null? "": editor.originalLineItemId;
|
||||
this.#originalLineItemId.dataset.date = editor.originalLineItemDate === null? "": editor.originalLineItemDate;
|
||||
this.#originalLineItemId.dataset.text = editor.originalLineItemText === null? "": editor.originalLineItemText;
|
||||
setElementShown(this.#originalLineItemText, editor.originalLineItemText !== null);
|
||||
if (editor.originalLineItemText === null) {
|
||||
this.#originalLineItemText.classList.add("d-none");
|
||||
this.#originalLineItemText.innerText = "";
|
||||
} else {
|
||||
this.#originalLineItemText.classList.remove("d-none");
|
||||
this.#originalLineItemText.innerText = A_("Offset %(item)s", {item: editor.originalLineItemText});
|
||||
}
|
||||
this.#accountCode.value = editor.accountCode === null? "": editor.accountCode;
|
||||
this.#accountCode.dataset.text = editor.accountText === null? "": editor.accountText;
|
||||
this.#accountText.innerText = editor.accountText === null? "": editor.accountText;
|
||||
this.#accountCode.value = editor.account.code;
|
||||
this.#accountCode.dataset.text = editor.account.text;
|
||||
if (editor.account.isNeedOffset) {
|
||||
this.#accountCode.classList.add("accounting-account-is-need-offset");
|
||||
} else {
|
||||
this.#accountCode.classList.remove("accounting-account-is-need-offset");
|
||||
}
|
||||
this.#accountText.innerText = editor.account.text;
|
||||
this.#description.value = editor.description === null? "": editor.description;
|
||||
this.#descriptionText.innerText = editor.description === null? "": editor.description;
|
||||
this.#amount.value = editor.amount;
|
||||
@ -1054,3 +1139,18 @@ function formatDecimal(number) {
|
||||
const whole = Number(number.minus(frac)).toLocaleString();
|
||||
return whole + String(frac).substring(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether an element is shown.
|
||||
*
|
||||
* @param element {HTMLElement} the element
|
||||
* @param isShown {boolean} true to show, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function setElementShown(element, isShown) {
|
||||
if (isShown) {
|
||||
element.classList.remove("d-none");
|
||||
} else {
|
||||
element.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
|
@ -44,7 +44,7 @@ class JournalEntryLineItemEditor {
|
||||
* The bootstrap modal
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#modal;
|
||||
modal;
|
||||
|
||||
/**
|
||||
* Either "debit" or "credit"
|
||||
@ -53,7 +53,7 @@ class JournalEntryLineItemEditor {
|
||||
debitCredit;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* The prefix of the HTML ID and class names
|
||||
* @type {string}
|
||||
*/
|
||||
#prefix = "accounting-line-item-editor"
|
||||
@ -148,12 +148,6 @@ class JournalEntryLineItemEditor {
|
||||
*/
|
||||
#debitCreditSubForm;
|
||||
|
||||
/**
|
||||
* Whether the journal entry line item needs offset
|
||||
* @type {boolean}
|
||||
*/
|
||||
isNeedOffset = false;
|
||||
|
||||
/**
|
||||
* The ID of the original line item
|
||||
* @type {string|null}
|
||||
@ -173,16 +167,16 @@ class JournalEntryLineItemEditor {
|
||||
originalLineItemText = null;
|
||||
|
||||
/**
|
||||
* The account code
|
||||
* @type {string|null}
|
||||
* The account
|
||||
* @type {JournalEntryAccount|null}
|
||||
*/
|
||||
accountCode = null;
|
||||
account = null;
|
||||
|
||||
/**
|
||||
* The account text
|
||||
* @type {string|null}
|
||||
* Whether the user has confirmed the account
|
||||
* @type {boolean}
|
||||
*/
|
||||
accountText = null;
|
||||
isAccountConfirmed = false;
|
||||
|
||||
/**
|
||||
* The description
|
||||
@ -190,12 +184,6 @@ class JournalEntryLineItemEditor {
|
||||
*/
|
||||
description = null;
|
||||
|
||||
/**
|
||||
* The amount
|
||||
* @type {string}
|
||||
*/
|
||||
amount = "";
|
||||
|
||||
/**
|
||||
* The description editors
|
||||
* @type {{debit: DescriptionEditor, credit: DescriptionEditor}}
|
||||
@ -204,7 +192,7 @@ class JournalEntryLineItemEditor {
|
||||
|
||||
/**
|
||||
* The account selectors
|
||||
* @type {{debit: AccountSelector, credit: AccountSelector}}
|
||||
* @type {{debit: JournalEntryAccountSelector, credit: JournalEntryAccountSelector}}
|
||||
*/
|
||||
#accountSelectors;
|
||||
|
||||
@ -222,23 +210,24 @@ class JournalEntryLineItemEditor {
|
||||
constructor(form) {
|
||||
this.form = form;
|
||||
this.#element = document.getElementById(this.#prefix);
|
||||
this.#modal = document.getElementById(this.#prefix + "-modal");
|
||||
this.#originalLineItemContainer = document.getElementById(this.#prefix + "-original-line-item-container");
|
||||
this.#originalLineItemControl = document.getElementById(this.#prefix + "-original-line-item-control");
|
||||
this.#originalLineItemText = document.getElementById(this.#prefix + "-original-line-item");
|
||||
this.#originalLineItemError = document.getElementById(this.#prefix + "-original-line-item-error");
|
||||
this.#originalLineItemDelete = document.getElementById(this.#prefix + "-original-line-item-delete");
|
||||
this.#descriptionControl = document.getElementById(this.#prefix + "-description-control");
|
||||
this.#descriptionText = document.getElementById(this.#prefix + "-description");
|
||||
this.#descriptionError = document.getElementById(this.#prefix + "-description-error");
|
||||
this.#accountControl = document.getElementById(this.#prefix + "-account-control");
|
||||
this.#accountText = document.getElementById(this.#prefix + "-account");
|
||||
this.#accountError = document.getElementById(this.#prefix + "-account-error")
|
||||
this.#amountInput = document.getElementById(this.#prefix + "-amount");
|
||||
this.#amountError = document.getElementById(this.#prefix + "-amount-error");
|
||||
this.modal = document.getElementById(`${this.#prefix}-modal`);
|
||||
this.#originalLineItemContainer = document.getElementById(`${this.#prefix}-original-line-item-container`);
|
||||
this.#originalLineItemControl = document.getElementById(`${this.#prefix}-original-line-item-control`);
|
||||
this.#originalLineItemText = document.getElementById(`${this.#prefix}-original-line-item`);
|
||||
this.#originalLineItemError = document.getElementById(`${this.#prefix}-original-line-item-error`);
|
||||
this.#originalLineItemDelete = document.getElementById(`${this.#prefix}-original-line-item-delete`);
|
||||
this.#descriptionControl = document.getElementById(`${this.#prefix}-description-control`);
|
||||
this.#descriptionText = document.getElementById(`${this.#prefix}-description`);
|
||||
this.#descriptionError = document.getElementById(`${this.#prefix}-description-error`);
|
||||
this.#accountControl = document.getElementById(`${this.#prefix}-account-control`);
|
||||
this.#accountText = document.getElementById(`${this.#prefix}-account`);
|
||||
this.#accountError = document.getElementById(`${this.#prefix}-account-error`)
|
||||
this.#amountInput = document.getElementById(`${this.#prefix}-amount`);
|
||||
this.#amountError = document.getElementById(`${this.#prefix}-amount-error`);
|
||||
this.#descriptionEditors = DescriptionEditor.getInstances(this);
|
||||
this.#accountSelectors = AccountSelector.getInstances(this);
|
||||
this.#accountSelectors = JournalEntryAccountSelector.getInstances(this);
|
||||
this.originalLineItemSelector = new OriginalLineItemSelector(this);
|
||||
|
||||
this.#originalLineItemControl.onclick = () => this.originalLineItemSelector.onOpen()
|
||||
this.#originalLineItemDelete.onclick = () => this.clearOriginalLineItem();
|
||||
this.#descriptionControl.onclick = () => this.#descriptionEditors[this.debitCredit].onOpen();
|
||||
@ -249,12 +238,30 @@ class JournalEntryLineItemEditor {
|
||||
if (this.lineItem === null) {
|
||||
this.lineItem = this.#debitCreditSubForm.addLineItem();
|
||||
}
|
||||
this.amount = this.#amountInput.value;
|
||||
this.lineItem.save(this);
|
||||
bootstrap.Modal.getInstance(this.#modal).hide();
|
||||
bootstrap.Modal.getInstance(this.modal).hide();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
this.modal.addEventListener("hidden.bs.modal", () => this.#debitCreditSubForm.onLineItemEditorClosed());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the amount.
|
||||
*
|
||||
* @return {string} the amount
|
||||
*/
|
||||
get amount() {
|
||||
return this.#amountInput.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currency code.
|
||||
*
|
||||
* @return {string} the currency code
|
||||
*/
|
||||
get currencyCode() {
|
||||
return this.#debitCreditSubForm.currency.currencyCode;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -263,7 +270,6 @@ class JournalEntryLineItemEditor {
|
||||
* @param originalLineItem {OriginalLineItem} the original line item
|
||||
*/
|
||||
saveOriginalLineItem(originalLineItem) {
|
||||
this.isNeedOffset = false;
|
||||
this.#originalLineItemContainer.classList.remove("d-none");
|
||||
this.#originalLineItemControl.classList.add("accounting-not-empty");
|
||||
this.originalLineItemId = originalLineItem.id;
|
||||
@ -279,9 +285,9 @@ class JournalEntryLineItemEditor {
|
||||
this.description = originalLineItem.description === ""? null: originalLineItem.description;
|
||||
this.#descriptionText.innerText = originalLineItem.description;
|
||||
this.#accountControl.classList.add("accounting-not-empty");
|
||||
this.accountCode = originalLineItem.accountCode;
|
||||
this.accountText = originalLineItem.accountText;
|
||||
this.#accountText.innerText = originalLineItem.accountText;
|
||||
this.account = originalLineItem.account.copy();
|
||||
this.isAccountConfirmed = false;
|
||||
this.#accountText.innerText = this.account.text;
|
||||
this.#amountInput.value = String(originalLineItem.netBalance);
|
||||
this.#amountInput.max = String(originalLineItem.netBalance);
|
||||
this.#amountInput.min = "0";
|
||||
@ -293,7 +299,6 @@ class JournalEntryLineItemEditor {
|
||||
*
|
||||
*/
|
||||
clearOriginalLineItem() {
|
||||
this.isNeedOffset = false;
|
||||
this.#originalLineItemContainer.classList.add("d-none");
|
||||
this.#originalLineItemControl.classList.remove("accounting-not-empty");
|
||||
this.originalLineItemId = null;
|
||||
@ -302,54 +307,34 @@ class JournalEntryLineItemEditor {
|
||||
this.#originalLineItemText.innerText = "";
|
||||
this.#setEnableDescriptionAccount(true);
|
||||
this.#accountControl.classList.remove("accounting-not-empty");
|
||||
this.accountCode = null;
|
||||
this.accountText = null;
|
||||
this.account = null;
|
||||
this.isAccountConfirmed = false;
|
||||
this.#accountText.innerText = "";
|
||||
this.#amountInput.max = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currency code.
|
||||
*
|
||||
* @return {string} the currency code
|
||||
*/
|
||||
getCurrencyCode() {
|
||||
return this.#debitCreditSubForm.currency.getCurrencyCode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the description from the description editor.
|
||||
*
|
||||
* @param description {string} the description
|
||||
* @param editor {DescriptionEditor} the description editor
|
||||
*/
|
||||
saveDescription(description) {
|
||||
if (description === "") {
|
||||
saveDescription(editor) {
|
||||
if (editor.selectedAccount !== null) {
|
||||
this.#accountControl.classList.add("accounting-not-empty");
|
||||
this.account = editor.selectedAccount.copy();
|
||||
this.#accountText.innerText = editor.selectedAccount.text;
|
||||
this.isAccountConfirmed = editor.isAccountConfirmed;
|
||||
this.#validateAccount();
|
||||
}
|
||||
if (editor.description === "") {
|
||||
this.#descriptionControl.classList.remove("accounting-not-empty");
|
||||
} else {
|
||||
this.#descriptionControl.classList.add("accounting-not-empty");
|
||||
}
|
||||
this.description = description === ""? null: description;
|
||||
this.#descriptionText.innerText = description;
|
||||
this.description = editor.description === ""? null: editor.description;
|
||||
this.#descriptionText.innerText = editor.description;
|
||||
this.#validateDescription();
|
||||
bootstrap.Modal.getOrCreateInstance(this.#modal).show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the description with the suggested account from the description editor.
|
||||
*
|
||||
* @param description {string} the description
|
||||
* @param accountCode {string} the account code
|
||||
* @param accountText {string} the account text
|
||||
* @param isAccountNeedOffset {boolean} true if the line items in the account need offset, or false otherwise
|
||||
*/
|
||||
saveDescriptionWithAccount(description, accountCode, accountText, isAccountNeedOffset) {
|
||||
this.isNeedOffset = isAccountNeedOffset;
|
||||
this.#accountControl.classList.add("accounting-not-empty");
|
||||
this.accountCode = accountCode;
|
||||
this.accountText = accountText;
|
||||
this.#accountText.innerText = accountText;
|
||||
this.#validateAccount();
|
||||
this.saveDescription(description)
|
||||
bootstrap.Modal.getOrCreateInstance(this.modal).show();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -357,27 +342,23 @@ class JournalEntryLineItemEditor {
|
||||
*
|
||||
*/
|
||||
clearAccount() {
|
||||
this.isNeedOffset = false;
|
||||
this.#accountControl.classList.remove("accounting-not-empty");
|
||||
this.accountCode = null;
|
||||
this.accountText = null;
|
||||
this.account = null;
|
||||
this.isAccountConfirmed = false;
|
||||
this.#accountText.innerText = "";
|
||||
this.#validateAccount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the account.
|
||||
* Saves the selected account.
|
||||
*
|
||||
* @param code {string} the account code
|
||||
* @param text {string} the account text
|
||||
* @param isNeedOffset {boolean} true if the line items in the account need offset or false otherwise
|
||||
* @param account {JournalEntryAccountOption} the selected account
|
||||
*/
|
||||
saveAccount(code, text, isNeedOffset) {
|
||||
this.isNeedOffset = isNeedOffset;
|
||||
saveAccount(account) {
|
||||
this.#accountControl.classList.add("accounting-not-empty");
|
||||
this.accountCode = code;
|
||||
this.accountText = text;
|
||||
this.#accountText.innerText = text;
|
||||
this.account = new JournalEntryAccount(account.code, account.text, account.isNeedOffset);
|
||||
this.isAccountConfirmed = true;
|
||||
this.#accountText.innerText = account.text;
|
||||
this.#validateAccount();
|
||||
}
|
||||
|
||||
@ -425,7 +406,7 @@ class JournalEntryLineItemEditor {
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validateAccount() {
|
||||
if (this.accountCode === null) {
|
||||
if (this.account === null) {
|
||||
this.#accountControl.classList.add("is-invalid");
|
||||
this.#accountError.innerText = A_("Please select the account.");
|
||||
return false;
|
||||
@ -484,7 +465,6 @@ class JournalEntryLineItemEditor {
|
||||
this.lineItem = null;
|
||||
this.#debitCreditSubForm = debitCredit;
|
||||
this.debitCredit = this.#debitCreditSubForm.debitCredit;
|
||||
this.isNeedOffset = false;
|
||||
this.#originalLineItemContainer.classList.add("d-none");
|
||||
this.#originalLineItemControl.classList.remove("accounting-not-empty");
|
||||
this.#originalLineItemControl.classList.remove("is-invalid");
|
||||
@ -500,8 +480,8 @@ class JournalEntryLineItemEditor {
|
||||
this.#descriptionError.innerText = ""
|
||||
this.#accountControl.classList.remove("accounting-not-empty");
|
||||
this.#accountControl.classList.remove("is-invalid");
|
||||
this.accountCode = null;
|
||||
this.accountText = null;
|
||||
this.account = null;
|
||||
this.isAccountConfirmed = false;
|
||||
this.#accountText.innerText = "";
|
||||
this.#accountError.innerText = "";
|
||||
this.#amountInput.value = "";
|
||||
@ -520,10 +500,9 @@ class JournalEntryLineItemEditor {
|
||||
this.lineItem = lineItem;
|
||||
this.#debitCreditSubForm = lineItem.debitCreditSubForm;
|
||||
this.debitCredit = this.#debitCreditSubForm.debitCredit;
|
||||
this.isNeedOffset = lineItem.isNeedOffset();
|
||||
this.originalLineItemId = lineItem.getOriginalLineItemId();
|
||||
this.originalLineItemDate = lineItem.getOriginalLineItemDate();
|
||||
this.originalLineItemText = lineItem.getOriginalLineItemText();
|
||||
this.originalLineItemId = lineItem.originalLineItemId;
|
||||
this.originalLineItemDate = lineItem.originalLineItemDate;
|
||||
this.originalLineItemText = lineItem.originalLineItemText;
|
||||
this.#originalLineItemText.innerText = this.originalLineItemText;
|
||||
if (this.originalLineItemId === null) {
|
||||
this.#originalLineItemContainer.classList.add("d-none");
|
||||
@ -533,25 +512,25 @@ class JournalEntryLineItemEditor {
|
||||
this.#originalLineItemControl.classList.add("accounting-not-empty");
|
||||
}
|
||||
this.#setEnableDescriptionAccount(!lineItem.isMatched && this.originalLineItemId === null);
|
||||
this.description = lineItem.getDescription();
|
||||
this.description = lineItem.description;
|
||||
if (this.description === null) {
|
||||
this.#descriptionControl.classList.remove("accounting-not-empty");
|
||||
} else {
|
||||
this.#descriptionControl.classList.add("accounting-not-empty");
|
||||
}
|
||||
this.#descriptionText.innerText = this.description === null? "": this.description;
|
||||
if (lineItem.getAccountCode() === null) {
|
||||
this.account = lineItem.account;
|
||||
this.isAccountConfirmed = true;
|
||||
if (this.account === null) {
|
||||
this.#accountControl.classList.remove("accounting-not-empty");
|
||||
} else {
|
||||
this.#accountControl.classList.add("accounting-not-empty");
|
||||
}
|
||||
this.accountCode = lineItem.getAccountCode();
|
||||
this.accountText = lineItem.getAccountText();
|
||||
this.#accountText.innerText = this.accountText;
|
||||
this.#amountInput.value = lineItem.getAmount() === null? "": String(lineItem.getAmount());
|
||||
this.#accountText.innerText = this.account.text;
|
||||
this.#amountInput.value = lineItem.amount === null? "": String(lineItem.amount);
|
||||
const maxAmount = this.#getMaxAmount();
|
||||
this.#amountInput.max = maxAmount === null? "": maxAmount;
|
||||
this.#amountInput.min = lineItem.getAmountMin() === null? "": String(lineItem.getAmountMin());
|
||||
this.#amountInput.min = lineItem.amountMin === null? "": String(lineItem.amountMin);
|
||||
this.#validate();
|
||||
}
|
||||
|
||||
@ -575,11 +554,11 @@ class JournalEntryLineItemEditor {
|
||||
#setEnableDescriptionAccount(isEnabled) {
|
||||
if (isEnabled) {
|
||||
this.#descriptionControl.dataset.bsToggle = "modal";
|
||||
this.#descriptionControl.dataset.bsTarget = "#accounting-description-editor-" + this.#debitCreditSubForm.debitCredit + "-modal";
|
||||
this.#descriptionControl.dataset.bsTarget = `#accounting-description-editor-${this.#debitCreditSubForm.debitCredit}-modal`;
|
||||
this.#descriptionControl.classList.remove("accounting-disabled");
|
||||
this.#descriptionControl.classList.add("accounting-clickable");
|
||||
this.#accountControl.dataset.bsToggle = "modal";
|
||||
this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + this.#debitCreditSubForm.debitCredit + "-modal";
|
||||
this.#accountControl.dataset.bsTarget = `#accounting-account-selector-${this.#debitCreditSubForm.debitCredit}-modal`;
|
||||
this.#accountControl.classList.remove("accounting-disabled");
|
||||
this.#accountControl.classList.add("accounting-clickable");
|
||||
} else {
|
||||
@ -594,3 +573,4 @@ class JournalEntryLineItemEditor {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -29,7 +29,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const onReorder = () => {
|
||||
const accounts = Array.from(list.children);
|
||||
for (let i = 0; i < accounts.length; i++) {
|
||||
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
|
||||
const no = document.getElementById(`accounting-order-${accounts[i].dataset.id}-no`);
|
||||
no.value = String(i + 1);
|
||||
}
|
||||
};
|
||||
|
@ -197,7 +197,7 @@ class RecurringExpenseIncomeSubForm {
|
||||
editor;
|
||||
|
||||
/**
|
||||
* The prefix of HTML ID and class
|
||||
* The prefix of the HTML ID and class names
|
||||
* @type {string}
|
||||
*/
|
||||
#prefix;
|
||||
@ -242,12 +242,12 @@ class RecurringExpenseIncomeSubForm {
|
||||
this.#form = form;
|
||||
this.expenseIncome = expenseIncome;
|
||||
this.editor = new RecurringItemEditor(this);
|
||||
this.#prefix = "accounting-recurring-" + expenseIncome;
|
||||
this.#prefix = `accounting-recurring-${expenseIncome}`;
|
||||
this.#element = document.getElementById(this.#prefix);
|
||||
this.#content = document.getElementById(this.#prefix + "-content");
|
||||
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.#content = document.getElementById(`${this.#prefix}-content`);
|
||||
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.#resetContent();
|
||||
this.#addButton.onclick = () => this.editor.onAddNew();
|
||||
@ -265,7 +265,7 @@ class RecurringExpenseIncomeSubForm {
|
||||
.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 element = document.getElementById(`${this.#prefix}-${String(newIndex)}`)
|
||||
const item = new RecurringItemSubForm(this, element);
|
||||
this.#items.push(item);
|
||||
this.#resetContent();
|
||||
@ -294,7 +294,7 @@ class RecurringExpenseIncomeSubForm {
|
||||
this.#element.classList.remove("accounting-not-empty");
|
||||
this.#element.classList.add("accounting-clickable");
|
||||
this.#element.dataset.bsToggle = "modal"
|
||||
this.#element.dataset.bsTarget = "#" + this.editor.modal.id;
|
||||
this.#element.dataset.bsTarget = `#${this.editor.modal.id}`;
|
||||
this.#element.onclick = () => this.editor.onAddNew();
|
||||
this.#content.classList.add("d-none");
|
||||
} else {
|
||||
@ -313,10 +313,8 @@ class RecurringExpenseIncomeSubForm {
|
||||
*/
|
||||
#initializeDragAndDropReordering() {
|
||||
initializeDragAndDropReordering(this.#itemList, () => {
|
||||
const itemId = Array.from(this.#itemList.children).map((item) => item.id);
|
||||
this.#items.sort((a, b) => itemId.indexOf(a.element.id) - itemId.indexOf(b.element.id));
|
||||
for (let i = 0; i < this.#items.length; i++) {
|
||||
this.#items[i].no.value = String(i + 1);
|
||||
for (const item of this.#items) {
|
||||
item.resetNo();
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -365,7 +363,7 @@ class RecurringItemSubForm {
|
||||
* The element
|
||||
* @type {HTMLLIElement}
|
||||
*/
|
||||
element;
|
||||
#element;
|
||||
|
||||
/**
|
||||
* The item index
|
||||
@ -386,10 +384,10 @@ class RecurringItemSubForm {
|
||||
#error;
|
||||
|
||||
/**
|
||||
* The number
|
||||
* The order number
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
no;
|
||||
#no;
|
||||
|
||||
/**
|
||||
* The name input
|
||||
@ -441,33 +439,42 @@ class RecurringItemSubForm {
|
||||
*/
|
||||
constructor(expenseIncomeSubForm, element) {
|
||||
this.#expenseIncomeSubForm = expenseIncomeSubForm
|
||||
this.element = element;
|
||||
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.no = document.getElementById(prefix + "-no");
|
||||
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");
|
||||
const prefix = `accounting-recurring-${expenseIncomeSubForm.expenseIncome}-${element.dataset.itemIndex}`;
|
||||
this.#control = document.getElementById(`${prefix}-control`);
|
||||
this.#error = document.getElementById(`${prefix}-error`);
|
||||
this.#no = document.getElementById(`${prefix}-no`);
|
||||
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.#element.parentElement.removeChild(this.#element);
|
||||
this.#expenseIncomeSubForm.deleteItem(this);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the order number.
|
||||
*
|
||||
*/
|
||||
resetNo() {
|
||||
const siblings = Array.from(this.#element.parentElement.children);
|
||||
this.#no.value = String(siblings.indexOf(this.#element) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name.
|
||||
*
|
||||
* @return {string|null} the name
|
||||
*/
|
||||
getName() {
|
||||
get name() {
|
||||
return this.#name.value === ""? null: this.#name.value;
|
||||
}
|
||||
|
||||
@ -476,7 +483,7 @@ class RecurringItemSubForm {
|
||||
*
|
||||
* @return {string|null} the account code
|
||||
*/
|
||||
getAccountCode() {
|
||||
get accountCode() {
|
||||
return this.#accountCode.value === ""? null: this.#accountCode.value;
|
||||
}
|
||||
|
||||
@ -485,7 +492,7 @@ class RecurringItemSubForm {
|
||||
*
|
||||
* @return {string|null} the account text
|
||||
*/
|
||||
getAccountText() {
|
||||
get accountText() {
|
||||
return this.#accountCode.dataset.text === ""? null: this.#accountCode.dataset.text;
|
||||
}
|
||||
|
||||
@ -494,7 +501,7 @@ class RecurringItemSubForm {
|
||||
*
|
||||
* @return {string|null} the description template
|
||||
*/
|
||||
getDescriptionTemplate() {
|
||||
get descriptionTemplate() {
|
||||
return this.#descriptionTemplate.value === ""? null: this.#descriptionTemplate.value;
|
||||
}
|
||||
|
||||
@ -504,12 +511,12 @@ class RecurringItemSubForm {
|
||||
* @param editor {RecurringItemEditor} the recurring item editor
|
||||
*/
|
||||
save(editor) {
|
||||
this.#name.value = editor.getName() === null? "": editor.getName();
|
||||
this.#name.value = editor.name === null? "": editor.name;
|
||||
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.#descriptionTemplate.value = editor.descriptionTemplate === null? "": editor.descriptionTemplate;
|
||||
this.#descriptionTemplateText.innerText = this.#descriptionTemplate.value;
|
||||
this.validate();
|
||||
}
|
||||
@ -645,16 +652,16 @@ class RecurringItemEditor {
|
||||
constructor(subForm) {
|
||||
this.#subForm = subForm;
|
||||
this.expenseIncome = subForm.expenseIncome;
|
||||
const prefix = "accounting-recurring-item-editor-" + 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.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();
|
||||
@ -677,7 +684,7 @@ class RecurringItemEditor {
|
||||
*
|
||||
* @return {string|null} the name
|
||||
*/
|
||||
getName() {
|
||||
get name() {
|
||||
return this.#name.value === ""? null: this.#name.value;
|
||||
}
|
||||
|
||||
@ -686,7 +693,7 @@ class RecurringItemEditor {
|
||||
*
|
||||
* @return {string|null} the description template
|
||||
*/
|
||||
getDescriptionTemplate() {
|
||||
get descriptionTemplate() {
|
||||
return this.#descriptionTemplate.value === ""? null: this.#descriptionTemplate.value;
|
||||
}
|
||||
|
||||
@ -742,16 +749,16 @@ class RecurringItemEditor {
|
||||
*/
|
||||
onEdit(item) {
|
||||
this.#item = item;
|
||||
this.#name.value = item.getName() === null? "": item.getName();
|
||||
this.accountCode = item.getAccountCode();
|
||||
this.accountText = item.getAccountText();
|
||||
this.#name.value = item.name === null? "": item.name;
|
||||
this.accountCode = item.accountCode;
|
||||
this.accountText = item.accountText;
|
||||
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.#accountContainer.innerText = this.accountText === null? "": this.accountText;
|
||||
this.#descriptionTemplate.value = item.descriptionTemplate === null? "": item.descriptionTemplate;
|
||||
this.#validate();
|
||||
}
|
||||
|
||||
@ -875,12 +882,12 @@ class RecurringAccountSelector {
|
||||
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");
|
||||
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();
|
||||
@ -891,16 +898,16 @@ class RecurringAccountSelector {
|
||||
*
|
||||
*/
|
||||
#filterOptions() {
|
||||
let hasAnyMatched = false;
|
||||
let isAnyMatched = false;
|
||||
for (const option of this.#options) {
|
||||
if (option.isMatched(this.#query.value)) {
|
||||
option.setShown(true);
|
||||
hasAnyMatched = true;
|
||||
isAnyMatched = true;
|
||||
} else {
|
||||
option.setShown(false);
|
||||
}
|
||||
}
|
||||
if (!hasAnyMatched) {
|
||||
if (!isAnyMatched) {
|
||||
this.#optionList.classList.add("d-none");
|
||||
this.#queryNoResult.classList.remove("d-none");
|
||||
} else {
|
||||
@ -916,7 +923,6 @@ class RecurringAccountSelector {
|
||||
onOpen() {
|
||||
this.#query.value = "";
|
||||
this.#filterOptions();
|
||||
console.log(this.editor.accountCode);
|
||||
for (const option of this.#options) {
|
||||
option.setActive(option.code === this.editor.accountCode);
|
||||
}
|
||||
@ -938,12 +944,6 @@ class RecurringAccountSelector {
|
||||
*/
|
||||
class RecurringAccount {
|
||||
|
||||
/**
|
||||
* The account selector for the recurring item editor
|
||||
* @type {RecurringAccountSelector}
|
||||
*/
|
||||
#selector;
|
||||
|
||||
/**
|
||||
* The element
|
||||
* @type {HTMLLIElement}
|
||||
@ -975,13 +975,12 @@ class RecurringAccount {
|
||||
* @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);
|
||||
this.#element.onclick = () => selector.editor.saveAccount(this);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -35,7 +35,7 @@ class OriginalLineItemSelector {
|
||||
lineItemEditor;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* The prefix of the HTML ID and class names
|
||||
* @type {string}
|
||||
*/
|
||||
#prefix = "accounting-original-line-item-selector";
|
||||
@ -88,17 +88,15 @@ class OriginalLineItemSelector {
|
||||
*/
|
||||
constructor(lineItemEditor) {
|
||||
this.lineItemEditor = lineItemEditor;
|
||||
this.#query = document.getElementById(this.#prefix + "-query");
|
||||
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
|
||||
this.#optionList = document.getElementById(this.#prefix + "-option-list");
|
||||
this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option")).map((element) => new OriginalLineItem(this, element));
|
||||
this.#query = document.getElementById(`${this.#prefix}-query`);
|
||||
this.#queryNoResult = document.getElementById(`${this.#prefix}-option-no-result`);
|
||||
this.#optionList = document.getElementById(`${this.#prefix}-option-list`);
|
||||
this.#options = Array.from(document.getElementsByClassName(`${this.#prefix}-option`)).map((element) => new OriginalLineItem(this, element));
|
||||
this.#optionById = {};
|
||||
for (const option of this.#options) {
|
||||
this.#optionById[option.id] = option;
|
||||
}
|
||||
this.#query.addEventListener("input", () => {
|
||||
this.#filterOptions();
|
||||
});
|
||||
this.#query.oninput = () => this.#filterOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -113,8 +111,8 @@ class OriginalLineItemSelector {
|
||||
const otherLineItems = form.getLineItems().filter((lineItem) => lineItem !== currentLineItem);
|
||||
let otherOffset = new Decimal(0);
|
||||
for (const otherLineItem of otherLineItems) {
|
||||
if (otherLineItem.getOriginalLineItemId() === originalLineItemId) {
|
||||
const amount = otherLineItem.getAmount();
|
||||
if (otherLineItem.originalLineItemId === originalLineItemId) {
|
||||
const amount = otherLineItem.amount;
|
||||
if (amount !== null) {
|
||||
otherOffset = otherOffset.plus(amount);
|
||||
}
|
||||
@ -131,8 +129,8 @@ class OriginalLineItemSelector {
|
||||
const otherLineItems = this.lineItemEditor.form.getLineItems().filter((lineItem) => lineItem !== this.lineItemEditor.lineItem);
|
||||
const otherOffsets = {}
|
||||
for (const otherLineItem of otherLineItems) {
|
||||
const otherOriginalLineItemId = otherLineItem.getOriginalLineItemId();
|
||||
const amount = otherLineItem.getAmount();
|
||||
const otherOriginalLineItemId = otherLineItem.originalLineItemId;
|
||||
const amount = otherLineItem.amount;
|
||||
if (otherOriginalLineItemId === null || amount === null) {
|
||||
continue;
|
||||
}
|
||||
@ -155,16 +153,16 @@ class OriginalLineItemSelector {
|
||||
*
|
||||
*/
|
||||
#filterOptions() {
|
||||
let hasAnyMatched = false;
|
||||
let isAnyMatched = false;
|
||||
for (const option of this.#options) {
|
||||
if (option.isMatched(this.#debitCredit, this.#currencyCode, this.#query.value)) {
|
||||
option.setShown(true);
|
||||
hasAnyMatched = true;
|
||||
isAnyMatched = true;
|
||||
} else {
|
||||
option.setShown(false);
|
||||
}
|
||||
}
|
||||
if (!hasAnyMatched) {
|
||||
if (!isAnyMatched) {
|
||||
this.#optionList.classList.add("d-none");
|
||||
this.#queryNoResult.classList.remove("d-none");
|
||||
} else {
|
||||
@ -178,7 +176,7 @@ class OriginalLineItemSelector {
|
||||
*
|
||||
*/
|
||||
onOpen() {
|
||||
this.#currencyCode = this.lineItemEditor.getCurrencyCode();
|
||||
this.#currencyCode = this.lineItemEditor.currencyCode;
|
||||
this.#debitCredit = this.lineItemEditor.debitCredit;
|
||||
for (const option of this.#options) {
|
||||
option.setActive(option.id === this.lineItemEditor.originalLineItemId);
|
||||
@ -196,10 +194,10 @@ class OriginalLineItemSelector {
|
||||
class OriginalLineItem {
|
||||
|
||||
/**
|
||||
* The original line item selector
|
||||
* @type {OriginalLineItemSelector}
|
||||
* The journal entry form
|
||||
* @type {JournalEntryForm}
|
||||
*/
|
||||
#selector;
|
||||
#form;
|
||||
|
||||
/**
|
||||
* The element
|
||||
@ -232,16 +230,10 @@ class OriginalLineItem {
|
||||
#currencyCode;
|
||||
|
||||
/**
|
||||
* The account code
|
||||
* @type {string}
|
||||
* The account
|
||||
* @type {JournalEntryAccount}
|
||||
*/
|
||||
accountCode;
|
||||
|
||||
/**
|
||||
* The account text
|
||||
* @type {string}
|
||||
*/
|
||||
accountText;
|
||||
account;
|
||||
|
||||
/**
|
||||
* The description
|
||||
@ -275,7 +267,7 @@ class OriginalLineItem {
|
||||
|
||||
/**
|
||||
* The values to query against
|
||||
* @type {string[][]}
|
||||
* @type {string[]}
|
||||
*/
|
||||
#queryValues;
|
||||
|
||||
@ -286,21 +278,20 @@ class OriginalLineItem {
|
||||
* @param element {HTMLLIElement} the element
|
||||
*/
|
||||
constructor(selector, element) {
|
||||
this.#selector = selector;
|
||||
this.#form = selector.lineItemEditor.form;
|
||||
this.#element = element;
|
||||
this.id = element.dataset.id;
|
||||
this.date = element.dataset.date;
|
||||
this.#debitCredit = element.dataset.debitCredit;
|
||||
this.#currencyCode = element.dataset.currencyCode;
|
||||
this.accountCode = element.dataset.accountCode;
|
||||
this.accountText = element.dataset.accountText;
|
||||
this.account = new JournalEntryAccount(element.dataset.accountCode, element.dataset.accountText, false);
|
||||
this.description = element.dataset.description;
|
||||
this.bareNetBalance = new Decimal(element.dataset.netBalance);
|
||||
this.netBalance = this.bareNetBalance;
|
||||
this.netBalanceText = document.getElementById("accounting-original-line-item-selector-option-" + this.id + "-net-balance");
|
||||
this.netBalanceText = document.getElementById(`accounting-original-line-item-selector-option-${this.id}-net-balance`);
|
||||
this.text = element.dataset.text;
|
||||
this.#queryValues = JSON.parse(element.dataset.queryValues);
|
||||
this.#element.onclick = () => this.#selector.lineItemEditor.saveOriginalLineItem(this);
|
||||
this.#element.onclick = () => selector.lineItemEditor.saveOriginalLineItem(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -341,10 +332,10 @@ class OriginalLineItem {
|
||||
*/
|
||||
isMatched(debitCredit, currencyCode, query = null) {
|
||||
return this.netBalance.greaterThan(0)
|
||||
&& this.date <= this.#selector.lineItemEditor.form.getDate()
|
||||
&& this.#isDebitCreditMatches(debitCredit)
|
||||
&& this.date <= this.#form.date
|
||||
&& this.#isDebitCreditMatched(debitCredit)
|
||||
&& this.#currencyCode === currencyCode
|
||||
&& this.#isQueryMatches(query);
|
||||
&& this.#isQueryMatched(query);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -353,34 +344,43 @@ class OriginalLineItem {
|
||||
* @param debitCredit {string} either "debit" or credit
|
||||
* @return {boolean} true if the option matches, or false otherwise
|
||||
*/
|
||||
#isDebitCreditMatches(debitCredit) {
|
||||
#isDebitCreditMatched(debitCredit) {
|
||||
return (debitCredit === "debit" && this.#debitCredit === "credit")
|
||||
|| (debitCredit === "credit" && this.#debitCredit === "debit");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the original line item matches the query.
|
||||
* Returns whether the original line item matches the query term.
|
||||
*
|
||||
* @param query {string|null} the query term
|
||||
* @return {boolean} true if the option matches, or false otherwise
|
||||
*/
|
||||
#isQueryMatches(query) {
|
||||
#isQueryMatched(query) {
|
||||
if (query === "") {
|
||||
return true;
|
||||
}
|
||||
for (const queryValue of this.#queryValues[0]) {
|
||||
if (this.#getNetBalanceForQuery().includes(query.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
for (const queryValue of this.#queryValues) {
|
||||
if (queryValue.toLowerCase().includes(query.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const queryValue of this.#queryValues[1]) {
|
||||
if (queryValue === query) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the net balance in the format for query match.
|
||||
*
|
||||
* @return {string} the net balance in the format for query match
|
||||
*/
|
||||
#getNetBalanceForQuery() {
|
||||
const frac = this.netBalance.modulo(1);
|
||||
const whole = Number(this.netBalance.minus(frac));
|
||||
return String(whole) + String(frac).substring(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the option is shown.
|
||||
*
|
||||
|
@ -33,12 +33,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
*/
|
||||
class PeriodChooser {
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* @type {string}
|
||||
*/
|
||||
prefix;
|
||||
|
||||
/**
|
||||
* The modal of the period chooser
|
||||
* @type {HTMLDivElement}
|
||||
@ -56,8 +50,8 @@ class PeriodChooser {
|
||||
*
|
||||
*/
|
||||
constructor() {
|
||||
this.prefix = "accounting-period-chooser";
|
||||
this.modal = document.getElementById(this.prefix + "-modal");
|
||||
const prefix = "accounting-period-chooser";
|
||||
this.modal = document.getElementById(`${prefix}-modal`);
|
||||
for (const cls of [MonthTab, YearTab, DayTab, CustomTab]) {
|
||||
const tab = new cls(this);
|
||||
this.tabPlanes[tab.tabId()] = tab;
|
||||
@ -94,7 +88,7 @@ class TabPlane {
|
||||
chooser;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* The prefix of the HTML ID and class names
|
||||
* @type {string}
|
||||
*/
|
||||
prefix;
|
||||
@ -118,9 +112,9 @@ class TabPlane {
|
||||
*/
|
||||
constructor(chooser) {
|
||||
this.chooser = chooser;
|
||||
this.prefix = "accounting-period-chooser-" + this.tabId();
|
||||
this.#tab = document.getElementById(this.prefix + "-tab");
|
||||
this.#page = document.getElementById(this.prefix + "-page");
|
||||
this.prefix = `accounting-period-chooser-${this.tabId()}`;
|
||||
this.#tab = document.getElementById(`${this.prefix}-tab`);
|
||||
this.#page = document.getElementById(`${this.prefix}-page`);
|
||||
this.#tab.onclick = () => this.#switchToMe();
|
||||
}
|
||||
|
||||
@ -170,12 +164,11 @@ class MonthTab extends TabPlane {
|
||||
*/
|
||||
constructor(chooser) {
|
||||
super(chooser);
|
||||
const monthChooser = document.getElementById(this.prefix + "-chooser");
|
||||
const monthChooser = document.getElementById(`${this.prefix}-chooser`);
|
||||
if (monthChooser !== null) {
|
||||
let start = monthChooser.dataset.start;
|
||||
this.#monthChooser = new tempusDominus.TempusDominus(monthChooser, {
|
||||
restrictions: {
|
||||
minDate: new Date(start),
|
||||
minDate: new Date(monthChooser.dataset.start),
|
||||
},
|
||||
display: {
|
||||
inline: true,
|
||||
@ -188,9 +181,8 @@ class MonthTab extends TabPlane {
|
||||
});
|
||||
monthChooser.addEventListener(tempusDominus.Namespace.events.change, (e) => {
|
||||
const date = e.detail.date;
|
||||
const year = date.year;
|
||||
const month = date.month + 1;
|
||||
const period = month < 10? year + "-0" + month: year + "-" + month;
|
||||
const zeroPaddedMonth = `0${date.month + 1}`.slice(-2)
|
||||
const period = `${date.year}-${zeroPaddedMonth}`;
|
||||
window.location = chooser.modal.dataset.urlTemplate
|
||||
.replaceAll("PERIOD", period);
|
||||
});
|
||||
@ -250,8 +242,8 @@ class DayTab extends TabPlane {
|
||||
*/
|
||||
constructor(chooser) {
|
||||
super(chooser);
|
||||
this.#date = document.getElementById(this.prefix + "-date");
|
||||
this.#dateError = document.getElementById(this.prefix + "-date-error");
|
||||
this.#date = document.getElementById(`${this.prefix}-date`);
|
||||
this.#dateError = document.getElementById(`${this.prefix}-date-error`);
|
||||
if (this.#date !== null) {
|
||||
this.#date.onchange = () => {
|
||||
if (this.#validateDate()) {
|
||||
@ -337,11 +329,11 @@ class CustomTab extends TabPlane {
|
||||
*/
|
||||
constructor(chooser) {
|
||||
super(chooser);
|
||||
this.#start = document.getElementById(this.prefix + "-start");
|
||||
this.#startError = document.getElementById(this.prefix + "-start-error");
|
||||
this.#end = document.getElementById(this.prefix + "-end");
|
||||
this.#endError = document.getElementById(this.prefix + "-end-error");
|
||||
this.#conform = document.getElementById(this.prefix + "-confirm");
|
||||
this.#start = document.getElementById(`${this.prefix}-start`);
|
||||
this.#startError = document.getElementById(`${this.prefix}-start-error`);
|
||||
this.#end = document.getElementById(`${this.prefix}-end`);
|
||||
this.#endError = document.getElementById(`${this.prefix}-end-error`);
|
||||
this.#conform = document.getElementById(`${this.prefix}-confirm`);
|
||||
if (this.#start !== null) {
|
||||
this.#start.onchange = () => {
|
||||
if (this.#validateStart()) {
|
||||
@ -359,7 +351,7 @@ class CustomTab extends TabPlane {
|
||||
isValid = this.#validateEnd() && isValid;
|
||||
if (isValid) {
|
||||
window.location = chooser.modal.dataset.urlTemplate
|
||||
.replaceAll("PERIOD", this.#start.value + "-" + this.#end.value);
|
||||
.replaceAll("PERIOD", `${this.#start.value}-${this.#end.value}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -37,7 +37,7 @@ First written: 2023/2/25
|
||||
|
||||
<ul id="accounting-account-selector-{{ debit_credit }}-option-list" class="list-group accounting-selector-list">
|
||||
{% for account in account_options %}
|
||||
<li id="accounting-account-selector-{{ debit_credit }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ debit_credit }}-option {% if account.is_in_use %} accounting-account-in-use {% endif %} {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" data-code="{{ account.code }}" data-text="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
|
||||
<li id="accounting-account-selector-{{ debit_credit }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ debit_credit }}-option {% if account.is_in_use %} accounting-account-is-in-use {% endif %} {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" data-code="{{ account.code }}" data-text="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
|
||||
{{ account }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
@ -182,8 +182,9 @@ First written: 2023/2/28
|
||||
|
||||
{# The suggested accounts #}
|
||||
<div class="mt-3">
|
||||
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-account-confirmed" class="btn btn-primary mb-1 d-none" type="button"></button>
|
||||
{% for account in description_editor.accounts %}
|
||||
<button class="btn btn-outline-primary d-none accounting-description-editor-{{ description_editor.debit_credit }}-account {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" type="button" data-code="{{ account.code }}" data-text="{{ account }}">
|
||||
<button class="btn btn-outline-primary mb-1 d-none accounting-description-editor-{{ description_editor.debit_credit }}-account {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" type="button" data-code="{{ account.code }}" data-text="{{ account }}">
|
||||
{{ account }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
|
@ -20,8 +20,9 @@ Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/21
|
||||
#}
|
||||
<div class="mb-2">
|
||||
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}">
|
||||
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}" class="form-control accounting-material-text-field {% if line_item_forms %} accounting-not-empty {% else %} accounting-clickable {% endif %} {% if debit_errors %} is-invalid {% endif %}">
|
||||
<label class="form-label" for="accounting-currency-{{ currency_index }}-{{ debit_credit }}">{{ header }}</label>
|
||||
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-content" class="mt-2 {% if not line_item_forms %} d-none {% endif %}">
|
||||
<ul id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-list" class="list-group accounting-line-item-list">
|
||||
{% for line_item_form in line_item_forms %}
|
||||
{% with currency_index = currency_index,
|
||||
@ -33,7 +34,7 @@ First written: 2023/3/21
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<div class="d-flex justify-content-between mt-2 mb-2">
|
||||
<div>{{ A_("Total") }}</div>
|
||||
<div><span id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-total" class="badge rounded-pill bg-primary">{{ debit_credit_total }}</span></div>
|
||||
</div>
|
||||
@ -45,5 +46,6 @@ First written: 2023/3/21
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-error" class="invalid-feedback">{% if debit_credit_errors %}{{ debit_credit_errors[0] }}{% endif %}</div>
|
||||
</div>
|
||||
|
@ -20,13 +20,13 @@ Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/2/25
|
||||
#}
|
||||
{# <ul> For SonarQube not to complain about incorrect HTML #}
|
||||
<li id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-currency-{{ currency_index }}-{{ debit_credit }} {% if form.offsets %} accounting-matched-line-item {% endif %}" data-currency-index="{{ currency_index }}" data-debit-credit="{{ debit_credit }}" data-line-item-index="{{ line_item_index }}" {% if form.is_need_offset %} data-is-need-offset="true" {% endif %}>
|
||||
<li id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-currency-{{ currency_index }}-{{ debit_credit }} {% if form.offsets %} accounting-matched-line-item {% endif %}" data-currency-index="{{ currency_index }}" data-debit-credit="{{ debit_credit }}" data-line-item-index="{{ line_item_index }}">
|
||||
{% if form.id.data %}
|
||||
<input type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-id" value="{{ form.id.data }}">
|
||||
{% endif %}
|
||||
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-no" value="{{ line_item_index }}">
|
||||
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original-line-item-id" class="accounting-original-line-item-id" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original_line_item_id" value="{{ form.original_line_item_id.data|accounting_default }}" data-date="{{ form.original_line_item_date|accounting_default }}" data-text="{{ form.original_line_item_text|accounting_default }}">
|
||||
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account-code" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account_code" value="{{ form.account_code.data|accounting_default }}" data-text="{{ form.account_text }}">
|
||||
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account-code" class="{% if form.is_need_offset %} accounting-account-is-need-offset {% endif %}" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account_code" value="{{ form.account_code.data|accounting_default }}" data-text="{{ form.account_text }}">
|
||||
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description" value="{{ form.description.data|accounting_default }}">
|
||||
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount" value="{{ form.amount.data|accounting_journal_entry_format_amount_input }}" data-min="{{ form.offset_total|accounting_default("0") }}">
|
||||
<div class="accounting-line-item-content">
|
||||
|
@ -25,7 +25,7 @@ First written: 2023/2/26
|
||||
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/journal-entry-form.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/journal-entry-line-item-editor.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/journal-entry-account-selector.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/original-line-item-selector.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/description-editor.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
@ -38,26 +38,49 @@ First written: 2023/3/22
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
<input id="accounting-default-currency" class="form-control" value="{{ obj.default_currency_text }}" readonly="readonly">
|
||||
<label class="form-label" for="accounting-default-currency">{{ A_("Default Currency") }}</label>
|
||||
</div>
|
||||
<table class="table table-striped table-hover table-light" aria-label="{{ A_("Settings") }}">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">{{ A_("Default Currency") }}</th>
|
||||
<td>{{ obj.default_currency_text }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{{ A_("Default Account for the Income and Expenses Log") }}</th>
|
||||
<td>{{ obj.default_ie_account_code_text }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
<input id="accounting-default-ie-account" class="form-control" value="{{ obj.default_ie_account_code_text }}" readonly="readonly">
|
||||
<label class="form-label" for="accounting-default-ie-account">{{ A_("Default Account for the Income and Expenses Log") }}</label>
|
||||
</div>
|
||||
<h2>{{ A_("Recurring Expense") }}</h2>
|
||||
|
||||
{% with expense_income = "expense",
|
||||
label = A_("Recurring Expense"),
|
||||
recurring_items = obj.recurring.expenses %}
|
||||
{% include "accounting/option/include/detail-recurring-expense-income.html" %}
|
||||
{% endwith %}
|
||||
{% if obj.recurring.expenses %}
|
||||
<ul class="list-group mb-3 accounting-list-group-stripped accounting-list-group-hover">
|
||||
{% for recurring_item in obj.recurring.expenses %}
|
||||
<li class="list-group-item">
|
||||
<div class="small">{{ recurring_item.account_text }}</div>
|
||||
<div>{{ recurring_item.name }}</div>
|
||||
<div class="small">{{ recurring_item.description_template }}</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>{{ A_("There is no data.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% with expense_income = "income",
|
||||
label = A_("Recurring Income"),
|
||||
recurring_items = obj.recurring.incomes %}
|
||||
{% include "accounting/option/include/detail-recurring-expense-income.html" %}
|
||||
{% endwith %}
|
||||
<h2>{{ A_("Recurring Income") }}</h2>
|
||||
|
||||
{% if obj.recurring.incomes %}
|
||||
<ul class="list-group mb-3 accounting-list-group-stripped accounting-list-group-hover">
|
||||
{% for recurring_item in obj.recurring.incomes %}
|
||||
<li class="list-group-item">
|
||||
<div class="small">{{ recurring_item.account_text }}</div>
|
||||
<div>{{ recurring_item.name }}</div>
|
||||
<div class="small">{{ recurring_item.description_template }}</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>{{ A_("There is no data.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
@ -1,31 +0,0 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
detail-recurring-expense-income.html: The recurring expense or income in the option detail
|
||||
|
||||
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
|
||||
#}
|
||||
<div id="accounting-recurring-{{ expense_income }}" class="form-control mb-3 accounting-material-text-field {% if recurring_items %} accounting-not-empty {% endif %}">
|
||||
<label class="form-label" for="accounting-recurring-{{ expense_income }}">{{ label }}</label>
|
||||
{% if recurring_items %}
|
||||
<ul class="list-group mb-2 mt-2">
|
||||
{% for item in recurring_items %}
|
||||
{% include "accounting/option/include/detail-recurring-item.html" %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
@ -1,28 +0,0 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
detail-recurring-item.html: The recurring item in the option detail
|
||||
|
||||
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
|
||||
#}
|
||||
{# <ul> For SonarQube not to complain about incorrect HTML #}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="small">{{ item.account_text }}</div>
|
||||
<div>{{ item.name }}</div>
|
||||
<div class="small">{{ item.description_template }}</div>
|
||||
</li>
|
||||
{# </ul> For SonarQube not to complain about incorrect HTML #}
|
@ -21,7 +21,7 @@ This module should not import any other module from the application.
|
||||
"""
|
||||
import typing as t
|
||||
|
||||
from flask import abort, Blueprint
|
||||
from flask import abort, Blueprint, Response
|
||||
|
||||
from accounting.utils.user import get_current_user, UserUtilityInterface
|
||||
|
||||
@ -49,6 +49,10 @@ def has_permission(rule: t.Callable[[], bool]) -> t.Callable:
|
||||
:raise Forbidden: When the user is denied.
|
||||
"""
|
||||
if not rule():
|
||||
if get_current_user() is None:
|
||||
response: Response | None = _unauthorized_func()
|
||||
if response is not None:
|
||||
return response
|
||||
abort(403)
|
||||
return view(*args, **kwargs)
|
||||
|
||||
@ -66,6 +70,9 @@ data."""
|
||||
__can_admin_func: t.Callable[[], bool] = lambda: True
|
||||
"""The callback that returns whether the current user can administrate the
|
||||
accounting settings."""
|
||||
_unauthorized_func: t.Callable[[], Response | None] \
|
||||
= lambda: Response(status=403)
|
||||
"""The callback that returns the response to require the user to log in."""
|
||||
|
||||
|
||||
def can_view() -> bool:
|
||||
@ -111,10 +118,12 @@ def init_app(bp: Blueprint, user_utils: UserUtilityInterface) -> None:
|
||||
:param user_utils: The user utilities.
|
||||
:return: None.
|
||||
"""
|
||||
global __can_view_func, __can_edit_func, __can_admin_func
|
||||
global __can_view_func, __can_edit_func, __can_admin_func, \
|
||||
_unauthorized_func
|
||||
__can_view_func = user_utils.can_view
|
||||
__can_edit_func = user_utils.can_edit
|
||||
__can_admin_func = user_utils.can_admin
|
||||
_unauthorized_func = user_utils.unauthorized
|
||||
bp.add_app_template_global(user_utils.can_view, "accounting_can_view")
|
||||
bp.add_app_template_global(user_utils.can_edit, "accounting_can_edit")
|
||||
bp.add_app_template_global(user_utils.can_admin, "accounting_can_admin")
|
||||
|
@ -23,7 +23,7 @@ import typing as t
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import g
|
||||
from flask import g, Response
|
||||
from flask_sqlalchemy.model import Model
|
||||
|
||||
T = t.TypeVar("T", bound=Model)
|
||||
@ -59,6 +59,17 @@ class UserUtilityInterface(t.Generic[T], ABC):
|
||||
accounting settings, or False otherwise.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def unauthorized(self) -> Response | None:
|
||||
"""Returns the response to require the user to log in.
|
||||
|
||||
This may be a redirection to the login page, or an HTTP 401
|
||||
Unauthorized response for HTTP Authentication. If this returns None,
|
||||
an HTTP 403 Forbidden response is return to the user.
|
||||
|
||||
:return: The response to require the user to log in.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def cls(self) -> t.Type[T]:
|
||||
|
@ -27,12 +27,9 @@ from flask import Flask
|
||||
from flask.testing import FlaskCliRunner
|
||||
|
||||
from test_site import db
|
||||
from testlib import create_test_app, get_client, set_locale
|
||||
from testlib import NEXT_URI, create_test_app, get_client, set_locale
|
||||
from testlib_journal_entry import add_journal_entry
|
||||
|
||||
NEXT_URI: str = "/_next"
|
||||
"""The next URI."""
|
||||
|
||||
|
||||
class AccountData:
|
||||
"""The account data."""
|
||||
@ -550,8 +547,8 @@ class AccountTestCase(unittest.TestCase):
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import Account
|
||||
editor_username, editor2_username = "editor", "editor2"
|
||||
client, csrf_token = get_client(self.app, editor2_username)
|
||||
editor_username, admin_username = "editor", "admin"
|
||||
client, csrf_token = get_client(self.app, admin_username)
|
||||
detail_uri: str = f"{PREFIX}/{CASH.code}"
|
||||
update_uri: str = f"{PREFIX}/{CASH.code}/update"
|
||||
account: Account
|
||||
@ -574,7 +571,7 @@ class AccountTestCase(unittest.TestCase):
|
||||
self.assertEqual(account.created_by.username,
|
||||
editor_username)
|
||||
self.assertEqual(account.updated_by.username,
|
||||
editor2_username)
|
||||
admin_username)
|
||||
|
||||
def test_l10n(self) -> None:
|
||||
"""Tests the localization.
|
||||
|
@ -28,8 +28,8 @@ from flask import Flask
|
||||
from flask.testing import FlaskCliRunner
|
||||
|
||||
from test_site import db
|
||||
from testlib import create_test_app, get_client, set_locale
|
||||
from testlib_journal_entry import add_journal_entry, NEXT_URI
|
||||
from testlib import NEXT_URI, create_test_app, get_client, set_locale
|
||||
from testlib_journal_entry import add_journal_entry
|
||||
|
||||
|
||||
class CurrencyData:
|
||||
@ -471,8 +471,8 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import Currency
|
||||
editor_username, editor2_username = "editor", "editor2"
|
||||
client, csrf_token = get_client(self.app, editor2_username)
|
||||
editor_username, admin_username = "editor", "admin"
|
||||
client, csrf_token = get_client(self.app, admin_username)
|
||||
detail_uri: str = f"{PREFIX}/{USD.code}"
|
||||
update_uri: str = f"{PREFIX}/{USD.code}/update"
|
||||
currency: Currency
|
||||
@ -493,7 +493,7 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
with self.app.app_context():
|
||||
currency = db.session.get(Currency, USD.code)
|
||||
self.assertEqual(currency.created_by.username, editor_username)
|
||||
self.assertEqual(currency.updated_by.username, editor2_username)
|
||||
self.assertEqual(currency.updated_by.username, admin_username)
|
||||
|
||||
def test_api_exists(self) -> None:
|
||||
"""Tests the API to check if a code exists.
|
||||
|
@ -24,8 +24,8 @@ from click.testing import Result
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskCliRunner
|
||||
|
||||
from testlib import create_test_app, get_client
|
||||
from testlib_journal_entry import Accounts, NEXT_URI, add_journal_entry
|
||||
from testlib import NEXT_URI, Accounts, create_test_app, get_client
|
||||
from testlib_journal_entry import add_journal_entry
|
||||
|
||||
|
||||
class DescriptionEditorTestCase(unittest.TestCase):
|
||||
|
@ -27,9 +27,9 @@ from flask import Flask
|
||||
from flask.testing import FlaskCliRunner
|
||||
|
||||
from test_site import db
|
||||
from testlib import create_test_app, get_client
|
||||
from testlib_journal_entry import NEXT_URI, NON_EMPTY_NOTE, EMPTY_NOTE, \
|
||||
Accounts, get_add_form, get_unchanged_update_form, get_update_form, \
|
||||
from testlib import NEXT_URI, Accounts, create_test_app, get_client
|
||||
from testlib_journal_entry import NON_EMPTY_NOTE, EMPTY_NOTE, \
|
||||
get_add_form, get_unchanged_update_form, get_update_form, \
|
||||
match_journal_entry_detail, set_negative_amount, \
|
||||
remove_debit_in_a_currency, remove_credit_in_a_currency, add_journal_entry
|
||||
|
||||
@ -537,8 +537,8 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
|
||||
from accounting.models import JournalEntry
|
||||
journal_entry_id: int \
|
||||
= add_journal_entry(self.client, self.__get_add_form())
|
||||
editor_username, editor2_username = "editor", "editor2"
|
||||
client, csrf_token = get_client(self.app, editor2_username)
|
||||
editor_username, admin_username = "editor", "admin"
|
||||
client, csrf_token = get_client(self.app, admin_username)
|
||||
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
|
||||
update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
|
||||
journal_entry: JournalEntry
|
||||
@ -562,7 +562,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
|
||||
self.assertEqual(journal_entry.created_by.username,
|
||||
editor_username)
|
||||
self.assertEqual(journal_entry.updated_by.username,
|
||||
editor2_username)
|
||||
admin_username)
|
||||
|
||||
def test_delete(self) -> None:
|
||||
"""Tests to delete a journal entry.
|
||||
@ -1163,8 +1163,8 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
|
||||
from accounting.models import JournalEntry
|
||||
journal_entry_id: int \
|
||||
= add_journal_entry(self.client, self.__get_add_form())
|
||||
editor_username, editor2_username = "editor", "editor2"
|
||||
client, csrf_token = get_client(self.app, editor2_username)
|
||||
editor_username, admin_username = "editor", "admin"
|
||||
client, csrf_token = get_client(self.app, admin_username)
|
||||
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
|
||||
update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
|
||||
journal_entry: JournalEntry
|
||||
@ -1188,7 +1188,7 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
|
||||
self.assertEqual(journal_entry.created_by.username,
|
||||
editor_username)
|
||||
self.assertEqual(journal_entry.updated_by.username,
|
||||
editor2_username)
|
||||
admin_username)
|
||||
|
||||
def test_delete(self) -> None:
|
||||
"""Tests to delete a journal entry.
|
||||
@ -1837,8 +1837,8 @@ class TransferJournalEntryTestCase(unittest.TestCase):
|
||||
from accounting.models import JournalEntry
|
||||
journal_entry_id: int \
|
||||
= add_journal_entry(self.client, self.__get_add_form())
|
||||
editor_username, editor2_username = "editor", "editor2"
|
||||
client, csrf_token = get_client(self.app, editor2_username)
|
||||
editor_username, admin_username = "editor", "admin"
|
||||
client, csrf_token = get_client(self.app, admin_username)
|
||||
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
|
||||
update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
|
||||
journal_entry: JournalEntry
|
||||
@ -1862,7 +1862,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
|
||||
self.assertEqual(journal_entry.created_by.username,
|
||||
editor_username)
|
||||
self.assertEqual(journal_entry.updated_by.username,
|
||||
editor2_username)
|
||||
admin_username)
|
||||
|
||||
def test_save_as_receipt(self) -> None:
|
||||
"""Tests to save a transfer journal entry as a cash receipt journal
|
||||
|
@ -26,8 +26,8 @@ from flask import Flask
|
||||
from flask.testing import FlaskCliRunner
|
||||
|
||||
from test_site import db
|
||||
from testlib import create_test_app, get_client
|
||||
from testlib_journal_entry import Accounts, match_journal_entry_detail
|
||||
from testlib import Accounts, create_test_app, get_client
|
||||
from testlib_journal_entry import match_journal_entry_detail
|
||||
from testlib_offset import TestData, JournalEntryLineItemData, \
|
||||
JournalEntryData, CurrencyData
|
||||
|
||||
|
@ -26,8 +26,7 @@ from flask import Flask
|
||||
from flask.testing import FlaskCliRunner
|
||||
|
||||
from test_site import db
|
||||
from testlib import create_test_app, get_client
|
||||
from testlib_journal_entry import NEXT_URI, Accounts
|
||||
from testlib import NEXT_URI, Accounts, create_test_app, get_client
|
||||
from testlib_offset import TestData
|
||||
|
||||
PREFIX: str = "/accounting/options"
|
||||
@ -68,7 +67,7 @@ class OptionTestCase(unittest.TestCase):
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
Option.query.delete()
|
||||
|
||||
self.client, self.csrf_token = get_client(self.app, "editor")
|
||||
self.client, self.csrf_token = get_client(self.app, "admin")
|
||||
self.data: TestData = TestData(self.app, self.client, self.csrf_token)
|
||||
|
||||
def test_nobody(self) -> None:
|
||||
@ -105,12 +104,12 @@ class OptionTestCase(unittest.TestCase):
|
||||
response = client.post(UPDATE_URI, data=self.__get_form(csrf_token))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_editor2(self) -> None:
|
||||
"""Test the permission as non-administrator.
|
||||
def test_editor(self) -> None:
|
||||
"""Test the permission as editor.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
client, csrf_token = get_client(self.app, "editor2")
|
||||
client, csrf_token = get_client(self.app, "editor")
|
||||
response: httpx.Response
|
||||
|
||||
response = client.get(DETAIL_URI)
|
||||
@ -122,7 +121,7 @@ class OptionTestCase(unittest.TestCase):
|
||||
response = client.post(UPDATE_URI, data=self.__get_form(csrf_token))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_editor(self) -> None:
|
||||
def test_admin(self) -> None:
|
||||
"""Test the permission as administrator.
|
||||
|
||||
:return: None.
|
||||
@ -344,7 +343,7 @@ class OptionTestCase(unittest.TestCase):
|
||||
"""
|
||||
from accounting.models import Option
|
||||
from accounting.utils.user import get_user_pk
|
||||
editor_username, editor2_username = "editor", "editor2"
|
||||
admin_username, editor_username = "admin", "editor"
|
||||
option: Option | None
|
||||
response: httpx.Response
|
||||
|
||||
@ -353,11 +352,11 @@ class OptionTestCase(unittest.TestCase):
|
||||
self.assertEqual(response.headers["Location"], DETAIL_URI)
|
||||
|
||||
with self.app.app_context():
|
||||
editor2_pk: int = get_user_pk(editor2_username)
|
||||
editor_pk: int = get_user_pk(editor_username)
|
||||
option = db.session.get(Option, "recurring")
|
||||
self.assertIsNotNone(option)
|
||||
option.created_by_id = editor2_pk
|
||||
option.updated_by_id = editor2_pk
|
||||
option.created_by_id = editor_pk
|
||||
option.updated_by_id = editor_pk
|
||||
db.session.commit()
|
||||
|
||||
form: dict[str, str] = self.__get_form()
|
||||
@ -372,8 +371,8 @@ class OptionTestCase(unittest.TestCase):
|
||||
with self.app.app_context():
|
||||
option = db.session.get(Option, "recurring")
|
||||
self.assertIsNotNone(option)
|
||||
self.assertEqual(option.created_by.username, editor2_username)
|
||||
self.assertEqual(option.updated_by.username, editor_username)
|
||||
self.assertEqual(option.created_by.username, editor_username)
|
||||
self.assertEqual(option.updated_by.username, admin_username)
|
||||
|
||||
def __get_form(self, csrf_token: str | None = None) -> dict[str, str]:
|
||||
"""Returns the option form.
|
||||
|
@ -22,7 +22,7 @@ import typing as t
|
||||
from secrets import token_urlsafe
|
||||
|
||||
import click
|
||||
from flask import Flask, Blueprint, render_template
|
||||
from flask import Flask, Blueprint, render_template, redirect, Response
|
||||
from flask.cli import with_appcontext
|
||||
from flask_babel_js import BabelJS
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
@ -72,15 +72,18 @@ def create_app(is_testing: bool = False) -> Flask:
|
||||
def can_view(self) -> bool:
|
||||
return auth.current_user() is not None \
|
||||
and auth.current_user().username in ["viewer", "editor",
|
||||
"editor2"]
|
||||
"admin"]
|
||||
|
||||
def can_edit(self) -> bool:
|
||||
return auth.current_user() is not None \
|
||||
and auth.current_user().username in ["editor", "editor2"]
|
||||
and auth.current_user().username in ["editor", "admin"]
|
||||
|
||||
def can_admin(self) -> bool:
|
||||
return auth.current_user() is not None \
|
||||
and auth.current_user().username == "editor"
|
||||
and auth.current_user().username == "admin"
|
||||
|
||||
def unauthorized(self) -> Response:
|
||||
return redirect("/login")
|
||||
|
||||
@property
|
||||
def cls(self) -> t.Type[auth.User]:
|
||||
@ -112,7 +115,7 @@ def init_db_command() -> None:
|
||||
"""Initializes the database."""
|
||||
db.create_all()
|
||||
from .auth import User
|
||||
for username in ["viewer", "editor", "editor2", "nobody"]:
|
||||
for username in ["viewer", "editor", "admin", "nobody"]:
|
||||
if User.query.filter(User.username == username).first() is None:
|
||||
db.session.add(User(username=username))
|
||||
db.session.commit()
|
||||
|
@ -58,8 +58,8 @@ def login() -> redirect:
|
||||
|
||||
:return: The redirection to the home page.
|
||||
"""
|
||||
if request.form.get("username") not in ["viewer", "editor", "editor2",
|
||||
"nobody"]:
|
||||
if request.form.get("username") not in {"viewer", "editor", "admin",
|
||||
"nobody"}:
|
||||
return redirect(url_for("auth.login"))
|
||||
session["user"] = request.form.get("username")
|
||||
return redirect(url_for("home.home"))
|
||||
|
@ -29,7 +29,7 @@ First written: 2023/1/27
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-primary" type="submit" name="username" value="viewer">{{ _("Viewer") }}</button>
|
||||
<button class="btn btn-primary" type="submit" name="username" value="editor">{{ _("Editor") }}</button>
|
||||
<button class="btn btn-primary" type="submit" name="username" value="editor2">{{ _("Editor2") }}</button>
|
||||
<button class="btn btn-primary" type="submit" name="username" value="admin">{{ _("Administrator") }}</button>
|
||||
<button class="btn btn-primary" type="submit" name="username" value="nobody">{{ _("Nobody") }}</button>
|
||||
</form>
|
||||
|
||||
|
@ -9,8 +9,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n"
|
||||
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
|
||||
"POT-Creation-Date: 2023-02-27 10:07+0800\n"
|
||||
"PO-Revision-Date: 2023-02-27 10:08+0800\n"
|
||||
"POT-Creation-Date: 2023-03-24 08:32+0800\n"
|
||||
"PO-Revision-Date: 2023-03-24 08:33+0800\n"
|
||||
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
|
||||
"Language: zh_Hant\n"
|
||||
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
|
||||
@ -18,27 +18,27 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.11.0\n"
|
||||
"Generated-By: Babel 2.12.1\n"
|
||||
|
||||
#: tests/test_site/templates/base.html:23
|
||||
msgid "en"
|
||||
msgstr "zh-Hant"
|
||||
|
||||
#: tests/test_site/templates/base.html:43
|
||||
#: tests/test_site/templates/base.html:46
|
||||
#: tests/test_site/templates/home.html:24
|
||||
msgid "Home"
|
||||
msgstr "首頁"
|
||||
|
||||
#: tests/test_site/templates/base.html:68
|
||||
#: tests/test_site/templates/base.html:71
|
||||
msgid "Log Out"
|
||||
msgstr "登出"
|
||||
|
||||
#: tests/test_site/templates/base.html:78
|
||||
#: tests/test_site/templates/base.html:81
|
||||
#: tests/test_site/templates/login.html:24
|
||||
msgid "Log In"
|
||||
msgstr "登入"
|
||||
|
||||
#: tests/test_site/templates/base.html:119
|
||||
#: tests/test_site/templates/base.html:122
|
||||
msgid "Error:"
|
||||
msgstr "錯誤:"
|
||||
|
||||
@ -51,8 +51,8 @@ msgid "Editor"
|
||||
msgstr "記帳者"
|
||||
|
||||
#: tests/test_site/templates/login.html:32
|
||||
msgid "Editor2"
|
||||
msgstr "記帳者2"
|
||||
msgid "Administrator"
|
||||
msgstr "管理者"
|
||||
|
||||
#: tests/test_site/templates/login.html:33
|
||||
msgid "Nobody"
|
||||
|
@ -26,6 +26,33 @@ from test_site import create_app
|
||||
|
||||
TEST_SERVER: str = "https://testserver"
|
||||
"""The test server URI."""
|
||||
NEXT_URI: str = "/_next"
|
||||
"""The next URI."""
|
||||
|
||||
|
||||
class Accounts:
|
||||
"""The shortcuts to the common accounts."""
|
||||
CASH: str = "1111-001"
|
||||
PETTY_CASH: str = "1112-001"
|
||||
BANK: str = "1113-001"
|
||||
NOTES_RECEIVABLE: str = "1131-001"
|
||||
RECEIVABLE: str = "1141-001"
|
||||
PREPAID: str = "1258-001"
|
||||
NOTES_PAYABLE: str = "2131-001"
|
||||
PAYABLE: str = "2141-001"
|
||||
SALES: str = "4111-001"
|
||||
SERVICE: str = "4611-001"
|
||||
AGENCY: str = "4711-001"
|
||||
RENT_EXPENSE: str = "6252-001"
|
||||
OFFICE: str = "6253-001"
|
||||
TRAVEL: str = "6254-001"
|
||||
POSTAGE: str = "6256-001"
|
||||
UTILITIES: str = "6261-001"
|
||||
INSURANCE: str = "6262-001"
|
||||
MEAL: str = "6272-001"
|
||||
INTEREST: str = "7111-001"
|
||||
DONATION: str = "7481-001"
|
||||
RENT_INCOME: str = "7482-001"
|
||||
|
||||
|
||||
def create_test_app() -> Flask:
|
||||
@ -57,7 +84,6 @@ def get_csrf_token(client: httpx.Client) -> str:
|
||||
return client.get("/.csrf-token").text
|
||||
|
||||
|
||||
|
||||
def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]:
|
||||
"""Returns a user client.
|
||||
|
||||
|
@ -26,40 +26,14 @@ import httpx
|
||||
from flask import Flask
|
||||
|
||||
from test_site import db
|
||||
from testlib import NEXT_URI, Accounts
|
||||
|
||||
NEXT_URI: str = "/_next"
|
||||
"""The next URI."""
|
||||
NON_EMPTY_NOTE: str = " This is \n\na test."
|
||||
"""The stripped content of an non-empty note."""
|
||||
EMPTY_NOTE: str = " \n\n "
|
||||
"""The empty note content."""
|
||||
|
||||
|
||||
class Accounts:
|
||||
"""The shortcuts to the common accounts."""
|
||||
CASH: str = "1111-001"
|
||||
PETTY_CASH: str = "1112-001"
|
||||
BANK: str = "1113-001"
|
||||
NOTES_RECEIVABLE: str = "1131-001"
|
||||
RECEIVABLE: str = "1141-001"
|
||||
PREPAID: str = "1258-001"
|
||||
NOTES_PAYABLE: str = "2131-001"
|
||||
PAYABLE: str = "2141-001"
|
||||
SALES: str = "4111-001"
|
||||
SERVICE: str = "4611-001"
|
||||
AGENCY: str = "4711-001"
|
||||
RENT_EXPENSE: str = "6252-001"
|
||||
OFFICE: str = "6253-001"
|
||||
TRAVEL: str = "6254-001"
|
||||
POSTAGE: str = "6256-001"
|
||||
UTILITIES: str = "6261-001"
|
||||
INSURANCE: str = "6262-001"
|
||||
MEAL: str = "6272-001"
|
||||
INTEREST: str = "7111-001"
|
||||
DONATION: str = "7481-001"
|
||||
RENT_INCOME: str = "7482-001"
|
||||
|
||||
|
||||
def get_add_form(csrf_token: str) -> dict[str, str]:
|
||||
"""Returns the form data to add a new journal entry.
|
||||
|
||||
|
@ -26,8 +26,8 @@ import httpx
|
||||
from flask import Flask
|
||||
|
||||
from test_site import db
|
||||
from testlib_journal_entry import Accounts, match_journal_entry_detail, \
|
||||
NEXT_URI
|
||||
from testlib import NEXT_URI, Accounts
|
||||
from testlib_journal_entry import match_journal_entry_detail
|
||||
|
||||
|
||||
class JournalEntryLineItemData:
|
||||
|
Reference in New Issue
Block a user