From b2ce0eff54f3a57936d6ac5a557d6735a2f97656 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BE=9D=E7=91=AA=E8=B2=93?= Date: Wed, 5 Aug 2020 07:48:50 +0800 Subject: [PATCH] Applied the summary helper and JavaScripts to the transaction form, so that the transaction form works in the accounting application. --- .../static/accounting/css/summary-helper.css | 36 + .../static/accounting/css/transactions.css | 2 +- .../static/accounting/js/regular-payments.js | 63 ++ .../static/accounting/js/summary-helper.js | 542 ++++++++++++++ .../static/accounting/js/transaction-form.js | 703 ++++++++++++++++++ .../accounting/include/summary-helper.html | 164 ++++ .../accounting/transactions/expense/form.html | 8 + .../accounting/transactions/income/form.html | 8 + .../transactions/transfer/form.html | 8 + accounting/utils.py | 7 +- 10 files changed, 1539 insertions(+), 2 deletions(-) create mode 100644 accounting/static/accounting/css/summary-helper.css create mode 100644 accounting/static/accounting/js/regular-payments.js create mode 100644 accounting/static/accounting/js/summary-helper.js create mode 100644 accounting/static/accounting/js/transaction-form.js create mode 100644 accounting/templates/accounting/include/summary-helper.html diff --git a/accounting/static/accounting/css/summary-helper.css b/accounting/static/accounting/css/summary-helper.css new file mode 100644 index 0000000..f944b9a --- /dev/null +++ b/accounting/static/accounting/css/summary-helper.css @@ -0,0 +1,36 @@ +/* The Mia Website + * summary-helper.css: The style sheet for the summary helper + */ + +/* Copyright (c) 2019-2020 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: 2020/4/3 + */ + +.summary-container { + padding: 0 0 1em 0; +} +.summary-tab-content { + padding-top: 1em; +} +.summary-categories-known { + max-height: 200px; + overflow-y: scroll; +} +.summary-categories-known .btn-summary-helper { + margin: 0.1em; +} diff --git a/accounting/static/accounting/css/transactions.css b/accounting/static/accounting/css/transactions.css index 9bdf59a..c8f6aaa 100644 --- a/accounting/static/accounting/css/transactions.css +++ b/accounting/static/accounting/css/transactions.css @@ -21,7 +21,7 @@ * First written: 2019/9/17 */ -.subject-line { +.account-line { font-size: 0.833em; } .amount { diff --git a/accounting/static/accounting/js/regular-payments.js b/accounting/static/accounting/js/regular-payments.js new file mode 100644 index 0000000..6e2d2c2 --- /dev/null +++ b/accounting/static/accounting/js/regular-payments.js @@ -0,0 +1,63 @@ +/* The Mia Website + * regular-payments.js: The JavaScript for the regular payments + */ + +/* Copyright (c) 2019-2020 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: 2020/4/4 + */ + +/** + * Returns the regular payment data. + * + * @returns {{debits: [], credits: []}} + */ +function getRegularPayments() { + const today = new Date($("#txn-date").get(0).value); + const thisMonth = today.getMonth() + 1; + const lastMonth = (thisMonth + 10) % 12 + 1; + let regular = { + debit: [], + credit: [], + }; + regular.debit.push({ + title: "共同生活基金", + summary: "共同生活基金" + thisMonth + "月", + account: "62651", + }); + regular.debit.push({ + title: "電話費", + summary: "電話費" + lastMonth + "月", + account: "62562", + }); + regular.debit.push({ + title: "健保", + summary: "健保" + lastMonth + "月", + account: "62621", + }); + regular.debit.push({ + title: "國民年金", + summary: "國民年金" + lastMonth + "月", + account: "13141", + }); + regular.credit.push({ + title: "薪水", + summary: lastMonth + "月份薪水", + account: "46116", + }); + return regular; +} diff --git a/accounting/static/accounting/js/summary-helper.js b/accounting/static/accounting/js/summary-helper.js new file mode 100644 index 0000000..7298edf --- /dev/null +++ b/accounting/static/accounting/js/summary-helper.js @@ -0,0 +1,542 @@ +/* The Mia Website + * summary-helper.js: The JavaScript for the summary helper + */ + +/* Copyright (c) 2019-2020 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: 2020/4/3 + */ + +// Initializes the summary helper JavaScript. +$(function () { + loadSummaryCategoryData(); + $("#summary-helper-form") + .on("submit", function () { + return false; + }); + $(".record-summary") + .attr("data-toggle", "modal") + .attr("data-target", "#summary-modal") + .on("click", function () { + startSummaryHelper(this); + }); + $("#summary-summary") + .on("change", function () { + this.value = this.value.trim(); + parseSummaryForHelper(this.value); + }); + $(".summary-tab") + .on("click", function () { + switchSummaryTab(this); + }); + // The general categories + $("#summary-general-category") + .on("change", function () { + setSummaryGeneralCategoryButtons(this.value); + setGeneralCategorySummary(); + setSummaryAccount("general", this.value); + }); + // The travel routes + $("#summary-travel-category") + .on("change", function () { + setSummaryTravelCategoryButtons(this.value); + setSummaryAccount("travel", this.value); + }); + $(".summary-travel-part") + .on("change", function () { + this.value = this.value.trim(); + setTravelSummary(); + }); + $(".btn-summary-travel-direction") + .on("click", function () { + $("#summary-travel-direction").get(0).value = this.innerText; + setSummaryTravelDirectionButtons(this.innerText); + setTravelSummary(); + }); + // The bus routes + $("#summary-bus-category") + .on("change", function () { + setSummaryBusCategoryButtons(this.value); + setSummaryAccount("bus", this.value); + }); + $(".summary-bus-part") + .on("change", function () { + this.value = this.value.trim(); + setBusSummary(); + }); + $("#summary-count") + .on("change", function () { + updateSummaryCount(); + }); + $("#summary-confirm") + .on("click", function () { + applySummaryToAccountingRecord(); + }); +}); + +/** + * The known categories + * @type {object} + * @private + */ +let summaryCategories = null; + +/** + * The known categories and their corresponding accounts + * @type {object} + * @private + */ +let summaryAccounts = null; + +/** + * The account that corresponds to this category + * @type {null|string} + * @private + */ +let summaryAccount = null; + +/** + * Loads the summary category data. + * + * @private + */ +function loadSummaryCategoryData() { + const data = JSON.parse($("#summary-categories").val()); + summaryCategories = {}; + summaryAccounts = {}; + ["debit", "credit"].forEach(function (type) { + summaryCategories[type] = {}; + summaryAccounts[type] = {}; + ["general", "travel", "bus"].forEach(function (format) { + summaryCategories[type][format] = []; + summaryAccounts[type][format] = {}; + if (type + "-" + format in data) { + data[type + "-" + format] + .forEach(function (item) { + summaryCategories[type][format].push(item[0]); + summaryAccounts[type][format][item[0]] = item[1]; + }); + } + }); + }); +} + +/** + * Starts the summary helper. + * + * @param {HTMLInputElement} summary the summary input element + */ +function startSummaryHelper(summary) { + const type = summary.id.substring(0, summary.id.indexOf("-")); + const no = parseInt(summary.id.substring(type.length + 1, summary.id.indexOf("-", type.length + 1))); + $("#summary-record").get(0).value = type + "-" + no; + $("#summary-summary").get(0).value = summary.value; + // Loads the know summary categories into the summary helper + loadKnownSummaryCategories(type); + // Parses the summary and sets up the summary helper + parseSummaryForHelper(summary.value); + // Focus on the summary input + setTimeout(function () { + $("#summary-summary").get(0).focus(); + }, 100); +} + +/** + * Loads the known summary categories into the summary helper. + * + * @param {string} type the record type, either "debit" or "credit" + * @private + */ +function loadKnownSummaryCategories(type) { + ["general", "travel", "bus"].forEach(function (format) { + const knownCategories = $("#summary-" + format + "-categories-known"); + knownCategories.html(""); + summaryCategories[type][format].forEach(function (item) { + knownCategories.append( + $("") + .addClass("btn btn-outline-primary") + .addClass("btn-summary-helper") + .addClass("btn-summary-" + format + "-category") + .text(item)); + }); + }); + + // The regular payments + const regularPayments = getRegularPayments(); + ["debit", "credit"].forEach(function (type) { + summaryCategories[type].regular = []; + summaryAccounts[type].regular = {}; + regularPayments[type].forEach(function (item) { + summaryCategories[type].regular.push(item); + summaryAccounts[type].regular[item.title] = item.account; + }); + }); + const regularPaymentButtons = $("#summary-regular-payments"); + regularPaymentButtons.html(""); + summaryCategories[type].regular.forEach(function (item) { + regularPaymentButtons.append( + $("") + .attr("title", item.summary) + .addClass("btn btn-outline-primary") + .addClass("btn-summary-helper") + .addClass("btn-summary-regular") + .text(item.title)); + }); + + $(".btn-summary-general-category") + .on("click", function () { + $("#summary-general-category").get(0).value = this.innerText; + setSummaryGeneralCategoryButtons(this.innerText); + setGeneralCategorySummary(); + setSummaryAccount("general", this.innerText); + }); + $(".btn-summary-travel-category") + .on("click", function () { + $("#summary-travel-category").get(0).value = this.innerText; + setSummaryTravelCategoryButtons(this.innerText); + setTravelSummary(); + setSummaryAccount("travel", this.innerText); + }); + $(".btn-summary-bus-category") + .on("click", function () { + $("#summary-bus-category").get(0).value = this.innerText; + setSummaryBusCategoryButtons(this.innerText); + setBusSummary(); + setSummaryAccount("bus", this.innerText); + }); + $(".btn-summary-regular") + .on("click", function () { + $("#summary-summary").get(0).value = this.title; + setSummaryRegularPaymentButtons(this.innerText); + setSummaryAccount("regular", this.innerText); + }); +} + +/** + * Parses the summary and sets up the summary helper. + * + * @param {string} summary the summary + */ +function parseSummaryForHelper(summary) { + // Parses the summary and sets up the category helpers. + parseSummaryForCategoryHelpers(summary); + // The number of items + const pos = summary.lastIndexOf("×"); + let count = 1; + if (pos !== -1) { + count = parseInt(summary.substr(pos + 1)); + } + if (count === 0) { + count = 1; + } + $("#summary-count").get(0).value = count; +} + +/** + * Parses the summary and sets up the category helpers. + * + * @param {string} summary the summary + */ +function parseSummaryForCategoryHelpers(summary) { + $(".btn-summary-helper") + .removeClass("btn-primary") + .addClass("btn-outline-primary"); + $("#btn-summary-one-way") + .removeClass("btn-outline-primary") + .addClass("btn-primary"); + $(".summary-helper-input").each(function () { + this.classList.remove("is-invalid"); + if (this.id === "summary-travel-direction") { + this.value = $("#btn-summary-one-way").text(); + } else { + this.value = ""; + } + }); + + // A bus route + const matchBus = summary.match(/^(.+)—(.+)—(.+)→(.+?)(?:×[0-9]+)?$/); + if (matchBus !== null) { + $("#summary-bus-category").get(0).value = matchBus[1]; + setSummaryBusCategoryButtons(matchBus[1]); + setSummaryAccount("bus", matchBus[1]); + $("#summary-bus-route").get(0).value = matchBus[2]; + $("#summary-bus-from").get(0).value = matchBus[3]; + $("#summary-bus-to").get(0).value = matchBus[4]; + switchSummaryTab($("#summary-tab-bus").get(0)); + return; + } + + // A general travel route + const matchTravel = summary.match(/^(.+)—(.+)([→|↔])(.+?)(?:×[0-9]+)?$/); + if (matchTravel !== null) { + $("#summary-travel-category").get(0).value = matchTravel[1]; + setSummaryTravelCategoryButtons(matchTravel[1]); + setSummaryAccount("travel", matchTravel[1]); + $("#summary-travel-from").get(0).value = matchTravel[2]; + $("#summary-travel-direction").get(0).value = matchTravel[3]; + setSummaryTravelDirectionButtons(matchTravel[3]); + $("#summary-travel-to").get(0).value = matchTravel[4]; + switchSummaryTab($("#summary-tab-travel").get(0)); + return; + } + + // A general category + const generalCategoryTab = $("#summary-tab-category").get(0); + const matchCategory = summary.match(/^(.+)—.+(?:×[0-9]+)?$/); + if (matchCategory !== null) { + $("#summary-general-category").get(0).value = matchCategory[1]; + setSummaryGeneralCategoryButtons(matchCategory[1]); + setSummaryAccount("general", matchCategory[1]); + switchSummaryTab(generalCategoryTab); + return; + } + + // A general summary text + setSummaryGeneralCategoryButtons(null); + setSummaryAccount("general", null); + switchSummaryTab(generalCategoryTab); +} + +/** + * Switch the summary helper to tab. + * + * @param {HTMLElement} tab the navigation tab corresponding to a type + * of helper + * @private + */ +function switchSummaryTab(tab) { + const tabName = tab.id.substr("summary-tab-".length); // "summary-tab-" + $(".summary-tab-content").each(function () { + if (this.id === "summary-tab-content-" + tabName) { + this.classList.remove("d-none"); + } else { + this.classList.add("d-none"); + } + }); + $(".summary-tab").each(function () { + if (this.id === tab.id) { + this.classList.add("active"); + } else { + this.classList.remove("active"); + } + }); +} + +/** + * Sets the known general category buttons. + * + * @param {string} category the general category + */ +function setSummaryGeneralCategoryButtons(category) { + $(".btn-summary-general-category").each(function () { + if (this.innerText === category) { + this.classList.remove("btn-outline-primary"); + this.classList.add("btn-primary"); + } else { + this.classList.add("btn-outline-primary"); + this.classList.remove("btn-primary"); + } + }); +} + +/** + * Sets the summary of a general category. + * + */ +function setGeneralCategorySummary() { + const summary = $("#summary-summary").get(0); + const dashPos = summary.value.indexOf("—"); + if (dashPos !== -1) { + summary.value = summary.value.substring(dashPos + 1); + } + const category = $("#summary-general-category").get(0).value; + if (category !== "") { + summary.value = category + "—" + summary.value; + } +} + +/** + * Sets the known travel category buttons. + * + * @param {string} category the travel category + */ +function setSummaryTravelCategoryButtons(category) { + $(".btn-summary-travel-category").each(function () { + if (this.innerText === category) { + this.classList.remove("btn-outline-primary"); + this.classList.add("btn-primary"); + } else { + this.classList.add("btn-outline-primary"); + this.classList.remove("btn-primary"); + } + }); +} + +/** + * Sets the summary of a general travel. + * + */ +function setTravelSummary() { + $(".summary-travel-part").each(function () { + if (this.value === "") { + this.classList.add("is-invalid"); + } else { + this.classList.remove("is-invalid"); + } + }); + let summary = $("#summary-travel-category").get(0).value + + "—" + $("#summary-travel-from").get(0).value + + $("#summary-travel-direction").get(0).value + + $("#summary-travel-to").get(0).value; + const count = parseInt($("#summary-count").get(0).value); + if (count !== 1) { + summary = summary + "×" + count; + } + $("#summary-summary").get(0).value = summary; +} + +/** + * Sets the known summary travel direction buttons. + * + * @param {string} direction the known summary travel direction + */ +function setSummaryTravelDirectionButtons(direction) { + $(".btn-summary-travel-direction").each(function () { + if (this.innerText === direction) { + this.classList.remove("btn-outline-primary"); + this.classList.add("btn-primary"); + } else { + this.classList.add("btn-outline-primary"); + this.classList.remove("btn-primary"); + } + }); +} + +/** + * Sets the known bus category buttons. + * + * @param {string} category the bus category + */ +function setSummaryBusCategoryButtons(category) { + $(".btn-summary-bus-category").each(function () { + if (this.innerText === category) { + this.classList.remove("btn-outline-primary"); + this.classList.add("btn-primary"); + } else { + this.classList.add("btn-outline-primary"); + this.classList.remove("btn-primary"); + } + }); +} + +/** + * Sets the summary of a bus travel. + * + */ +function setBusSummary() { + $(".summary-bus-part").each(function () { + if (this.value === "") { + this.classList.add("is-invalid"); + } else { + this.classList.remove("is-invalid"); + } + }); + let summary = $("#summary-bus-category").get(0).value + + "—" + $("#summary-bus-route").get(0).value + + "—" + $("#summary-bus-from").get(0).value + + "→" + $("#summary-bus-to").get(0).value; + const count = parseInt($("#summary-count").get(0).value); + if (count !== 1) { + summary = summary + "×" + count; + } + $("#summary-summary").get(0).value = summary; +} + +/** + * Sets the regular payment buttons. + * + * @param {string} category the regular payment + */ +function setSummaryRegularPaymentButtons(category) { + $(".btn-summary-regular").each(function () { + if (this.innerText === category) { + this.classList.remove("btn-outline-primary"); + this.classList.add("btn-primary"); + } else { + this.classList.add("btn-outline-primary"); + this.classList.remove("btn-primary"); + } + }); +} + +/** + * Sets the account for this summary category. + * + * @param {string} format the category format, either "general", + * "travel", or "bus". + * @param {string} category the category + */ +function setSummaryAccount(format, category) { + const recordId = $("#summary-record").get(0).value; + const type = recordId.substring(0, recordId.indexOf("-")); + if (category in summaryAccounts[type][format]) { + summaryAccount = summaryAccounts[type][format][category]; + } else { + summaryAccount = null; + } +} + +/** + * Updates the count. + * + * @private + */ +function updateSummaryCount() { + const count = parseInt($("#summary-count").val()); + const summary = $("#summary-summary").get(0); + const pos = summary.value.lastIndexOf("×"); + if (pos === -1) { + if (count !== 1) { + summary.value = summary.value + "×" + count; + } + } else { + const content = summary.value.substring(0, pos); + if (count === 1) { + summary.value = content; + } else { + summary.value = content + "×" + count; + } + } +} + +/** + * Applies the summary to the accounting record. + * + * @private + */ +function applySummaryToAccountingRecord() { + const recordId = $("#summary-record").get(0).value; + const summary = $("#" + recordId + "-summary").get(0); + summary.value = $("#summary-summary").get(0).value.trim(); + const account = $("#" + recordId + "-account").get(0); + if (summaryAccount !== null && account.value === "") { + account.value = summaryAccount; + } + setTimeout(function () { + summary.blur(); + }, 100); +} diff --git a/accounting/static/accounting/js/transaction-form.js b/accounting/static/accounting/js/transaction-form.js new file mode 100644 index 0000000..4c01f2a --- /dev/null +++ b/accounting/static/accounting/js/transaction-form.js @@ -0,0 +1,703 @@ +/* The Mia Website + * transaction-form.js: The JavaScript for the transaction form + */ + +/* Copyright (c) 2019-2020 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: 2019/9/19 + */ + +// Initializes the page JavaScript. +$(function () { + getAccountOptions(); + resetRecordButtons(); + $("#txn-date") + .on("blur", function () { + validateDate(); + }); + $(".record-account") + .on("focus", function () { + removeBlankOption(this); + }) + .on("blur", function () { + validateAccount(this); + }); + $(".record-summary") + .on("blur", function () { + validateSummary(this); + }); + $(".record-amount") + .on("blur", function () { + validateAmount(this); + }) + .on("change", function () { + updateTotalAmount(this); + validateBalance(); + }); + $("#txn-note") + .on("blur", function () { + validateNote(); + }); + $("#txn-form") + .on("submit", function () { + return validateForm(); + }); + $(".btn-new") + .on("click", function () { + addNewRecord(this); + }); + $(".btn-del-record") + .on("click", function () { + deleteRecord(this); + }); +}); + +/** + * The localized messages + * @type {Array.} + * @private + */ +let l10n = null; + +/** + * Returns the localization of a message. + * + * @param {string} key the message key + * @returns {string} the localized message + * @private + */ +function __(key) { + if (l10n === null) { + l10n = JSON.parse($("#l10n-messages").val()); + } + if (key in l10n) { + return l10n[key]; + } + return key; +} + +/** + * Returns whether this is a transfer transaction. + * + * @returns {boolean} true if this is a transfer transaction, or false + * otherwise + * @private + */ +function isTransfer() { + return $("#debit-records").length > 0 && $("#credit-records").length > 0; +} + +/** + * The account options + * @type {Array.} + * @private + */ +let accountOptions; + +/** + * Obtains the account options. + * + * @private + */ +function getAccountOptions() { + const request = new XMLHttpRequest(); + request.onreadystatechange = function() { + if (this.readyState === 4 && this.status === 200) { + accountOptions = JSON.parse(this.responseText); + $(".record-account").each(function () { + initializeAccountOptions(this); + }); + } + }; + request.open("GET", $("#account-option-url").val(), true); + request.send(); +} + +/** + * Initialize the account options. + * + * @param {HTMLSelectElement} account the account select element + * @private + */ +function initializeAccountOptions(account) { + const jAccount = $(account); + const type = account.id.substring(0, account.id.indexOf("-")); + const selectedAccount = account.value; + let isCash = false; + if (type === "debit") { + isCash = ($(".credit-record").length === 0); + } else if (type === "credit") { + isCash = ($(".debit-record").length === 0); + } + jAccount.html(""); + if (selectedAccount === "") { + jAccount.append($("