diff --git a/src/accounting/models.py b/src/accounting/models.py index 3068005..befb901 100644 --- a/src/accounting/models.py +++ b/src/accounting/models.py @@ -21,6 +21,7 @@ from __future__ import annotations import re import typing as t +from datetime import date from decimal import Decimal import sqlalchemy as sa @@ -568,6 +569,21 @@ class Transaction(db.Model): return False return True + @property + def can_delete(self) -> bool: + """Returns whether the transaction can be deleted. + + :return: True if the transaction can be deleted, or False otherwise. + """ + if not hasattr(self, "__can_delete"): + def has_offset() -> bool: + for entry in self.entries: + if len(entry.offsets) > 0: + return True + return False + setattr(self, "__can_delete", not has_offset()) + return getattr(self, "__can_delete") + def delete(self) -> None: """Deletes the transaction. @@ -624,6 +640,21 @@ class JournalEntry(db.Model): amount = db.Column(db.Numeric(14, 2), nullable=False) """The amount.""" + def __str__(self) -> str: + """Returns the string representation of the journal entry. + + :return: The string representation of the journal entry. + """ + if not hasattr(self, "__str"): + from accounting.template_filters import format_date, format_amount + setattr(self, "__str", + gettext("%(date)s %(summary)s %(amount)s", + date=format_date(self.transaction.date), + summary="" if self.summary is None + else self.summary, + amount=format_amount(self.amount))) + return getattr(self, "__str") + @property def eid(self) -> int | None: """Returns the journal entry ID. This is the alternative name of the @@ -649,6 +680,20 @@ class JournalEntry(db.Model): """ return self.amount if self.is_debit else None + @property + def is_original_entry(self) -> bool: + """Returns whether the entry is an original entry. + + :return: True if the entry is an original entry, or False otherwise. + """ + if not self.account.is_offset_needed: + return False + if self.account.base_code[0] == "1" and not self.is_debit: + return False + if self.account.base_code[0] == "2" and self.is_debit: + return False + return True + @property def credit(self) -> Decimal | None: """Returns the credit amount. @@ -656,3 +701,45 @@ class JournalEntry(db.Model): :return: The credit amount, or None if this is not a credit entry. """ return None if self.is_debit else self.amount + + @property + def net_balance(self) -> Decimal: + """Returns the net balance. + + :return: The net balance. + """ + if not hasattr(self, "__net_balance"): + setattr(self, "__net_balance", self.amount + sum( + [x.amount if x.is_debit == self.is_debit else -x.amount + for x in self.offsets])) + return getattr(self, "__net_balance") + + @net_balance.setter + def net_balance(self, net_balance: Decimal) -> None: + """Sets the net balance. + + :param net_balance: The net balance. + :return: None. + """ + setattr(self, "__net_balance", net_balance) + + @property + def query_values(self) -> tuple[list[str], list[str]]: + """Returns the values to be queried. + + :return: The values to be queried. + """ + def format_amount(value: Decimal) -> str: + whole: int = int(value) + frac: Decimal = (value - whole).normalize() + return str(whole) + str(abs(frac))[1:] + + txn_day: date = self.transaction.date + summary: str = "" if self.summary is None else self.summary + return ([summary], + [str(txn_day.year), + "{}/{}".format(txn_day.year, txn_day.month), + "{}/{}".format(txn_day.month, txn_day.day), + "{}/{}/{}".format(txn_day.year, txn_day.month, txn_day.day), + format_amount(self.amount), + format_amount(self.net_balance)]) diff --git a/src/accounting/static/css/style.css b/src/accounting/static/css/style.css index 128374e..b735eff 100644 --- a/src/accounting/static/css/style.css +++ b/src/accounting/static/css/style.css @@ -31,6 +31,9 @@ color: #141619; background-color: #D3D3D4; } +.form-control.accounting-disabled { + background-color: #e9ecef; +} /** The toolbar */ .accounting-toolbar { @@ -113,6 +116,33 @@ border-bottom: thick double slategray; } +/* Links between objects */ +.accounting-original-entry { + border-top: thin solid darkslategray; + padding: 0.2rem 0.5rem; +} +.accounting-original-entry a { + color: inherit; + text-decoration: none; +} +.accounting-original-entry a:hover { + color: inherit; +} +.accounting-offset-entries { + border-top: thin solid darkslategray; + padding: 0.2rem 0.5rem; +} +.accounting-offset-entries ul li { + list-style: none; +} +.accounting-offset-entries ul li a { + color: inherit; + text-decoration: none; +} +.accounting-offset-entries ul li a:hover { + color: inherit; +} + /** The option selector */ .accounting-selector-list { height: 20rem; @@ -150,6 +180,9 @@ font-weight: bolder; border-top: thick double slategray; } +.accounting-entry-editor-original-entry-content { + width: calc(100% - 3rem); +} /* The report table */ .accounting-report-table-header, .accounting-report-table-footer { diff --git a/src/accounting/static/js/account-selector.js b/src/accounting/static/js/account-selector.js index 26956f0..20f7922 100644 --- a/src/accounting/static/js/account-selector.js +++ b/src/accounting/static/js/account-selector.js @@ -111,7 +111,7 @@ class AccountSelector { }; for (const option of this.#options) { option.onclick = () => { - this.#entryEditor.saveAccount(option.dataset.code, option.dataset.content); + this.#entryEditor.saveAccount(option.dataset.code, option.dataset.content, option.classList.contains("accounting-account-is-offset-needed")); }; } this.#query.addEventListener("input", () => { diff --git a/src/accounting/static/js/journal-entry-editor.js b/src/accounting/static/js/journal-entry-editor.js index 738c584..e46c6ce 100644 --- a/src/accounting/static/js/journal-entry-editor.js +++ b/src/accounting/static/js/journal-entry-editor.js @@ -57,6 +57,36 @@ class JournalEntryEditor { */ #prefix = "accounting-entry-editor" + /** + * The container of the original entry + * @type {HTMLDivElement} + */ + #originalEntryContainer; + + /** + * The control of the original entry + * @type {HTMLDivElement} + */ + #originalEntryControl; + + /** + * The original entry + * @type {HTMLDivElement} + */ + #originalEntry; + + /** + * The error message of the original entry + * @type {HTMLDivElement} + */ + #originalEntryError; + + /** + * The delete button of the original entry + * @type {HTMLButtonElement} + */ + #originalEntryDelete; + /** * The control of the summary * @type {HTMLDivElement} @@ -109,7 +139,7 @@ class JournalEntryEditor { * The journal entry to edit * @type {JournalEntrySubForm|null} */ - #entry; + entry; /** * The debit or credit entry side sub-form @@ -124,6 +154,11 @@ class JournalEntryEditor { constructor() { this.#element = document.getElementById(this.#prefix); this.#modal = document.getElementById(this.#prefix + "-modal"); + this.#originalEntryContainer = document.getElementById(this.#prefix + "-original-entry-container"); + this.#originalEntryControl = document.getElementById(this.#prefix + "-original-entry-control"); + this.#originalEntry = document.getElementById(this.#prefix + "-original-entry"); + this.#originalEntryError = document.getElementById(this.#prefix + "-original-entry-error"); + this.#originalEntryDelete = document.getElementById(this.#prefix + "-original-entry-delete"); this.#summaryControl = document.getElementById(this.#prefix + "-summary-control"); this.#summary = document.getElementById(this.#prefix + "-summary"); this.#summaryError = document.getElementById(this.#prefix + "-summary-error"); @@ -131,25 +166,90 @@ class JournalEntryEditor { this.#account = document.getElementById(this.#prefix + "-account"); this.#accountError = document.getElementById(this.#prefix + "-account-error") this.#amount = document.getElementById(this.#prefix + "-amount"); - this.#amountError = document.getElementById(this.#prefix + "-amount-error") + this.#amountError = document.getElementById(this.#prefix + "-amount-error"); + this.#originalEntryControl.onclick = () => OriginalEntrySelector.start(this, this.#originalEntry.dataset.id); + this.#originalEntryDelete.onclick = () => this.clearOriginalEntry(); this.#summaryControl.onclick = () => { SummaryEditor.start(this, this.#summary.dataset.value); }; this.#accountControl.onclick = () => { AccountSelector.start(this, this.entryType); } + this.#amount.onchange = () => { + this.#validateAmount(); + } this.#element.onsubmit = () => { if (this.#validate()) { - if (this.#entry === null) { - this.#entry = this.#side.addJournalEntry(); + if (this.entry === null) { + this.entry = this.#side.addJournalEntry(); } - this.#entry.save(this.#account.dataset.code, this.#account.dataset.text, this.#summary.dataset.value, this.#amount.value); + this.entry.save("isOriginalEntry" in this.#element.dataset,this.#originalEntry.dataset.id, this.#originalEntry.dataset.date, this.#originalEntry.dataset.text, this.#account.dataset.code, this.#account.dataset.text, this.#summary.dataset.value, this.#amount.value); bootstrap.Modal.getInstance(this.#modal).hide(); } return false; }; } + /** + * Saves the original entry from the original entry selector. + * + * @param originalEntry {OriginalEntry} the original entry + */ + saveOriginalEntry(originalEntry) { + delete this.#element.dataset.isOriginalEntry; + this.#originalEntryContainer.classList.remove("d-none"); + this.#originalEntryControl.classList.add("accounting-not-empty"); + this.#originalEntry.dataset.id = originalEntry.id; + this.#originalEntry.dataset.date = originalEntry.date; + this.#originalEntry.dataset.text = originalEntry.text; + this.#originalEntry.innerText = originalEntry.text; + this.#setEnableSummaryAccount(false); + if (originalEntry.summary === "") { + this.#summaryControl.classList.remove("accounting-not-empty"); + } else { + this.#summaryControl.classList.add("accounting-not-empty"); + } + this.#summary.dataset.value = originalEntry.summary; + this.#summary.innerText = originalEntry.summary; + this.#accountControl.classList.add("accounting-not-empty"); + this.#account.dataset.code = originalEntry.accountCode; + this.#account.dataset.text = originalEntry.accountText; + this.#account.innerText = originalEntry.accountText; + this.#amount.value = String(originalEntry.netBalance); + this.#amount.max = String(originalEntry.netBalance); + this.#amount.min = "0"; + this.#validate(); + } + + /** + * Clears the original entry. + * + */ + clearOriginalEntry() { + delete this.#element.dataset.isOriginalEntry; + this.#originalEntryContainer.classList.add("d-none"); + this.#originalEntryControl.classList.remove("accounting-not-empty"); + this.#originalEntry.dataset.id = ""; + this.#originalEntry.dataset.date = ""; + this.#originalEntry.dataset.text = ""; + this.#originalEntry.innerText = ""; + this.#setEnableSummaryAccount(true); + this.#accountControl.classList.remove("accounting-not-empty"); + this.#account.dataset.code = ""; + this.#account.dataset.text = ""; + this.#account.innerText = ""; + this.#amount.max = ""; + } + + /** + * Returns the currency code. + * + * @return {string} the currency code + */ + getCurrencyCode() { + return this.#side.currency.getCurrencyCode(); + } + /** * Returns the transaction form. * @@ -172,6 +272,7 @@ class JournalEntryEditor { } this.#summary.dataset.value = summary; this.#summary.innerText = summary; + this.#validateSummary(); bootstrap.Modal.getOrCreateInstance(this.#modal).show(); } @@ -181,8 +282,14 @@ class JournalEntryEditor { * @param summary {string} the summary * @param accountCode {string} the account code * @param accountText {string} the account text + * @param isAccountOffsetNeeded {boolean} true if the journal entries in the account need offset, or false otherwise */ - saveSummaryWithAccount(summary, accountCode, accountText) { + saveSummaryWithAccount(summary, accountCode, accountText, isAccountOffsetNeeded) { + if (isAccountOffsetNeeded) { + this.#element.dataset.isOriginalEntry = "true"; + } else { + delete this.#element.dataset.isOriginalEntry; + } this.#accountControl.classList.add("accounting-not-empty"); this.#account.dataset.code = accountCode; this.#account.dataset.text = accountText; @@ -205,6 +312,7 @@ class JournalEntryEditor { * */ clearAccount() { + delete this.#element.dataset.isOriginalEntry; this.#accountControl.classList.remove("accounting-not-empty"); this.#account.dataset.code = ""; this.#account.dataset.text = ""; @@ -217,8 +325,14 @@ class JournalEntryEditor { * * @param code {string} the account code * @param text {string} the account text + * @param isOffsetNeeded {boolean} true if the journal entries in the account need offset or false otherwise */ - saveAccount(code, text) { + saveAccount(code, text, isOffsetNeeded) { + if (isOffsetNeeded) { + this.#element.dataset.isOriginalEntry = "true"; + } else { + delete this.#element.dataset.isOriginalEntry; + } this.#accountControl.classList.add("accounting-not-empty"); this.#account.dataset.code = code; this.#account.dataset.text = text; @@ -233,12 +347,25 @@ class JournalEntryEditor { */ #validate() { let isValid = true; + isValid = this.#validateOriginalEntry() && isValid; isValid = this.#validateSummary() && isValid; isValid = this.#validateAccount() && isValid; isValid = this.#validateAmount() && isValid return isValid; } + /** + * Validates the original entry. + * + * @return {boolean} true if valid, or false otherwise + * @private + */ + #validateOriginalEntry() { + this.#originalEntryControl.classList.remove("is-invalid"); + this.#originalEntryError.innerText = ""; + return true; + } + /** * Validates the summary. * @@ -281,8 +408,29 @@ class JournalEntryEditor { this.#amountError.innerText = A_("Please fill in the amount."); return false; } + const amount =new Decimal(this.#amount.value); + if (amount.lessThanOrEqualTo(0)) { + this.#amount.classList.add("is-invalid"); + this.#amountError.innerText = A_("Please fill in a positive amount."); + return false; + } + if (this.#amount.max !== "") { + if (amount.greaterThan(new Decimal(this.#amount.max))) { + this.#amount.classList.add("is-invalid"); + this.#amountError.innerText = A_("The amount must not exceed the net balance %(balance)s of the original entry.", {balance: new Decimal(this.#amount.max)}); + return false; + } + } + if (this.#amount.min !== "") { + const min = new Decimal(this.#amount.min); + if (amount.lessThan(min)) { + this.#amount.classList.add("is-invalid"); + this.#amountError.innerText = A_("The amount must not be less than the offset total %(total)s.", {total: formatDecimal(min)}); + return false; + } + } this.#amount.classList.remove("is-invalid"); - this.#amount.innerText = ""; + this.#amountError.innerText = ""; return true; } @@ -292,24 +440,33 @@ class JournalEntryEditor { * @param side {DebitCreditSideSubForm} the debit or credit side sub-form */ #onAddNew(side) { - this.#entry = null; + this.entry = null; this.#side = side; this.entryType = this.#side.entryType; this.#element.dataset.entryType = side.entryType; - this.#summaryControl.dataset.bsTarget = "#accounting-summary-editor-" + side.entryType + "-modal"; + delete this.#element.dataset.isOriginalEntry; + this.#originalEntryContainer.classList.add("d-none"); + this.#originalEntryControl.classList.remove("accounting-not-empty"); + this.#originalEntryControl.classList.remove("is-invalid"); + this.#originalEntry.dataset.id = ""; + this.#originalEntry.dataset.date = ""; + this.#originalEntry.dataset.text = ""; + this.#originalEntry.innerText = ""; + this.#setEnableSummaryAccount(true); this.#summaryControl.classList.remove("accounting-not-empty"); this.#summaryControl.classList.remove("is-invalid"); this.#summary.dataset.value = ""; this.#summary.innerText = "" this.#summaryError.innerText = "" - this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + side.entryType + "-modal"; this.#accountControl.classList.remove("accounting-not-empty"); this.#accountControl.classList.remove("is-invalid"); - this.#account.innerText = ""; this.#account.dataset.code = ""; this.#account.dataset.text = ""; + this.#account.innerText = ""; this.#accountError.innerText = ""; this.#amount.value = ""; + this.#amount.max = ""; + this.#amount.min = "0"; this.#amount.classList.remove("is-invalid"); this.#amountError.innerText = ""; } @@ -318,17 +475,37 @@ class JournalEntryEditor { * Edits a journal entry. * * @param entry {JournalEntrySubForm} the journal entry sub-form + * @param originalEntryId {string} the ID of the original entry + * @param originalEntryDate {string} the date of the original entry + * @param originalEntryText {string} the text of the original entry * @param summary {string} the summary * @param accountCode {string} the account code * @param accountText {string} the account text * @param amount {string} the amount + * @param amountMin {string} the minimal amount */ - #onEdit(entry, summary, accountCode, accountText, amount) { - this.#entry = entry; + #onEdit(entry, originalEntryId, originalEntryDate, originalEntryText, summary, accountCode, accountText, amount, amountMin) { + this.entry = entry; this.#side = entry.side; this.entryType = this.#side.entryType; this.#element.dataset.entryType = entry.entryType; - this.#summaryControl.dataset.bsTarget = "#accounting-summary-editor-" + entry.entryType + "-modal"; + if (entry.isOriginalEntry()) { + this.#element.dataset.isOriginalEntry = "true"; + } else { + delete this.#element.dataset.isOriginalEntry; + } + if (originalEntryId === "") { + this.#originalEntryContainer.classList.add("d-none"); + this.#originalEntryControl.classList.remove("accounting-not-empty"); + } else { + this.#originalEntryContainer.classList.remove("d-none"); + this.#originalEntryControl.classList.add("accounting-not-empty"); + } + this.#originalEntry.dataset.id = originalEntryId; + this.#originalEntry.dataset.date = originalEntryDate; + this.#originalEntry.dataset.text = originalEntryText; + this.#originalEntry.innerText = originalEntryText; + this.#setEnableSummaryAccount(!entry.isMatched && originalEntryId === ""); if (summary === "") { this.#summaryControl.classList.remove("accounting-not-empty"); } else { @@ -336,16 +513,58 @@ class JournalEntryEditor { } this.#summary.dataset.value = summary; this.#summary.innerText = summary; - this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + entry.entryType + "-modal"; if (accountCode === "") { this.#accountControl.classList.remove("accounting-not-empty"); } else { this.#accountControl.classList.add("accounting-not-empty"); } - this.#account.innerText = accountText; this.#account.dataset.code = accountCode; this.#account.dataset.text = accountText; + this.#account.innerText = accountText; this.#amount.value = amount; + const maxAmount = this.#getMaxAmount(); + this.#amount.max = maxAmount === null? "": maxAmount; + this.#amount.min = amountMin; + this.#validate(); + } + + /** + * Finds out the max amount. + * + * @return {Decimal|null} the max amount + */ + #getMaxAmount() { + if (this.#originalEntry.dataset.id === "") { + return null; + } + return OriginalEntrySelector.getNetBalance(this.entry, this.getTransactionForm(), this.#originalEntry.dataset.id); + } + + /** + * Sets the enable status of the summary and account. + * + * @param isEnabled {boolean} true to enable, or false otherwise + */ + #setEnableSummaryAccount(isEnabled) { + if (isEnabled) { + this.#summaryControl.dataset.bsToggle = "modal"; + this.#summaryControl.dataset.bsTarget = "#accounting-summary-editor-" + this.#side.entryType + "-modal"; + this.#summaryControl.classList.remove("accounting-disabled"); + this.#summaryControl.classList.add("accounting-clickable"); + this.#accountControl.dataset.bsToggle = "modal"; + this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + this.#side.entryType + "-modal"; + this.#accountControl.classList.remove("accounting-disabled"); + this.#accountControl.classList.add("accounting-clickable"); + } else { + this.#summaryControl.dataset.bsToggle = ""; + this.#summaryControl.dataset.bsTarget = ""; + this.#summaryControl.classList.add("accounting-disabled"); + this.#summaryControl.classList.remove("accounting-clickable"); + this.#accountControl.dataset.bsToggle = ""; + this.#accountControl.dataset.bsTarget = ""; + this.#accountControl.classList.add("accounting-disabled"); + this.#accountControl.classList.remove("accounting-clickable"); + } } /** @@ -375,12 +594,16 @@ class JournalEntryEditor { * Edits a journal entry. * * @param entry {JournalEntrySubForm} the journal entry sub-form + * @param originalEntryId {string} the ID of the original entry + * @param originalEntryDate {string} the date of the original entry + * @param originalEntryText {string} the text of the original entry * @param summary {string} the summary * @param accountCode {string} the account code * @param accountText {string} the account text * @param amount {string} the amount + * @param amountMin {string} the minimal amount */ - static edit(entry, summary, accountCode, accountText, amount) { - this.#editor.#onEdit(entry, summary, accountCode, accountText, amount); + static edit(entry, originalEntryId, originalEntryDate, originalEntryText, summary, accountCode, accountText, amount, amountMin) { + this.#editor.#onEdit(entry, originalEntryId, originalEntryDate, originalEntryText, summary, accountCode, accountText, amount, amountMin); } } diff --git a/src/accounting/static/js/original-entry-selector.js b/src/accounting/static/js/original-entry-selector.js new file mode 100644 index 0000000..4c8b979 --- /dev/null +++ b/src/accounting/static/js/original-entry-selector.js @@ -0,0 +1,447 @@ +/* The Mia! Accounting Flask Project + * original-entry-selector.js: The JavaScript for the original entry 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/3/10 + */ +"use strict"; + +// Initializes the page JavaScript. +document.addEventListener("DOMContentLoaded", () => { + OriginalEntrySelector.initialize(); +}); + +/** + * The original entry selector. + * + */ +class OriginalEntrySelector { + + /** + * The prefix of the HTML ID and class + * @type {string} + */ + #prefix = "accounting-original-entry-selector"; + + /** + * The modal of the original entry editor + * @type {HTMLDivElement} + */ + #modal; + + /** + * 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 {OriginalEntry[]} + */ + #options; + + /** + * The options by their ID + * @type {Object.} + */ + #optionById; + + /** + * The journal entry editor. + * @type {JournalEntryEditor} + */ + entryEditor; + + /** + * Constructs an original entry selector. + * + */ + constructor() { + this.#modal = document.getElementById(this.#prefix + "-modal"); + 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 OriginalEntry(this, element)); + this.#optionById = {}; + for (const option of this.#options) { + this.#optionById[option.id] = option; + } + this.#query.addEventListener("input", () => { + this.#filterOptions(); + }); + } + + /** + * Returns the net balance for an original entry. + * + * @param currentEntry {JournalEntrySubForm} the journal entry sub-form that is currently editing + * @param form {TransactionForm} the transaction form + * @param originalEntryId {string} the ID of the original entry + * @return {Decimal} the net balance of the original entry + */ + #getNetBalance(currentEntry, form, originalEntryId) { + const otherEntries = form.getEntries().filter((entry) => entry !== currentEntry); + let otherOffset = new Decimal(0); + for (const otherEntry of otherEntries) { + if (otherEntry.getOriginalEntryId() === originalEntryId) { + const amount = otherEntry.getAmount(); + if (amount !== null) { + otherOffset = otherOffset.plus(amount); + } + } + } + return this.#optionById[originalEntryId].bareNetBalance.minus(otherOffset); + } + + /** + * Updates the net balances, subtracting the offset amounts on the form but the currently editing journal entry + * + */ + #updateNetBalances() { + const otherEntries = this.entryEditor.getTransactionForm().getEntries().filter((entry) => entry !== this.entryEditor.entry); + const otherOffsets = {} + for (const otherEntry of otherEntries) { + const otherOriginalEntryId = otherEntry.getOriginalEntryId(); + const amount = otherEntry.getAmount(); + if (otherOriginalEntryId === null || amount === null) { + continue; + } + if (!(otherOriginalEntryId in otherOffsets)) { + otherOffsets[otherOriginalEntryId] = new Decimal("0"); + } + otherOffsets[otherOriginalEntryId] = otherOffsets[otherOriginalEntryId].plus(amount); + } + for (const option of this.#options) { + if (option.id in otherOffsets) { + option.updateNetBalance(otherOffsets[option.id]); + } else { + option.resetNetBalance(); + } + } + } + + /** + * Filters the options. + * + */ + #filterOptions() { + let hasAnyMatched = false; + for (const option of this.#options) { + if (option.isMatched(this.#modal.dataset.entryType, this.#modal.dataset.currencyCode, this.#query.value)) { + option.setShown(true); + hasAnyMatched = true; + } else { + option.setShown(false); + } + } + if (!hasAnyMatched) { + this.#optionList.classList.add("d-none"); + this.#queryNoResult.classList.remove("d-none"); + } else { + this.#optionList.classList.remove("d-none"); + this.#queryNoResult.classList.add("d-none"); + } + } + + /** + * The callback when the original entry selector is shown. + * + * @param entryEditor {JournalEntryEditor} the journal entry editor + * @param originalEntryId {string|null} the ID of the original entry + */ + #onOpen(entryEditor, originalEntryId) { + this.entryEditor = entryEditor + this.#modal.dataset.currencyCode = entryEditor.getCurrencyCode(); + this.#modal.dataset.entryType = entryEditor.entryType; + for (const option of this.#options) { + option.setActive(option.id === originalEntryId); + } + this.#query.value = ""; + this.#updateNetBalances(); + this.#filterOptions(); + } + + /** + * The original entry selector. + * @type {OriginalEntrySelector} + */ + static #selector; + + /** + * Initializes the original entry selector. + * + */ + static initialize() { + this.#selector = new OriginalEntrySelector(); + } + + /** + * Starts the original entry selector. + * + * @param entryEditor {JournalEntryEditor} the journal entry editor + * @param originalEntryId {string|null} the ID of the original entry + */ + static start(entryEditor, originalEntryId = null) { + this.#selector.#onOpen(entryEditor, originalEntryId); + } + + /** + * Returns the net balance for an original entry. + * + * @param currentEntry {JournalEntrySubForm} the journal entry sub-form that is currently editing + * @param form {TransactionForm} the transaction form + * @param originalEntryId {string} the ID of the original entry + * @return {Decimal} the net balance of the original entry + */ + static getNetBalance(currentEntry, form, originalEntryId) { + return this.#selector.#getNetBalance(currentEntry, form, originalEntryId); + } +} + +/** + * An original entry. + * + */ +class OriginalEntry { + + /** + * The original entry selector + * @type {OriginalEntrySelector} + */ + #selector; + + /** + * The element + * @type {HTMLLIElement} + */ + #element; + + /** + * The ID + * @type {string} + */ + id; + + /** + * The date + * @type {string} + */ + date; + + /** + * The entry type, either "debit" or "credit" + * @type {string} + */ + #entryType; + + /** + * The currency code + * @type {string} + */ + #currencyCode; + + /** + * The account code + * @type {string} + */ + accountCode; + + /** + * The account text + * @type {string} + */ + accountText; + + /** + * The summary + * @type {string} + */ + summary; + + /** + * The net balance, without the offset amounts on the form + * @type {Decimal} + */ + bareNetBalance; + + /** + * The net balance + * @type {Decimal} + */ + netBalance; + + /** + * The text of the net balance + * @type {HTMLSpanElement} + */ + netBalanceText; + + /** + * The text representation of the original entry + * @type {string} + */ + text; + + /** + * The values to query against + * @type {string[][]} + */ + #queryValues; + + /** + * Constructs an original entry. + * + * @param selector {OriginalEntrySelector} the original entry selector + * @param element {HTMLLIElement} the element + */ + constructor(selector, element) { + this.#selector = selector; + this.#element = element; + this.id = element.dataset.id; + this.date = element.dataset.date; + this.#entryType = element.dataset.entryType; + this.#currencyCode = element.dataset.currencyCode; + this.accountCode = element.dataset.accountCode; + this.accountText = element.dataset.accountText; + this.summary = element.dataset.summary; + this.bareNetBalance = new Decimal(element.dataset.netBalance); + this.netBalance = this.bareNetBalance; + this.netBalanceText = document.getElementById("accounting-original-entry-selector-option-" + this.id + "-net-balance"); + this.text = element.dataset.text; + this.#queryValues = JSON.parse(element.dataset.queryValues); + this.#element.onclick = () => this.#selector.entryEditor.saveOriginalEntry(this); + } + + /** + * Resets the net balance to its initial value, without the offset amounts on the form. + * + */ + resetNetBalance() { + if (this.netBalance !== this.bareNetBalance) { + this.netBalance = this.bareNetBalance; + this.#updateNetBalanceText(); + } + } + + /** + * Updates the net balance with an offset. + * + * @param offset {Decimal} the offset to be added to the net balance + */ + updateNetBalance(offset) { + this.netBalance = this.bareNetBalance.minus(offset); + this.#updateNetBalanceText(); + } + + /** + * Updates the text display of the net balance. + * + */ + #updateNetBalanceText() { + this.netBalanceText.innerText = formatDecimal(this.netBalance); + } + + /** + * Returns whether the original matches. + * + * @param entryType {string} the entry type, either "debit" or "credit" + * @param currencyCode {string} the currency code + * @param query {string|null} the query term + */ + isMatched(entryType, currencyCode, query = null) { + return this.netBalance.greaterThan(0) + && this.date <= this.#selector.entryEditor.getTransactionForm().getDate() + && this.#isEntryTypeMatches(entryType) + && this.#currencyCode === currencyCode + && this.#isQueryMatches(query); + } + + /** + * Returns whether the original entry matches the entry type. + * + * @param entryType {string} the entry type, either "debit" or credit + * @return {boolean} true if the option matches, or false otherwise + */ + #isEntryTypeMatches(entryType) { + return (entryType === "debit" && this.#entryType === "credit") + || (entryType === "credit" && this.#entryType === "debit"); + } + + /** + * Returns whether the original entry matches the query. + * + * @param query {string|null} the query term + * @return {boolean} true if the option matches, or false otherwise + */ + #isQueryMatches(query) { + if (query === "") { + return true; + } + for (const queryValue of this.#queryValues[0]) { + if (queryValue.toLowerCase().includes(query.toLowerCase())) { + return true; + } + } + for (const queryValue of this.#queryValues[1]) { + if (queryValue === query) { + 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"); + } + } +} diff --git a/src/accounting/static/js/summary-editor.js b/src/accounting/static/js/summary-editor.js index 6d0072b..d0badd0 100644 --- a/src/accounting/static/js/summary-editor.js +++ b/src/accounting/static/js/summary-editor.js @@ -69,6 +69,12 @@ class SummaryEditor { */ summary; + /** + * The button to the original entry selector + * @type {HTMLButtonElement} + */ + #offsetButton; + /** * The number input * @type {HTMLInputElement} @@ -116,6 +122,7 @@ class SummaryEditor { this.prefix = "accounting-summary-editor-" + form.dataset.entryType; this.#modal = document.getElementById(this.prefix + "-modal"); this.summary = document.getElementById(this.prefix + "-summary"); + 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 @@ -128,6 +135,7 @@ class SummaryEditor { this.currentTab = this.tabPlanes.general; this.#initializeSuggestedAccounts(); this.summary.onchange = () => this.#onSummaryChange(); + this.#offsetButton.onclick = () => OriginalEntrySelector.start(this.#entryEditor); this.#form.onsubmit = () => { if (this.currentTab.validate()) { this.#submit(); @@ -210,7 +218,7 @@ class SummaryEditor { #submit() { bootstrap.Modal.getOrCreateInstance(this.#modal).hide(); if (this.#selectedAccount !== null) { - this.#entryEditor.saveSummaryWithAccount(this.summary.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text); + this.#entryEditor.saveSummaryWithAccount(this.summary.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text, this.#selectedAccount.classList.contains("accounting-account-is-offset-needed")); } else { this.#entryEditor.saveSummary(this.summary.value); } diff --git a/src/accounting/static/js/transaction-form.js b/src/accounting/static/js/transaction-form.js index 0b2f16e..bf30e95 100644 --- a/src/accounting/static/js/transaction-form.js +++ b/src/accounting/static/js/transaction-form.js @@ -159,7 +159,19 @@ class TransactionForm { this.#currencies[0].deleteButton.classList.add("d-none"); } else { for (const currency of this.#currencies) { - currency.deleteButton.classList.remove("d-none"); + const isAnyEntryMatched = () => { + for (const entry of currency.getEntries()) { + if (entry.isMatched) { + return true; + } + } + return false; + }; + if (isAnyEntryMatched()) { + currency.deleteButton.classList.add("d-none"); + } else { + currency.deleteButton.classList.remove("d-none"); + } } } } @@ -178,6 +190,20 @@ class TransactionForm { }); } + /** + * Returns all the journal entries in the form. + * + * @param entryType {string|null} the entry type, either "debit" or "credit", or null for both + * @return {JournalEntrySubForm[]} all the journal entry sub-forms + */ + getEntries(entryType = null) { + const entries = []; + for (const currency of this.#currencies) { + entries.push(...currency.getEntries(entryType)); + } + return entries; + } + /** * Returns the account codes used in the form. * @@ -185,11 +211,35 @@ class TransactionForm { * @return {string[]} the account codes used in the form */ getAccountCodesUsed(entryType) { - let inUse = []; - for (const currency of this.#currencies) { - inUse = inUse.concat(currency.getAccountCodesUsed(entryType)); + return this.getEntries(entryType).map((entry) => entry.getAccountCode()) + .filter((code) => code !== null); + } + + /** + * Returns the date. + * + * @return {string} the date + */ + getDate() { + return this.#date.value; + } + + /** + * Updates the minimal date. + * + */ + updateMinDate() { + let lastOriginalEntryDate = null; + for (const entry of this.getEntries()) { + const date = entry.getOriginalEntryDate(); + if (date !== null) { + if (lastOriginalEntryDate === null || lastOriginalEntryDate < date) { + lastOriginalEntryDate = date; + } + } } - return inUse; + this.#date.min = lastOriginalEntryDate === null? "": lastOriginalEntryDate; + this.#validateDate(); } /** @@ -218,6 +268,11 @@ class TransactionForm { this.#dateError.innerText = A_("Please fill in the date."); return false; } + if (this.#date.value < this.#date.min) { + this.#date.classList.add("is-invalid"); + this.#dateError.innerText = A_("The date cannot be earlier than the original entries."); + return false; + } this.#date.classList.remove("is-invalid"); this.#dateError.innerText = ""; return true; @@ -330,6 +385,18 @@ class CurrencySubForm { */ no; + /** + * The currency code + * @type {HTMLInputElement} + */ + #code; + + /** + * The currency code selector + * @type {HTMLSelectElement} + */ + #codeSelect; + /** * The button to delete the currency * @type {HTMLButtonElement} @@ -362,11 +429,16 @@ class CurrencySubForm { 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.#debit = debitElement === null? null: new DebitCreditSideSubForm(this, debitElement, "debit"); const creditElement = document.getElementById(this.#prefix + "-credit"); this.#credit = creditElement == null? null: new DebitCreditSideSubForm(this, creditElement, "credit"); + this.#codeSelect.onchange = () => { + this.#code.value = this.#codeSelect.value; + }; this.deleteButton.onclick = () => { this.element.parentElement.removeChild(this.element); this.form.deleteCurrency(this); @@ -374,18 +446,45 @@ class CurrencySubForm { } /** - * Returns the account codes used in the form. + * Returns the currency code. * - * @param entryType {string} the entry type, either "debit" or "credit" - * @return {string[]} the account codes used in the form + * @return {string} the currency code */ - getAccountCodesUsed(entryType) { - if (entryType === "debit") { - return this.#debit.getAccountCodesUsed(); - } else if (entryType === "credit") { - return this.#credit.getAccountCodesUsed(); + getCurrencyCode() { + return this.#code.value; + } + + /** + * Returns all the journal entries in the form. + * + * @param entryType {string|null} the entry type, either "debit" or "credit", or null for both + * @return {JournalEntrySubForm[]} all the journal entry sub-forms + */ + getEntries(entryType = null) { + const entries = [] + for (const side of [this.#debit, this.#credit]) { + if (side !== null ) { + if (entryType === null || side.entryType === entryType) { + entries.push(...side.entries); + } + } } - return [] + return entries; + } + + /** + * Updates whether to enable the currency code selector + * + */ + updateCodeSelectorStatus() { + let isEnabled = true; + for (const entry of this.getEntries()) { + if (entry.getOriginalEntryId() !== null) { + isEnabled = false; + break; + } + } + this.#codeSelect.disabled = !isEnabled; } /** @@ -476,7 +575,7 @@ class DebitCreditSideSubForm { * The journal entry sub-forms * @type {JournalEntrySubForm[]} */ - #entries; + entries; /** * The total @@ -506,7 +605,7 @@ class DebitCreditSideSubForm { this.#error = document.getElementById(this.#prefix + "-error"); this.#entryList = document.getElementById(this.#prefix + "-list"); // noinspection JSValidateTypes - this.#entries = Array.from(document.getElementsByClassName(this.#prefix)).map((element) => new JournalEntrySubForm(this, element)); + this.entries = Array.from(document.getElementsByClassName(this.#prefix)).map((element) => new JournalEntrySubForm(this, element)); this.#total = document.getElementById(this.#prefix + "-total"); this.#addEntryButton = document.getElementById(this.#prefix + "-add-entry"); this.#addEntryButton.onclick = () => { @@ -522,14 +621,14 @@ class DebitCreditSideSubForm { * @returns {JournalEntrySubForm} the newly-added journal entry sub-form */ addJournalEntry() { - const newIndex = 1 + (this.#entries.length === 0? 0: Math.max(...this.#entries.map((entry) => entry.entryIndex))); + const newIndex = 1 + (this.entries.length === 0? 0: Math.max(...this.entries.map((entry) => entry.entryIndex))); const html = this.currency.form.entryTemplate .replaceAll("CURRENCY_INDEX", escapeHtml(String(this.#currencyIndex))) .replaceAll("ENTRY_TYPE", escapeHtml(this.entryType)) .replaceAll("ENTRY_INDEX", escapeHtml(String(newIndex))); this.#entryList.insertAdjacentHTML("beforeend", html); const entry = new JournalEntrySubForm(this, document.getElementById(this.#prefix + "-" + String(newIndex))); - this.#entries.push(entry); + this.entries.push(entry); this.#resetDeleteJournalEntryButtons(); this.#initializeDragAndDropReordering(); this.validate(); @@ -542,9 +641,11 @@ class DebitCreditSideSubForm { * @param entry {JournalEntrySubForm} */ deleteJournalEntry(entry) { - const index = this.#entries.indexOf(entry); - this.#entries.splice(index, 1); + const index = this.entries.indexOf(entry); + this.entries.splice(index, 1); this.updateTotal(); + this.currency.updateCodeSelectorStatus(); + this.currency.form.updateMinDate(); this.#resetDeleteJournalEntryButtons(); } @@ -553,11 +654,15 @@ class DebitCreditSideSubForm { * */ #resetDeleteJournalEntryButtons() { - if (this.#entries.length === 1) { - this.#entries[0].deleteButton.classList.add("d-none"); + if (this.entries.length === 1) { + this.entries[0].deleteButton.classList.add("d-none"); } else { - for (const entry of this.#entries) { - entry.deleteButton.classList.remove("d-none"); + for (const entry of this.entries) { + if (entry.isMatched) { + entry.deleteButton.classList.add("d-none"); + } else { + entry.deleteButton.classList.remove("d-none"); + } } } } @@ -569,9 +674,10 @@ class DebitCreditSideSubForm { */ getTotal() { let total = new Decimal("0"); - for (const entry of this.#entries) { - if (entry.amount.value !== "") { - total = total.plus(new Decimal(entry.amount.value)); + for (const entry of this.entries) { + const amount = entry.getAmount(); + if (amount !== null) { + total = total.plus(amount); } } return total; @@ -582,17 +688,10 @@ class DebitCreditSideSubForm { * */ updateTotal() { - let total = new Decimal("0"); - for (const entry of this.#entries) { - if (entry.amount.value !== "") { - total = total.plus(new Decimal(entry.amount.value)); - } - } this.#total.innerText = formatDecimal(this.getTotal()); this.currency.validateBalance(); } - /** * Initializes the drag and drop reordering on the currency sub-forms. * @@ -600,22 +699,13 @@ class DebitCreditSideSubForm { #initializeDragAndDropReordering() { initializeDragAndDropReordering(this.#entryList, () => { const entryId = Array.from(this.#entryList.children).map((entry) => entry.id); - this.#entries.sort((a, b) => entryId.indexOf(a.element.id) - entryId.indexOf(b.element.id)); - for (let i = 0; i < this.#entries.length; i++) { - this.#entries[i].no.value = String(i + 1); + this.entries.sort((a, b) => entryId.indexOf(a.element.id) - entryId.indexOf(b.element.id)); + for (let i = 0; i < this.entries.length; i++) { + this.entries[i].no.value = String(i + 1); } }); } - /** - * Returns the account codes used in the form. - * - * @return {string[]} the account codes used in the form - */ - getAccountCodesUsed() { - return this.#entries.filter((entry) => entry.getAccountCode() !== null).map((entry) => entry.getAccountCode()); - } - /** * Validates the form. * @@ -624,7 +714,7 @@ class DebitCreditSideSubForm { validate() { let isValid = true; isValid = this.#validateReal() && isValid; - for (const entry of this.#entries) { + for (const entry of this.entries) { isValid = entry.validate() && isValid; } return isValid; @@ -636,7 +726,7 @@ class DebitCreditSideSubForm { * @returns {boolean} true if valid, or false otherwise */ #validateReal() { - if (this.#entries.length === 0) { + if (this.entries.length === 0) { this.#element.classList.add("is-invalid"); this.#error.innerText = A_("Please add some journal entries."); return false; @@ -677,6 +767,12 @@ class JournalEntrySubForm { */ entryIndex; + /** + * Whether this is an original entry with offsets + * @type {boolean} + */ + isMatched; + /** * The prefix of the HTML ID and class * @type {string} @@ -725,11 +821,29 @@ class JournalEntrySubForm { */ #summaryText; + /** + * The ID of the original entry + * @type {HTMLInputElement} + */ + #originalEntryId; + + /** + * The text of the original entry + * @type {HTMLDivElement} + */ + #originalEntryText; + + /** + * The offset entries + * @type {HTMLInputElement} + */ + #offsets; + /** * The amount * @type {HTMLInputElement} */ - amount; + #amount; /** * The text display of the amount @@ -754,6 +868,7 @@ class JournalEntrySubForm { this.element = element; this.entryType = element.dataset.entryType; this.entryIndex = parseInt(element.dataset.entryIndex); + this.isMatched = element.classList.contains("accounting-matched-entry"); this.#prefix = "accounting-currency-" + element.dataset.currencyIndex + "-" + this.entryType + "-" + this.entryIndex; this.#control = document.getElementById(this.#prefix + "-control"); this.#error = document.getElementById(this.#prefix + "-error"); @@ -762,11 +877,14 @@ class JournalEntrySubForm { this.#accountText = document.getElementById(this.#prefix + "-account-text"); this.#summary = document.getElementById(this.#prefix + "-summary"); this.#summaryText = document.getElementById(this.#prefix + "-summary-text"); - this.amount = document.getElementById(this.#prefix + "-amount"); + this.#originalEntryId = document.getElementById(this.#prefix + "-original-entry-id"); + this.#originalEntryText = document.getElementById(this.#prefix + "-original-entry-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"); this.#control.onclick = () => { - JournalEntryEditor.edit(this, this.#summary.value, this.#accountCode.value, this.#accountCode.dataset.text, this.amount.value); + JournalEntryEditor.edit(this, this.#originalEntryId.value, this.#originalEntryId.dataset.date, this.#originalEntryId.dataset.text, this.#summary.value, this.#accountCode.value, this.#accountCode.dataset.text, this.#amount.value, this.#amount.dataset.min); }; this.deleteButton.onclick = () => { this.element.parentElement.removeChild(this.element); @@ -774,6 +892,33 @@ class JournalEntrySubForm { }; } + /** + * Returns whether the entry is an original entry. + * + * @return {boolean} true if the entry is an original entry, or false otherwise + */ + isOriginalEntry() { + return "isOriginalEntry" in this.element.dataset; + } + + /** + * Returns the ID of the original entry. + * + * @return {string|null} the ID of the original entry + */ + getOriginalEntryId() { + return this.#originalEntryId.value === ""? null: this.#originalEntryId.value; + } + + /** + * Returns the date of the original entry. + * + * @return {string|null} the date of the original entry + */ + getOriginalEntryDate() { + return this.#originalEntryId.dataset.date === ""? null: this.#originalEntryId.dataset.date; + } + /** * Returns the account code. * @@ -783,6 +928,15 @@ class JournalEntrySubForm { return this.#accountCode.value === ""? null: this.#accountCode.value; } + /** + * Returns the amount. + * + * @return {Decimal|null} the amount + */ + getAmount() { + return this.#amount.value === ""? null: new Decimal(this.#amount.value); + } + /** * Validates the form. * @@ -794,7 +948,7 @@ class JournalEntrySubForm { this.#error.innerText = A_("Please select the account."); return false; } - if (this.amount.value === "") { + if (this.#amount.value === "") { this.#control.classList.add("is-invalid"); this.#error.innerText = A_("Please fill in the amount."); return false; @@ -807,21 +961,42 @@ class JournalEntrySubForm { /** * Stores the data into the journal entry sub-form. * + * @param isOriginalEntry {boolean} true if this is an original entry, or false otherwise + * @param originalEntryId {string} the ID of the original entry + * @param originalEntryDate {string} the date of the original entry + * @param originalEntryText {string} the text of the original entry * @param accountCode {string} the account code * @param accountText {string} the account text * @param summary {string} the summary * @param amount {string} the amount */ - save(accountCode, accountText, summary, amount) { + save(isOriginalEntry, originalEntryId, originalEntryDate, originalEntryText, accountCode, accountText, summary, amount) { + if (isOriginalEntry) { + this.#offsets.classList.remove("d-none"); + } else { + this.#offsets.classList.add("d-none"); + } + this.#originalEntryId.value = originalEntryId; + this.#originalEntryId.dataset.date = originalEntryDate; + this.#originalEntryId.dataset.text = originalEntryText; + if (originalEntryText === "") { + this.#originalEntryText.classList.add("d-none"); + this.#originalEntryText.innerText = ""; + } else { + this.#originalEntryText.classList.remove("d-none"); + this.#originalEntryText.innerText = A_("Offset %(entry)s", {entry: originalEntryText}); + } this.#accountCode.value = accountCode; this.#accountCode.dataset.text = accountText; this.#accountText.innerText = accountText; this.#summary.value = summary; this.#summaryText.innerText = summary; - this.amount.value = amount; + this.#amount.value = amount; this.#amountText.innerText = formatDecimal(new Decimal(amount)); this.validate(); this.side.updateTotal(); + this.side.currency.updateCodeSelectorStatus(); + this.side.currency.form.updateMinDate(); } } diff --git a/src/accounting/templates/accounting/transaction/expense/detail.html b/src/accounting/templates/accounting/transaction/expense/detail.html index 3ef08a3..009c274 100644 --- a/src/accounting/templates/accounting/transaction/expense/detail.html +++ b/src/accounting/templates/accounting/transaction/expense/detail.html @@ -35,19 +35,9 @@ First written: 2023/2/26