Added the summary helper for the transaction form.

This commit is contained in:
依瑪貓 2023-02-28 15:49:01 +08:00
parent fc8e257a10
commit d5c2231794
12 changed files with 1301 additions and 18 deletions

View File

@ -0,0 +1,531 @@
/* The Mia! Accounting Flask Project
* summary-helper.js: The JavaScript for the summary helper
*/
/* 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
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
SummaryHelper.initialize();
});
/**
* A summary helper.
*
*/
class SummaryHelper {
/**
* The entry type
* @type {string}
*/
#entryType;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix;
/**
* The default tab ID
* @type {string}
*/
#defaultTabId;
/**
* Constructs a summary helper.
*
* @param form {HTMLFormElement} the summary helper form
*/
constructor(form) {
this.#entryType = form.dataset.entryType;
this.#prefix = "accounting-summary-helper-" + form.dataset.entryType;
this.#defaultTabId = form.dataset.defaultTabId;
this.#init();
}
/**
* Initializes the summary helper.
*
*/
#init() {
const helper = this;
const tabs = Array.from(document.getElementsByClassName(this.#prefix + "-tab"));
tabs.forEach(function (tab) {
tab.onclick = function () {
helper.#switchToTab(tab.dataset.tabId);
}
});
this.#initializeGeneralTagHelper();
this.#initializeGeneralTripHelper();
this.#initializeBusTripHelper();
this.#initializeNumberHelper();
this.#initializeSubmission();
}
/**
* Switches to a tab.
*
* @param tabId {string} the tab ID.
*/
#switchToTab(tabId) {
const tabs = Array.from(document.getElementsByClassName(this.#prefix + "-tab"));
const pages = Array.from(document.getElementsByClassName(this.#prefix + "-page"));
tabs.forEach(function (tab) {
if (tab.dataset.tabId === tabId) {
tab.classList.add("active");
tab.ariaCurrent = "page";
} else {
tab.classList.remove("active");
tab.ariaCurrent = "false";
}
});
pages.forEach(function (page) {
if (page.dataset.tabId === tabId) {
page.classList.remove("d-none");
page.ariaCurrent = "page";
} else {
page.classList.add("d-none");
page.ariaCurrent = "false";
}
});
}
/**
* Initializes the general tag helper.
*
*/
#initializeGeneralTagHelper() {
const buttons = Array.from(document.getElementsByClassName(this.#prefix + "-general-btn-tag"));
const summary = document.getElementById(this.#prefix + "-summary");
const tag = document.getElementById(this.#prefix + "-general-tag");
const updateSummary = function () {
const pos = summary.value.indexOf("—");
const prefix = tag.value === ""? "": tag.value + "—";
if (pos === -1) {
summary.value = prefix + summary.value;
} else {
summary.value = prefix + summary.value.substring(pos + 1);
}
}
buttons.forEach(function (button) {
button.onclick = function () {
buttons.forEach(function (otherButton) {
otherButton.classList.remove("btn-primary");
otherButton.classList.add("btn-outline-primary");
});
button.classList.remove("btn-outline-primary");
button.classList.add("btn-primary");
tag.value = button.dataset.value;
updateSummary();
};
});
tag.onchange = function () {
buttons.forEach(function (button) {
if (button.dataset.value === tag.value) {
button.classList.remove("btn-outline-primary");
button.classList.add("btn-primary");
} else {
button.classList.remove("btn-primary");
button.classList.add("btn-outline-primary");
}
});
updateSummary();
};
}
/**
* Initializes the general trip helper.
*
*/
#initializeGeneralTripHelper() {
const buttons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-btn-tag"));
const summary = document.getElementById(this.#prefix + "-summary");
const tag = document.getElementById(this.#prefix + "-travel-tag");
const from = document.getElementById(this.#prefix + "-travel-from");
const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction"))
const to = document.getElementById(this.#prefix + "-travel-to");
const updateSummary = function () {
let direction;
for (const button of directionButtons) {
if (button.classList.contains("btn-primary")) {
direction = button.dataset.arrow;
break;
}
}
summary.value = tag.value + "—" + from.value + direction + to.value;
};
buttons.forEach(function (button) {
button.onclick = function () {
buttons.forEach(function (otherButton) {
otherButton.classList.remove("btn-primary");
otherButton.classList.add("btn-outline-primary");
});
button.classList.remove("btn-outline-primary");
button.classList.add("btn-primary");
tag.value = button.dataset.value;
updateSummary();
};
});
tag.onchange = function () {
buttons.forEach(function (button) {
if (button.dataset.value === tag.value) {
button.classList.remove("btn-outline-primary");
button.classList.add("btn-primary");
} else {
button.classList.remove("btn-primary");
button.classList.add("btn-outline-primary");
}
});
updateSummary();
};
from.onchange = updateSummary;
directionButtons.forEach(function (button) {
button.onclick = function () {
directionButtons.forEach(function (otherButton) {
otherButton.classList.remove("btn-primary");
otherButton.classList.add("btn-outline-primary");
});
button.classList.remove("btn-outline-primary");
button.classList.add("btn-primary");
updateSummary();
};
});
to.onchange = updateSummary;
}
/**
* Initializes the bus trip helper.
*
*/
#initializeBusTripHelper() {
const buttons = Array.from(document.getElementsByClassName(this.#prefix + "-bus-btn-tag"));
const summary = document.getElementById(this.#prefix + "-summary");
const tag = document.getElementById(this.#prefix + "-bus-tag");
const route = document.getElementById(this.#prefix + "-bus-route");
const from = document.getElementById(this.#prefix + "-bus-from");
const to = document.getElementById(this.#prefix + "-bus-to");
const updateSummary = function () {
summary.value = tag.value + "—" + route.value + "—" + from.value + "→" + to.value;
};
buttons.forEach(function (button) {
button.onclick = function () {
buttons.forEach(function (otherButton) {
otherButton.classList.remove("btn-primary");
otherButton.classList.add("btn-outline-primary");
});
button.classList.remove("btn-outline-primary");
button.classList.add("btn-primary");
tag.value = button.dataset.value;
updateSummary();
};
});
tag.onchange = function () {
buttons.forEach(function (button) {
if (button.dataset.value === tag.value) {
button.classList.remove("btn-outline-primary");
button.classList.add("btn-primary");
} else {
button.classList.remove("btn-primary");
button.classList.add("btn-outline-primary");
}
});
updateSummary();
};
route.onchange = updateSummary;
from.onchange = updateSummary;
to.onchange = updateSummary;
}
/**
* Initializes the number helper.
*
*/
#initializeNumberHelper() {
const summary = document.getElementById(this.#prefix + "-summary");
const number = document.getElementById(this.#prefix + "-number");
number.onchange = function () {
const found = summary.value.match(/^(.+)×(\d+)$/);
if (found !== null) {
summary.value = found[1];
}
if (number.value > 1) {
summary.value = summary.value + "×" + String(number.value);
}
};
}
/**
* Initializes the summary submission
*
*/
#initializeSubmission() {
const form = document.getElementById(this.#prefix);
const helper = this;
form.onsubmit = function () {
if (helper.#validate()) {
helper.#submit();
}
return false;
};
}
/**
* Validates the form.
*
* @return {boolean} true if valid, or false otherwise
*/
#validate() {
return true;
}
/**
* Submits the summary.
*
*/
#submit() {
const summary = document.getElementById(this.#prefix + "-summary");
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
const formSummary = document.getElementById("accounting-entry-form-summary");
const helperModal = document.getElementById(this.#prefix + "-modal");
const entryModal = document.getElementById("accounting-entry-form-modal");
if (summary.value === "") {
formSummaryControl.classList.remove("accounting-not-empty");
} else {
formSummaryControl.classList.add("accounting-not-empty");
}
formSummary.dataset.value = summary.value;
formSummary.innerText = summary.value;
bootstrap.Modal.getInstance(helperModal).hide();
bootstrap.Modal.getOrCreateInstance(entryModal).show();
}
/**
* Initializes the summary help when it is shown.
*
* @param isNew {boolean} true for adding a new journal entry, or false otherwise
*/
initShow(isNew) {
const closeButtons = Array.from(document.getElementsByClassName(this.#prefix + "-close"));
closeButtons.forEach(function (button) {
if (isNew) {
button.dataset.bsTarget = "";
} else {
button.dataset.bsTarget = "#accounting-entry-form-modal";
}
});
this.#reset();
if (!isNew) {
this.#populate();
}
}
/**
* Resets the summary helper.
*
*/
#reset() {
const inputs = Array.from(document.getElementsByClassName(this.#prefix + "-input"));
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-btn-tag"));
const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction"));
inputs.forEach(function (input) {
input.value = "";
input.classList.remove("is-invalid");
});
tagButtons.forEach(function (btnTag) {
btnTag.classList.remove("btn-primary");
btnTag.classList.add("btn-outline-primary");
});
directionButtons.forEach(function (btnDirection) {
if (btnDirection.classList.contains("accounting-default")) {
btnDirection.classList.remove("btn-outline-primary");
btnDirection.classList.add("btn-primary");
} else {
btnDirection.classList.add("btn-outline-primary");
btnDirection.classList.remove("btn-primary");
}
});
this.#switchToTab(this.#defaultTabId);
}
/**
* Populates the summary helper from the journal entry form.
*
*/
#populate() {
const formSummary = document.getElementById("accounting-entry-form-summary");
const summary = document.getElementById(this.#prefix + "-summary");
summary.value = formSummary.dataset.value;
const pos = summary.value.indexOf("—");
if (pos === -1) {
return;
}
let found;
found = summary.value.match(/^(.+?)—(.+?)—(.+?)→(.+?)(?:×(\d+)?)?$/);
if (found !== null) {
return this.#populateBusTrip(found[1], found[2], found[3], found[4], found[5]);
}
found = summary.value.match(/^(.+?)—(.+?)([→↔])(.+?)(?:×(\d+)?)?$/);
if (found !== null) {
return this.#populateGeneralTrip(found[1], found[2], found[3], found[4], found[5]);
}
found = summary.value.match(/^(.+?)—.+?(?:×(\d+)?)?$/);
if (found !== null) {
return this.#populateGeneralTag(found[1], found[2]);
}
}
/**
* Populates a bus trip.
*
* @param tagName {string} the tag name
* @param routeName {string} the route name or route number
* @param fromName {string} the name of the origin
* @param toName {string} the name of the destination
* @param numberStr {string|undefined} the number of items, if any
*/
#populateBusTrip(tagName, routeName, fromName, toName, numberStr) {
const tag = document.getElementById(this.#prefix + "-bus-tag");
const route = document.getElementById(this.#prefix + "-bus-route");
const from = document.getElementById(this.#prefix + "-bus-from");
const to = document.getElementById(this.#prefix + "-bus-to");
const number = document.getElementById(this.#prefix + "-number");
const buttons = Array.from(document.getElementsByClassName(this.#prefix + "-bus-btn-tag"));
tag.value = tagName;
route.value = routeName;
from.value = fromName;
to.value = toName;
if (numberStr !== undefined) {
number.value = parseInt(numberStr);
}
buttons.forEach(function (button) {
if (button.dataset.value === tagName) {
button.classList.remove("btn-outline-primary");
button.classList.add("btn-primary");
}
});
this.#switchToTab("bus");
}
/**
* Populates a general trip.
*
* @param tagName {string} the tag name
* @param fromName {string} the name of the origin
* @param direction {string} the direction arrow, either "→" or "↔"
* @param toName {string} the name of the destination
* @param numberStr {string|undefined} the number of items, if any
*/
#populateGeneralTrip(tagName, fromName, direction, toName, numberStr) {
const tag = document.getElementById(this.#prefix + "-travel-tag");
const from = document.getElementById(this.#prefix + "-travel-from");
const to = document.getElementById(this.#prefix + "-travel-to");
const number = document.getElementById(this.#prefix + "-number");
const buttons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-btn-tag"));
const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction"));
tag.value = tagName;
from.value = fromName;
directionButtons.forEach(function (btnDirection) {
if (btnDirection.dataset.arrow === direction) {
btnDirection.classList.remove("btn-outline-primary");
btnDirection.classList.add("btn-primary");
} else {
btnDirection.classList.add("btn-outline-primary");
btnDirection.classList.remove("btn-primary");
}
});
to.value = toName;
if (numberStr !== undefined) {
number.value = parseInt(numberStr);
}
buttons.forEach(function (button) {
if (button.dataset.value === tagName) {
button.classList.remove("btn-outline-primary");
button.classList.add("btn-primary");
}
});
this.#switchToTab("travel");
}
/**
* Populates a general tag.
*
* @param tagName {string} the tag name
* @param numberStr {string|undefined} the number of items, if any
*/
#populateGeneralTag(tagName, numberStr) {
const tag = document.getElementById(this.#prefix + "-general-tag");
const number = document.getElementById(this.#prefix + "-number");
const buttons = Array.from(document.getElementsByClassName(this.#prefix + "-general-btn-tag"));
tag.value = tagName;
if (numberStr !== undefined) {
number.value = parseInt(numberStr);
}
buttons.forEach(function (button) {
if (button.dataset.value === tagName) {
button.classList.remove("btn-outline-primary");
button.classList.add("btn-primary");
}
});
this.#switchToTab("general");
}
/**
* The summary helpers.
* @type {{debit: SummaryHelper, credit: SummaryHelper}}
*/
static #helpers = {}
/**
* Initializes the summary helpers.
*
*/
static initialize() {
const forms = Array.from(document.getElementsByClassName("accounting-summary-helper"));
for (const form of forms) {
const helper = new SummaryHelper(form);
this.#helpers[helper.#entryType] = helper;
}
this.#initializeTransactionForm();
}
/**
* Initializes the transaction form.
*
*/
static #initializeTransactionForm() {
const entryForm = document.getElementById("accounting-entry-form");
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
const helpers = this.#helpers;
formSummaryControl.onclick = function () {
helpers[entryForm.dataset.entryType].initShow(false);
};
}
/**
* Initializes the summary helper for a new journal entry.
*
* @param entryType {string} the entry type, either "debit" or "credit"
*/
static initializeNewJournalEntry(entryType) {
this.#helpers[entryType].initShow(true);
}
}

View File

@ -157,6 +157,7 @@ function initializeNewEntryButton(button) {
const formAccountControl = document.getElementById("accounting-entry-form-account-control"); const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account"); const formAccount = document.getElementById("accounting-entry-form-account");
const formAccountError = document.getElementById("accounting-entry-form-account-error") const formAccountError = document.getElementById("accounting-entry-form-account-error")
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
const formSummary = document.getElementById("accounting-entry-form-summary"); const formSummary = document.getElementById("accounting-entry-form-summary");
const formSummaryError = document.getElementById("accounting-entry-form-summary-error"); const formSummaryError = document.getElementById("accounting-entry-form-summary-error");
const formAmount = document.getElementById("accounting-entry-form-amount"); const formAmount = document.getElementById("accounting-entry-form-amount");
@ -165,19 +166,23 @@ function initializeNewEntryButton(button) {
entryForm.dataset.currencyIndex = button.dataset.currencyIndex; entryForm.dataset.currencyIndex = button.dataset.currencyIndex;
entryForm.dataset.entryType = button.dataset.entryType; entryForm.dataset.entryType = button.dataset.entryType;
entryForm.dataset.entryIndex = button.dataset.entryIndex; entryForm.dataset.entryIndex = button.dataset.entryIndex;
formAccountControl.classList.remove("accounting-not-empty") formAccountControl.classList.remove("accounting-not-empty");
formAccountControl.classList.remove("is-invalid"); formAccountControl.classList.remove("is-invalid");
formAccountControl.dataset.bsTarget = button.dataset.accountModal; formAccountControl.dataset.bsTarget = button.dataset.accountModal;
formAccount.innerText = ""; formAccount.innerText = "";
formAccount.dataset.code = ""; formAccount.dataset.code = "";
formAccount.dataset.text = ""; formAccount.dataset.text = "";
formAccountError.innerText = ""; formAccountError.innerText = "";
formSummary.value = ""; formSummaryControl.dataset.bsTarget = "#accounting-summary-helper-" + button.dataset.entryType + "-modal";
formSummary.classList.remove("is-invalid"); formSummaryControl.classList.remove("accounting-not-empty");
formSummaryControl.classList.remove("is-invalid");
formSummary.dataset.value = "";
formSummary.innerText = ""
formSummaryError.innerText = "" formSummaryError.innerText = ""
formAmount.value = ""; formAmount.value = "";
formAmount.classList.remove("is-invalid"); formAmount.classList.remove("is-invalid");
formAmountError.innerText = ""; formAmountError.innerText = "";
SummaryHelper.initializeNewJournalEntry(button.dataset.entryType);
}; };
} }
@ -209,6 +214,7 @@ function initializeJournalEntry(entry) {
const control = document.getElementById(entry.dataset.prefix + "-control"); const control = document.getElementById(entry.dataset.prefix + "-control");
const formAccountControl = document.getElementById("accounting-entry-form-account-control"); const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account"); const formAccount = document.getElementById("accounting-entry-form-account");
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
const formSummary = document.getElementById("accounting-entry-form-summary"); const formSummary = document.getElementById("accounting-entry-form-summary");
const formAmount = document.getElementById("accounting-entry-form-amount"); const formAmount = document.getElementById("accounting-entry-form-amount");
control.onclick = function () { control.onclick = function () {
@ -224,7 +230,14 @@ function initializeJournalEntry(entry) {
formAccount.innerText = accountCode.dataset.text; formAccount.innerText = accountCode.dataset.text;
formAccount.dataset.code = accountCode.value; formAccount.dataset.code = accountCode.value;
formAccount.dataset.text = accountCode.dataset.text; formAccount.dataset.text = accountCode.dataset.text;
formSummary.value = summary.value; formSummaryControl.dataset.bsTarget = "#accounting-summary-helper-" + entry.dataset.entryType + "-modal";
if (summary.value === "") {
formSummaryControl.classList.remove("accounting-not-empty");
} else {
formSummaryControl.classList.add("accounting-not-empty");
}
formSummary.dataset.value = summary.value;
formSummary.innerText = summary.value;
formAmount.value = amount.value; formAmount.value = amount.value;
validateJournalEntryForm(); validateJournalEntryForm();
}; };
@ -239,7 +252,6 @@ function initializeJournalEntryFormModal() {
const entryForm = document.getElementById("accounting-entry-form"); const entryForm = document.getElementById("accounting-entry-form");
const formAccountControl = document.getElementById("accounting-entry-form-account-control"); const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account"); const formAccount = document.getElementById("accounting-entry-form-account");
const formSummary = document.getElementById("accounting-entry-form-summary");
const formAmount = document.getElementById("accounting-entry-form-amount"); const formAmount = document.getElementById("accounting-entry-form-amount");
const modal = document.getElementById("accounting-entry-form-modal"); const modal = document.getElementById("accounting-entry-form-modal");
formAccountControl.onclick = function () { formAccountControl.onclick = function () {
@ -268,7 +280,6 @@ function initializeJournalEntryFormModal() {
btnClear.disabled = false; btnClear.disabled = false;
} }
}; };
formSummary.onchange = validateJournalEntrySummary;
formAmount.onchange = validateJournalEntryAmount; formAmount.onchange = validateJournalEntryAmount;
entryForm.onsubmit = function () { entryForm.onsubmit = function () {
if (validateJournalEntryForm()) { if (validateJournalEntryForm()) {
@ -320,10 +331,9 @@ function validateJournalEntryAccount() {
* @private * @private
*/ */
function validateJournalEntrySummary() { function validateJournalEntrySummary() {
const field = document.getElementById("accounting-entry-form-summary"); const control = document.getElementById("accounting-entry-form-summary-control");
const error = document.getElementById("accounting-entry-form-summary-error"); const error = document.getElementById("accounting-entry-form-summary-error");
field.value = field.value.trim(); control.classList.remove("is-invalid");
field.classList.remove("is-invalid");
error.innerText = ""; error.innerText = "";
return true; return true;
} }
@ -393,8 +403,8 @@ function saveJournalEntryForm() {
accountCode.value = formAccount.dataset.code; accountCode.value = formAccount.dataset.code;
accountCode.dataset.text = formAccount.dataset.text; accountCode.dataset.text = formAccount.dataset.text;
accountText.innerText = formAccount.dataset.text; accountText.innerText = formAccount.dataset.text;
summary.value = formSummary.value; summary.value = formSummary.dataset.value;
summaryText.innerText = formSummary.value; summaryText.innerText = formSummary.dataset.value;
amount.value = formAmount.value; amount.value = formAmount.value;
amountText.innerText = formatDecimal(new Decimal(formAmount.value)); amountText.innerText = formatDecimal(new Decimal(formAmount.value));
if (entryForm.dataset.entryIndex === "new") { if (entryForm.dataset.entryIndex === "new") {

View File

@ -70,7 +70,7 @@ First written: 2023/2/25
</div> </div>
<div> <div>
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-account-modal="#accounting-debit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-account-modal="#accounting-debit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-summary-helper-debit-modal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</button> </button>

View File

@ -36,9 +36,11 @@ First written: 2023/2/25
<div id="accounting-entry-form-account-error" class="invalid-feedback"></div> <div id="accounting-entry-form-account-error" class="invalid-feedback"></div>
</div> </div>
<div class="form-floating mb-3"> <div class="mb-3">
<input id="accounting-entry-form-summary" class="form-control" type="text" value="" placeholder=" "> <div id="accounting-entry-form-summary-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
<label for="accounting-entry-form-summary">{{ A_("Summary") }}</label> <label class="form-label" for="accounting-entry-form-summary">{{ A_("Summary") }}</label>
<div id="accounting-entry-form-summary" data-value=""></div>
</div>
<div id="accounting-entry-form-summary-error" class="invalid-feedback"></div> <div id="accounting-entry-form-summary-error" class="invalid-feedback"></div>
</div> </div>

View File

@ -24,6 +24,7 @@ First written: 2023/2/26
{% block accounting_scripts %} {% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/transaction-form.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/transaction-form.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/summary-helper.js") }}"></script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@ -85,6 +86,9 @@ First written: 2023/2/26
</form> </form>
{% include "accounting/transaction/include/entry-form-modal.html" %} {% include "accounting/transaction/include/entry-form-modal.html" %}
{% for summary_helper in form.summary_helper.types %}
{% include "accounting/transaction/include/summary-helper-modal.html" %}
{% endfor %}
{% block account_selector_modals %}{% endblock %} {% block account_selector_modals %}{% endblock %}
{% endblock %} {% endblock %}

View File

@ -0,0 +1,171 @@
{#
The Mia! Accounting Flask Project
entry-form-modal.html: The modal of the summary helper
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
#}
<form id="accounting-summary-helper-{{ summary_helper.type }}" class="accounting-summary-helper" data-entry-type="{{ summary_helper.type }}" data-default-tab-id="general">
<div id="accounting-summary-helper-{{ summary_helper.type }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-summary-helper-{{ summary_helper.type }}-modal-label">
<label for="accounting-summary-helper-{{ summary_helper.type }}-summary">{{ A_("Summary") }}</label>
</h1>
<button type="button" class="btn-close accounting-summary-helper-{{ summary_helper.type }}-close" data-bs-toggle="modal" data-bs-target="" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<input id="accounting-summary-helper-{{ summary_helper.type }}-summary" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-modal-label">
</div>
<ul class="nav nav-tabs mb-2">
<li class="nav-item">
<span class="nav-link active accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="page" data-tab-id="general">
{{ A_("General") }}
</span>
</li>
<li class="nav-item">
<span class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="travel">
{{ A_("Travel") }}
</span>
</li>
<li class="nav-item">
<span class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="bus">
{{ A_("Bus") }}
</span>
</li>
<li class="nav-item">
<span class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="regular">
{{ A_("Regular") }}
</span>
</li>
<li class="nav-item">
<span class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="number">
{{ A_("Number") }}
</span>
</li>
</ul>
{# A general summary with a tag #}
<div class="accounting-summary-helper-{{ summary_helper.type }}-page" aria-current="page" data-tab-id="general">
<div class="form-floating mb-2">
<input id="accounting-summary-helper-{{ summary_helper.type }}-general-tag" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-general-tag">{{ A_("Tag") }}</label>
</div>
<div>
{% for tag in summary_helper.general.tags %}
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-btn-tag accounting-summary-helper-{{ summary_helper.type }}-general-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}">
{{ tag }}
</button>
{% endfor %}
</div>
</div>
{# A general trip with the origin and distination #}
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" data-tab-id="travel">
<div class="form-floating mb-2">
<input id="accounting-summary-helper-{{ summary_helper.type }}-travel-tag" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-travel-tag">{{ A_("Tag") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-travel-tag-error" class="invalid-feedback"></div>
</div>
<div>
{% for tag in summary_helper.travel.tags %}
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-btn-tag accounting-summary-helper-{{ summary_helper.type }}-travel-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}">
{{ tag }}
</button>
{% endfor %}
</div>
<div class="d-flex justify-content-between mt-2">
<div class="form-floating">
<input id="accounting-summary-helper-{{ summary_helper.type }}-travel-from" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-travel-from">{{ A_("From") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-travel-from-error" class="invalid-feedback"></div>
</div>
<div class="btn-group-vertical ms-1 me-1">
<button class="btn btn-primary accounting-summary-helper-{{ summary_helper.type }}-travel-direction accounting-default" type="button" data-arrow="&rarr;">&rarr;</button>
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-travel-direction" type="button" data-arrow="&harr;">&harr;</button>
</div>
<div class="form-floating">
<input id="accounting-summary-helper-{{ summary_helper.type }}-travel-to" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-travel-to">{{ A_("To") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-travel-to-error" class="invalid-feedback"></div>
</div>
</div>
</div>
{# A bus trip with the route name or route number, the origin and distination #}
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" data-tab-id="bus">
<div class="d-flex justify-content-between mb-2">
<div class="form-floating me-2">
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-tag" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-tag">{{ A_("Tag") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-tag-error" class="invalid-feedback"></div>
</div>
<div class="form-floating">
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-route" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-route">{{ A_("Route") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-route-error" class="invalid-feedback"></div>
</div>
</div>
<div>
{% for tag in summary_helper.bus.tags %}
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-btn-tag accounting-summary-helper-{{ summary_helper.type }}-bus-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}">
{{ tag }}
</button>
{% endfor %}
</div>
<div class="d-flex justify-content-between mt-2">
<div class="form-floating me-2">
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-from" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-from">{{ A_("From") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-from-error" class="invalid-feedback"></div>
</div>
<div class="form-floating">
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-to" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-to">{{ A_("To") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-to-error" class="invalid-feedback"></div>
</div>
</div>
</div>
{# A regular income/payment #}
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" data-tab-id="regular">
{# TODO: To be done #}
</div>
{# The number of items #}
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" data-tab-id="number">
<div class="form-floating">
<input id="accounting-summary-helper-{{ summary_helper.type }}-number" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="number" min="1" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-number">{{ A_("The number of items") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-number-error" class="invalid-feedback"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary accounting-summary-helper-{{ summary_helper.type }}-close" data-bs-toggle="modal" data-bs-target="">{{ A_("Cancel") }}</button>
<button id="accounting-summary-helper-{{ summary_helper.type }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
</div>
</div>
</div>
</div>
</form>

View File

@ -70,7 +70,7 @@ First written: 2023/2/25
</div> </div>
<div> <div>
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-account-modal="#accounting-credit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-account-modal="#accounting-credit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-summary-helper-credit-modal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</button> </button>

View File

@ -72,7 +72,7 @@ First written: 2023/2/25
</div> </div>
<div> <div>
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-account-modal="#accounting-debit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-account-modal="#accounting-debit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-summary-helper-debit-modal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</button> </button>
@ -112,7 +112,7 @@ First written: 2023/2/25
</div> </div>
<div> <div>
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-account-modal="#accounting-credit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-account-modal="#accounting-credit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-summary-helper-credit-modal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</button> </button>

View File

@ -364,6 +364,14 @@ class TransactionForm(FlaskForm):
return [x for x in self.currencies.errors return [x for x in self.currencies.errors
if isinstance(x, str) or isinstance(x, LazyString)] if isinstance(x, str) or isinstance(x, LazyString)]
@property
def summary_helper(self) -> SummaryHelper:
"""Returns the summary helper.
:return: The summary helper.
"""
return SummaryHelper()
T = t.TypeVar("T", bound=TransactionForm) T = t.TypeVar("T", bound=TransactionForm)
"""A transaction form variant.""" """A transaction form variant."""

View File

@ -0,0 +1,227 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
# 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.
"""The summary helper.
"""
import typing as t
import sqlalchemy as sa
from accounting import db
from accounting.models import Account, JournalEntry
class SummaryAccount:
"""An account for a summary tag."""
def __init__(self, account: Account, freq: int):
"""Constructs an account for a summary tag.
:param account: The account.
:param freq: The frequency of the tag with the account.
"""
self.__account: Account = account
"""The account."""
self.code: str = account.code
"""The account code."""
self.freq: int = freq
"""The frequency of the tag with the account."""
def __str__(self) -> str:
"""Returns the string representation of the account.
:return: The string representation of the account.
"""
return str(self.__account)
def add_freq(self, freq: int) -> None:
"""Adds the frequency of an account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
self.freq = self.freq + freq
class SummaryTag:
"""A summary tag."""
def __init__(self, name: str):
"""Constructs a summary tag.
:param name: The tag name.
"""
self.name: str = name
"""The tag name."""
self.__account_dict: dict[int, SummaryAccount] = {}
"""The accounts that come with the tag, in the order of their
frequency."""
self.freq: int = 0
"""The frequency of the tag."""
def __str__(self) -> str:
"""Returns the string representation of the tag.
:return: The string representation of the tag.
"""
return self.name
def add_account(self, account: Account, freq: int):
"""Adds an account.
:param account: The associated account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
self.__account_dict[account.id] = SummaryAccount(account, freq)
self.freq = self.freq + freq
@property
def accounts(self) -> list[SummaryAccount]:
"""Returns the accounts by the order of their frequencies.
:return: The accounts by the order of their frequencies.
"""
return sorted(self.__account_dict.values(), key=lambda x: -x.freq)
class SummaryType:
"""A summary type"""
def __init__(self, type_id: t.Literal["general", "travel", "bus"]):
"""Constructs a summary type.
:param type_id: The type ID, either "general", "travel", or "bus".
"""
self.id: t.Literal["general", "travel", "bus"] = type_id
"""The type ID."""
self.__tag_dict: dict[str, SummaryTag] = {}
"""A dictionary from the tag name to their corresponding tag."""
def add_tag(self, name: str, account: Account, freq: int) -> None:
"""Adds a tag.
:param name: The tag name.
:param account: The associated account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
if name not in self.__tag_dict:
self.__tag_dict[name] = SummaryTag(name)
self.__tag_dict[name].add_account(account, freq)
@property
def tags(self) -> list[SummaryTag]:
"""Returns the tags by the order of their frequencies.
:return: The tags by the order of their frequencies.
"""
return sorted(self.__tag_dict.values(), key=lambda x: -x.freq)
class SummaryEntryType:
"""A summary type"""
def __init__(self, entry_type_id: t.Literal["debit", "credit"]):
"""Constructs a summary entry type.
:param entry_type_id: The entry type ID, either "debit" or "credit".
"""
self.type: t.Literal["debit", "credit"] = entry_type_id
"""The entry type."""
self.general: SummaryType = SummaryType("general")
"""The general tags."""
self.travel: SummaryType = SummaryType("travel")
"""The travel tags."""
self.bus: SummaryType = SummaryType("bus")
"""The bus tags."""
self.__type_dict: dict[t.Literal["general", "travel", "bus"],
SummaryType] \
= {x.id: x for x in {self.general, self.travel, self.bus}}
"""A dictionary from the type ID to the corresponding tags."""
def add_tag(self, tag_type: t.Literal["general", "travel", "bus"],
name: str, account: Account, freq: int) -> None:
"""Adds a tag.
:param tag_type: The tag type, either "general", "travel", or "bus".
:param name: The name.
:param account: The associated account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
self.__type_dict[tag_type].add_tag(name, account, freq)
class SummaryHelper:
"""The summary helper."""
def __init__(self):
"""Constructs the summary helper."""
self.debit: SummaryEntryType = SummaryEntryType("debit")
"""The debit tags."""
self.credit: SummaryEntryType = SummaryEntryType("credit")
"""The credit tags."""
self.types: set[SummaryEntryType] = {self.debit, self.credit}
"""The tags categorized by the entry types."""
entry_type: sa.Label = sa.case((JournalEntry.is_debit, "debit"),
else_="credit").label("entry_type")
tag_type: sa.Label = sa.case(
(JournalEntry.summary.like("_%—_%—_%→_%"), "bus"),
(sa.or_(JournalEntry.summary.like("_%—_%→_%"),
JournalEntry.summary.like("_%—_%↔_%")), "travel"),
else_="general").label("tag_type")
tag: sa.Label = get_prefix(JournalEntry.summary, "").label("tag")
select: sa.Select = sa.Select(entry_type, tag_type, tag,
JournalEntry.account_id,
sa.func.count().label("freq"))\
.filter(JournalEntry.summary.is_not(None),
JournalEntry.summary.like("_%—_%"))\
.group_by(entry_type, tag_type, tag, JournalEntry.account_id)
result: list[sa.Row] = db.session.execute(select).all()
accounts: dict[int, Account] \
= {x.id: x for x in Account.query
.filter(Account.id.in_({x.account_id for x in result})).all()}
entry_type_dict: dict[t.Literal["debit", "credit"], SummaryEntryType] \
= {x.type: x for x in self.types}
for row in result:
entry_type_dict[row.entry_type].add_tag(
row.tag_type, row.tag, accounts[row.account_id], row.freq)
def get_prefix(string: str | sa.Column, separator: str | sa.Column) \
-> sa.Function:
"""Returns the SQL function to find the prefix of a string.
:param string: The string.
:param separator: The separator.
:return: The position of the substring, starting from 1.
"""
return sa.func.substr(string, 0, get_position(string, separator))
def get_position(string: str | sa.Column, substring: str | sa.Column) \
-> sa.Function:
"""Returns the SQL function to find the position of a substring.
:param string: The string.
:param substring: The substring.
:return: The position of the substring, starting from 1.
"""
if db.engine.name == "postgresql":
return sa.func.strpos(string, substring)
return sa.func.instr(string, substring)

View File

@ -0,0 +1,327 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/28
# 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.
"""The test for the summary helper.
"""
import unittest
from datetime import date
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import create_app
from testlib import get_client
from testlib_txn import Accounts, NEXT_URI, add_txn
class SummeryHelperTestCase(unittest.TestCase):
"""The summary helper test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount, Transaction, \
JournalEntry
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
Transaction.query.delete()
JournalEntry.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor")
def test_summary_helper(self) -> None:
"""Test the summary helper.
:return: None.
"""
from accounting.transaction.summary_helper import SummaryHelper
for form in get_form_data(self.csrf_token):
add_txn(self.client, form)
with self.app.app_context():
helper: SummaryHelper = SummaryHelper()
# Debit-General
self.assertEqual(len(helper.debit.general.tags), 2)
self.assertEqual(helper.debit.general.tags[0].name, "Lunch")
self.assertEqual(len(helper.debit.general.tags[0].accounts), 2)
self.assertEqual(helper.debit.general.tags[0].accounts[0].code,
Accounts.MEAL)
self.assertEqual(helper.debit.general.tags[0].accounts[1].code,
Accounts.PAYABLE)
self.assertEqual(helper.debit.general.tags[1].name, "Dinner")
self.assertEqual(len(helper.debit.general.tags[1].accounts), 2)
self.assertEqual(helper.debit.general.tags[1].accounts[0].code,
Accounts.MEAL)
self.assertEqual(helper.debit.general.tags[1].accounts[1].code,
Accounts.PAYABLE)
# Debit-Travel
self.assertEqual(len(helper.debit.travel.tags), 3)
self.assertEqual(helper.debit.travel.tags[0].name, "Bike")
self.assertEqual(len(helper.debit.travel.tags[0].accounts), 1)
self.assertEqual(helper.debit.travel.tags[0].accounts[0].code,
Accounts.TRAVEL)
self.assertEqual(helper.debit.travel.tags[1].name, "Taxi")
self.assertEqual(len(helper.debit.travel.tags[1].accounts), 1)
self.assertEqual(helper.debit.travel.tags[1].accounts[0].code,
Accounts.TRAVEL)
self.assertEqual(helper.debit.travel.tags[2].name, "Airplane")
self.assertEqual(len(helper.debit.travel.tags[2].accounts), 1)
self.assertEqual(helper.debit.travel.tags[2].accounts[0].code,
Accounts.TRAVEL)
# Debit-Bus
self.assertEqual(len(helper.debit.bus.tags), 2)
self.assertEqual(helper.debit.bus.tags[0].name, "Train")
self.assertEqual(len(helper.debit.bus.tags[0].accounts), 1)
self.assertEqual(helper.debit.bus.tags[0].accounts[0].code,
Accounts.TRAVEL)
self.assertEqual(helper.debit.bus.tags[1].name, "Bus")
self.assertEqual(len(helper.debit.bus.tags[1].accounts), 1)
self.assertEqual(helper.debit.bus.tags[1].accounts[0].code,
Accounts.TRAVEL)
# Credit-General
self.assertEqual(len(helper.credit.general.tags), 2)
self.assertEqual(helper.credit.general.tags[0].name, "Lunch")
self.assertEqual(len(helper.credit.general.tags[0].accounts), 3)
self.assertEqual(helper.credit.general.tags[0].accounts[0].code,
Accounts.PAYABLE)
self.assertEqual(helper.credit.general.tags[0].accounts[1].code,
Accounts.BANK)
self.assertEqual(helper.credit.general.tags[0].accounts[2].code,
Accounts.CASH)
self.assertEqual(helper.credit.general.tags[1].name, "Dinner")
self.assertEqual(len(helper.credit.general.tags[1].accounts), 2)
self.assertEqual(helper.credit.general.tags[1].accounts[0].code,
Accounts.BANK)
self.assertEqual(helper.credit.general.tags[1].accounts[1].code,
Accounts.PAYABLE)
# Credit-Travel
self.assertEqual(len(helper.credit.travel.tags), 2)
self.assertEqual(helper.credit.travel.tags[0].name, "Bike")
self.assertEqual(len(helper.credit.travel.tags[0].accounts), 2)
self.assertEqual(helper.credit.travel.tags[0].accounts[0].code,
Accounts.PAYABLE)
self.assertEqual(helper.credit.travel.tags[0].accounts[1].code,
Accounts.PREPAID)
self.assertEqual(helper.credit.travel.tags[1].name, "Taxi")
self.assertEqual(len(helper.credit.travel.tags[1].accounts), 2)
self.assertEqual(helper.credit.travel.tags[1].accounts[0].code,
Accounts.PAYABLE)
self.assertEqual(helper.credit.travel.tags[1].accounts[1].code,
Accounts.CASH)
# Credit-Bus
self.assertEqual(len(helper.credit.bus.tags), 2)
self.assertEqual(helper.credit.bus.tags[0].name, "Train")
self.assertEqual(len(helper.credit.bus.tags[0].accounts), 2)
self.assertEqual(helper.credit.bus.tags[0].accounts[0].code,
Accounts.PREPAID)
self.assertEqual(helper.credit.bus.tags[0].accounts[1].code,
Accounts.PAYABLE)
self.assertEqual(helper.credit.bus.tags[1].name, "Bus")
self.assertEqual(len(helper.credit.bus.tags[1].accounts), 1)
self.assertEqual(helper.credit.bus.tags[1].accounts[0].code,
Accounts.PREPAID)
def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"""Returns the form data for multiple transaction forms.
:param csrf_token: The CSRF token.
:return: A list of the form data.
"""
txn_date: str = date.today().isoformat()
return [{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-credit-0-account_code": Accounts.SERVICE,
"currency-0-credit-0-summary": " Salary ",
"currency-0-credit-0-amount": "2500"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL,
"currency-0-debit-0-summary": " Lunch—Fish ",
"currency-0-debit-0-amount": "4.7",
"currency-0-credit-0-account_code": Accounts.BANK,
"currency-0-credit-0-summary": " Lunch—Fish ",
"currency-0-credit-0-amount": "4.7",
"currency-0-debit-1-account_code": Accounts.MEAL,
"currency-0-debit-1-summary": " Lunch—Fries ",
"currency-0-debit-1-amount": "2.15",
"currency-0-credit-1-account_code": Accounts.PAYABLE,
"currency-0-credit-1-summary": " Lunch—Fries ",
"currency-0-credit-1-amount": "2.15",
"currency-0-debit-2-account_code": Accounts.MEAL,
"currency-0-debit-2-summary": " Dinner—Hamburger ",
"currency-0-debit-2-amount": "4.25",
"currency-0-credit-2-account_code": Accounts.BANK,
"currency-0-credit-2-summary": " Dinner—Hamburger ",
"currency-0-credit-2-amount": "4.25"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL,
"currency-0-debit-0-summary": " Lunch—Salad ",
"currency-0-debit-0-amount": "4.99",
"currency-0-credit-0-account_code": Accounts.CASH,
"currency-0-credit-0-summary": " Lunch—Salad ",
"currency-0-credit-0-amount": "4.99",
"currency-0-debit-1-account_code": Accounts.MEAL,
"currency-0-debit-1-summary": " Dinner—Steak ",
"currency-0-debit-1-amount": "8.28",
"currency-0-credit-1-account_code": Accounts.PAYABLE,
"currency-0-credit-1-summary": " Dinner—Steak ",
"currency-0-credit-1-amount": "8.28"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL,
"currency-0-debit-0-summary": " Lunch—Pizza ",
"currency-0-debit-0-amount": "5.49",
"currency-0-credit-0-account_code": Accounts.PAYABLE,
"currency-0-credit-0-summary": " Lunch—Pizza ",
"currency-0-credit-0-amount": "5.49",
"currency-0-debit-1-account_code": Accounts.MEAL,
"currency-0-debit-1-summary": " Lunch—Noodles ",
"currency-0-debit-1-amount": "7.47",
"currency-0-credit-1-account_code": Accounts.PAYABLE,
"currency-0-credit-1-summary": " Lunch—Noodles ",
"currency-0-credit-1-amount": "7.47"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL,
"currency-0-debit-0-summary": " Airplane—Lake City↔Hill Town ",
"currency-0-debit-0-amount": "800"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL,
"currency-0-debit-0-summary": " Bus—323—Downtown→Museum ",
"currency-0-debit-0-amount": "2.5",
"currency-0-credit-0-account_code": Accounts.PREPAID,
"currency-0-credit-0-summary": " Bus—323—Downtown→Museum ",
"currency-0-credit-0-amount": "2.5",
"currency-0-debit-1-account_code": Accounts.TRAVEL,
"currency-0-debit-1-summary": " Train—Blue—Museum→Central ",
"currency-0-debit-1-amount": "3.2",
"currency-0-credit-1-account_code": Accounts.PREPAID,
"currency-0-credit-1-summary": " Train—Blue—Museum→Central ",
"currency-0-credit-1-amount": "3.2",
"currency-0-debit-2-account_code": Accounts.TRAVEL,
"currency-0-debit-2-summary": " Train—Green—Central→Mall ",
"currency-0-debit-2-amount": "3.2",
"currency-0-credit-2-account_code": Accounts.PREPAID,
"currency-0-credit-2-summary": " Train—Green—Central→Mall ",
"currency-0-credit-2-amount": "3.2",
"currency-0-debit-3-account_code": Accounts.TRAVEL,
"currency-0-debit-3-summary": " Train—Red—Mall→Museum ",
"currency-0-debit-3-amount": "4.4",
"currency-0-credit-3-account_code": Accounts.PAYABLE,
"currency-0-credit-3-summary": " Train—Red—Mall→Museum ",
"currency-0-credit-3-amount": "4.4"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL,
"currency-0-debit-0-summary": " Taxi—Museum→Office ",
"currency-0-debit-0-amount": "15.5",
"currency-0-credit-0-account_code": Accounts.CASH,
"currency-0-credit-0-summary": " Taxi—Museum→Office ",
"currency-0-credit-0-amount": "15.5",
"currency-0-debit-1-account_code": Accounts.TRAVEL,
"currency-0-debit-1-summary": " Taxi—Office→Restaurant ",
"currency-0-debit-1-amount": "12",
"currency-0-credit-1-account_code": Accounts.PAYABLE,
"currency-0-credit-1-summary": " Taxi—Office→Restaurant ",
"currency-0-credit-1-amount": "12",
"currency-0-debit-2-account_code": Accounts.TRAVEL,
"currency-0-debit-2-summary": " Taxi—Restaurant→City Hall ",
"currency-0-debit-2-amount": "8",
"currency-0-credit-2-account_code": Accounts.PAYABLE,
"currency-0-credit-2-summary": " Taxi—Restaurant→City Hall ",
"currency-0-credit-2-amount": "8",
"currency-0-debit-3-account_code": Accounts.TRAVEL,
"currency-0-debit-3-summary": " Bike—City Hall→Office ",
"currency-0-debit-3-amount": "3.5",
"currency-0-credit-3-account_code": Accounts.PAYABLE,
"currency-0-credit-3-summary": " Bike—City Hall→Office ",
"currency-0-credit-3-amount": "3.5",
"currency-0-debit-4-account_code": Accounts.TRAVEL,
"currency-0-debit-4-summary": " Bike—Restaurant→Office ",
"currency-0-debit-4-amount": "4",
"currency-0-credit-4-account_code": Accounts.PAYABLE,
"currency-0-credit-4-summary": " Bike—Restaurant→Office ",
"currency-0-credit-4-amount": "4",
"currency-0-debit-5-account_code": Accounts.TRAVEL,
"currency-0-debit-5-summary": " Bike—Office→Theatre ",
"currency-0-debit-5-amount": "1.5",
"currency-0-credit-5-account_code": Accounts.PAYABLE,
"currency-0-credit-5-summary": " Bike—Office→Theatre ",
"currency-0-credit-5-amount": "1.5",
"currency-0-debit-6-account_code": Accounts.TRAVEL,
"currency-0-debit-6-summary": " Bike—Theatre→Home ",
"currency-0-debit-6-amount": "5.5",
"currency-0-credit-6-account_code": Accounts.PREPAID,
"currency-0-credit-6-summary": " Bike—Theatre→Home ",
"currency-0-credit-6-amount": "5.5"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.PAYABLE,
"currency-0-debit-0-summary": " Dinner—Steak ",
"currency-0-debit-0-amount": "8.28",
"currency-0-credit-0-account_code": Accounts.BANK,
"currency-0-credit-0-summary": " Dinner—Steak ",
"currency-0-credit-0-amount": "8.28",
"currency-0-debit-1-account_code": Accounts.PAYABLE,
"currency-0-debit-1-summary": " Lunch—Pizza ",
"currency-0-debit-1-amount": "5.49",
"currency-0-credit-1-account_code": Accounts.BANK,
"currency-0-credit-1-summary": " Lunch—Pizza ",
"currency-0-credit-1-amount": "5.49"}]

View File

@ -39,11 +39,14 @@ class Accounts:
"""The shortcuts to the common accounts.""" """The shortcuts to the common accounts."""
CASH: str = "1111-001" CASH: str = "1111-001"
BANK: str = "1113-001" BANK: str = "1113-001"
PREPAID: str = "1258-001"
PAYABLE: str = "2141-001"
SALES: str = "4111-001" SALES: str = "4111-001"
SERVICE: str = "4611-001" SERVICE: str = "4611-001"
AGENCY: str = "4711-001" AGENCY: str = "4711-001"
OFFICE: str = "6153-001" OFFICE: str = "6153-001"
TRAVEL: str = "6154-001" TRAVEL: str = "6154-001"
MEAL: str = "6172-001"
INTEREST: str = "4111-001" INTEREST: str = "4111-001"
DONATION: str = "7481-001" DONATION: str = "7481-001"
RENT: str = "7482-001" RENT: str = "7482-001"