Applied the summary helper and JavaScripts to the transaction form, so that the transaction form works in the accounting application.

This commit is contained in:
依瑪貓 2020-08-05 07:48:50 +08:00
parent 2c6256b497
commit b2ce0eff54
10 changed files with 1539 additions and 2 deletions

View File

@ -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;
}

View File

@ -21,7 +21,7 @@
* First written: 2019/9/17 * First written: 2019/9/17
*/ */
.subject-line { .account-line {
font-size: 0.833em; font-size: 0.833em;
} }
.amount { .amount {

View File

@ -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;
}

View File

@ -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(
$("<span/>")
.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(
$("<span/>")
.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);
}

View File

@ -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($("<option/>"));
}
const headerInUse = $("<option/>")
.attr("disabled", "disabled")
.text(accountOptions["header_in_use"]);
jAccount.append(headerInUse);
accountOptions[type + "_in_use"].forEach(function (item) {
// Skips the cash account on cash transactions.
if (item["code"] === 1111 && isCash) {
return;
}
const option = $("<option/>")
.attr("value", item["code"])
.text(item["code"] + " " + item["title"]);
if (String(item["code"]) === selectedAccount) {
option.attr("selected", "selected");
}
jAccount.append(option);
});
const headerNotInUse = $("<option/>")
.attr("disabled", "disabled")
.text(accountOptions["header_not_in_use"]);
jAccount.append(headerNotInUse);
accountOptions[type + "_not_in_use"].forEach(function (item) {
const option = $("<option/>")
.attr("value", item["code"])
.text(item["code"] + " " + item["title"]);
if (String(item["code"]) === selectedAccount) {
option.attr("selected", "selected");
}
jAccount.append(option);
});
}
/**
* Removes the dummy blank option.
*
* @param {HTMLSelectElement} select the select element
* @private
*/
function removeBlankOption(select) {
$(select).children().each(function () {
if (this.value === "" && !this.disabled) {
$(this).remove();
}
});
}
/**
* Updates the total amount.
*
* @param {HTMLButtonElement|HTMLInputElement} element the amount
* element that
* changed, or the
* button that
* was hit to
* delete a record
* @private
*/
function updateTotalAmount(element) {
const type = element.id.substring(0, element.id.indexOf("-"));
let total = 0;
$("." + type + "-to-sum").each(function () {
if (this.value !== "") {
total += parseInt(this.value);
}
});
total = String(total);
while (total.match(/^[1-9][0-9]*[0-9]{3}/)) {
total = total.replace(/^([1-9][0-9]*)([0-9]{3})/, "$1,$2");
}
$("#" + type + "-total").text(total);
}
/**
* Adds a new accounting record.
*
* @param {HTMLButtonElement} button the button element that was hit
* to add a new record
* @private
*/
function addNewRecord(button) {
const type = button.id.substring(0, button.id.indexOf("-"));
// Finds the new number that is the maximum number plus 1.
let newNo = 0;
$("." + type + "-record").each(function () {
const no = parseInt(this.id.substring(type.length + 1));
if (newNo < no) {
newNo = no;
}
});
newNo++;
// Inserts a new table row for the new accounting record.
insertNewRecord(type, newNo);
// Resets the order of the records.
resetRecordOrders(type);
// Resets the sort and delete buttons for the records.
resetRecordButtons();
}
/**
* Inserts a new accounting record.
*
* @param {string} type the record type, either "debit" or "credit"
* @param {number} newNo the number of this new accounting record
* @private
*/
function insertNewRecord(type, newNo) {
if (isTransfer()) {
insertNewTransferRecord(type, newNo);
} else {
insertNewNonTransferRecord(type, newNo);
}
}
/**
* Inserts a new accounting record for a transfer transaction.
*
* @param {string} type the record type, either "debit" or "credit"
* @param {number} newNo the number of this new accounting record
* @private
*/
function insertNewTransferRecord(type, newNo) {
const divAccount = createAccountBlock(type, newNo)
.addClass("col-sm-12");
const divAccountRow = $("<div/>")
.addClass("row")
.append(divAccount);
const divSummary = createSummaryBlock(type, newNo)
.addClass("col-lg-8");
const divAmount = createAmountBlock(type, newNo)
.addClass("col-lg-4");
const divSummaryAmountRow = $("<div/>")
.addClass("row")
.append(divSummary, divAmount);
const divContent = $("<div/>")
.append(divAccountRow)
.append(divSummaryAmountRow);
const divBtnGroup = createActionButtonBlock(
type, newNo, type + "-" + newNo + "-delete")
.addClass("btn-group-vertical");
const divActions = $("<div/>")
.append(divBtnGroup);
$("<li/>")
.attr("id", type + "-" + newNo)
.addClass("list-group-item")
.addClass("d-flex")
.addClass(type + "-record")
.append(divContent, divActions)
.appendTo("#" + type + "-records");
}
/**
* Inserts a new accounting record for a non-transfer transaction.
*
* @param {string} type the record type, either "debit" or "credit"
* @param {number} newNo the number of this new accounting record
* @private
*/
function insertNewNonTransferRecord(type, newNo) {
const divAccount = createAccountBlock(type, newNo)
.addClass("col-lg-6");
const divSummary = createSummaryBlock(type, newNo)
.addClass("col-sm-8");
const divAmount = createAmountBlock(type, newNo)
.addClass("col-sm-4");
const divSummaryAmountRow = $("<div/>")
.addClass("row")
.append(divSummary, divAmount);
const divSummaryAmount = $("<div/>")
.addClass("col-lg-6")
.append(divSummaryAmountRow);
const divContent = $("<div/>")
.addClass("row")
.append(divAccount, divSummaryAmount);
const divBtnGroup = createActionButtonBlock(
type, newNo, type + "-" + newNo + "-delete")
.addClass("btn-group")
.addClass("d-none d-lg-flex");
const divBtnGroupVertical = createActionButtonBlock(
type, newNo, type + "-" + newNo + "-m-delete")
.addClass("btn-group-vertical")
.addClass("d-lg-none");
const divActions = $("<div/>")
.append(divBtnGroup, divBtnGroupVertical);
$("<li/>")
.attr("id", type + "-" + newNo)
.addClass("list-group-item")
.addClass("d-flex")
.addClass("justify-content-between")
.addClass(type + "-record")
.append(divContent, divActions)
.appendTo("#" + type + "-records");
}
/**
* Creates and returns a new <div></div> account block.
*
* @param {string} type the record type, either "debit" or "credit"
* @param {number} newNo the number of this new accounting record
* @returns {JQuery<HTMLElement>} the new <div></div> account block
* @private
*/
function createAccountBlock(type, newNo) {
const order = $("<input/>")
.attr("id", type + "-" + newNo + "-ord")
.attr("type", "hidden")
.attr("name", type + "-" + newNo + "-ord")
.addClass(type + "-ord");
const account = $("<select/>")
.attr("id", type + "-" + newNo + "-account")
.attr("name", type + "-" + newNo + "-account")
.addClass("form-control")
.addClass("record-account")
.addClass(type + "-account")
.on("focus", function () {
removeBlankOption(this);
})
.on("blur", function () {
validateAccount(this);
})
.each(function () {
initializeAccountOptions(this);
});
const accountError = $("<div/>")
.attr("id", type + "-" + newNo + "-account-error")
.addClass("invalid-feedback");
return $("<div/>")
.append(order, account, accountError);
}
/**
* Creates and returns a new <div></div> summary block.
*
* @param {string} type the record type, either "debit" or "credit"
* @param {number} newNo the number of this new accounting record
* @returns {JQuery<HTMLElement>} the new <div></div> summary block
* @private
*/
function createSummaryBlock(type, newNo) {
const summary = $("<input/>")
.attr("id", type + "-" + newNo + "-summary")
.attr("type", "text")
.attr("name", type + "-" + newNo + "-summary")
.addClass("form-control")
.addClass("record-summary")
.on("blur", function () {
validateSummary(this);
});
if (typeof startSummaryHelper === "function") {
summary
.attr("data-toggle", "modal")
.attr("data-target", "#summary-modal")
.on("click", function () {
startSummaryHelper(this);
});
}
const summaryError = $("<div/>")
.attr("id", type + "-" + newNo + "-summary-error")
.addClass("invalid-feedback");
return $("<div/>")
.append(summary, summaryError);
}
/**
* Creates and returns a new <div></div> amount block.
*
* @param {string} type the record type, either "debit" or "credit"
* @param {number} newNo the number of this new accounting record
* @returns {JQuery<HTMLElement>} the new <div></div> amount block
* @private
*/
function createAmountBlock(type, newNo) {
const amount = $("<input/>")
.attr("id", type + "-" + newNo + "-amount")
.attr("type", "number")
.attr("name", type + "-" + newNo + "-amount")
.attr("min", 1)
.attr("required", "required")
.addClass("form-control")
.addClass("record-amount")
.addClass(type + "-to-sum")
.on("blur", function () {
validateAmount(this);
})
.on("change", function () {
updateTotalAmount(this);
validateBalance();
});
const amountError = $("<div/>")
.attr("id", type + "-" + newNo + "-amount-error")
.addClass("invalid-feedback");
return $("<div/>")
.append(amount, amountError);
}
/**
* Creates and returns a new <div></div> action button block.
*
* @param {string} type the record type, either "debit" or "credit"
* @param {number} newNo the number of this new accounting record
* @param {string} btnDelId the ID of the delete button
* @returns {JQuery<HTMLElement>} the new <div></div> button block
* @private
*/
function createActionButtonBlock(type, newNo, btnDelId) {
const btnSort = $("<button/>")
.attr("type", "button")
.addClass("btn btn-outline-secondary")
.addClass("btn-sort-" + type)
.append($("<i/>").addClass("fas fa-sort"));
const btnDelete = $("<button/>")
.attr("id", btnDelId)
.attr("type", "button")
.addClass("btn btn-danger")
.addClass("btn-del-record")
.addClass("btn-del-" + type)
.on("click", function () {
deleteRecord(this);
})
.append($("<i/>").addClass("fas fa-trash"));
return $("<div/>")
.addClass("btn-actions-" + type)
.append(btnSort, btnDelete);
}
/**
* Deletes a record.
*
* @param {HTMLButtonElement} button the button element that was hit
* to delete this record
* @private
*/
function deleteRecord(button) {
const type = button.id.substring(0, button.id.indexOf("-"));
const no = parseInt(button.id.substring(type.length + 1, button.id.indexOf("-", type.length + 1)));
$("#" + type + "-" + no).remove();
resetRecordOrders(type);
resetRecordButtons();
updateTotalAmount(button);
}
/**
* Resets the order of the records according to their appearance.
*
* @param {string} type the record type, either "debit" or "credit".
* @private
*/
function resetRecordOrders(type) {
const sorted = $("#" + type + "-records").sortable("toArray");
for (let i = 0; i < sorted.length; i++) {
$("#" + sorted[i] + "-ord")[0].value = i + 1;
}
}
/**
* Resets the sort and delete buttons for the records.
*
* @private
*/
function resetRecordButtons() {
["debit", "credit"].forEach(function (type) {
const records = $("." + type + "-record");
if (records.length > 1) {
$("#" + type + "-records").sortable({
classes: {
"ui-sortable-helper": "list-group-item-secondary",
},
cursor: "move",
cancel: "input, select",
stop: function () {
resetRecordOrders(type);
},
}).sortable("enable");
$(".btn-actions-" + type).removeClass("invisible");
} else if (records.length === 1) {
$("#" + type + "-records").sortable().sortable("disable");
$(".btn-actions-" + type).addClass("invisible");
}
});
}
/*******************
* Form Validation *
*******************/
/**
* Validates the form.
*
* @returns {boolean} true if the validation succeed, or false
* otherwise
* @private
*/
function validateForm() {
let isValidated = true;
isValidated = isValidated && validateDate();
$(".record-account").each(function () {
isValidated = isValidated && validateAccount(this);
});
$(".record-summary").each(function () {
isValidated = isValidated && validateSummary(this);
});
$(".record-amount").each(function () {
isValidated = isValidated && validateAmount(this);
});
if (isTransfer()) {
isValidated = isValidated && validateBalance();
}
isValidated = isValidated && validateNote();
return isValidated;
}
/**
* Validates the date column.
*
* @returns {boolean} true if the validation succeed, or false
* otherwise
* @private
*/
function validateDate() {
const date = $("#txn-date")[0];
const errorMessage = $("#txn-date-error");
if (date.value === "") {
date.classList.add("is-invalid");
errorMessage.text(__("Please fill in the date."));
return false;
}
date.classList.remove("is-invalid");
errorMessage.text("");
return true;
}
/**
* Validates the account column.
*
* @param {HTMLSelectElement} account the account selection element
* @returns {boolean} true if the validation succeed, or false
* otherwise
* @private
*/
function validateAccount(account) {
const errorMessage = $("#" + account.id + "-error");
if (account.value === "") {
account.classList.add("is-invalid");
errorMessage.text(__("Please select the account."));
return false;
}
account.classList.remove("is-invalid");
errorMessage.text("");
return true;
}
/**
* Validates the summary column.
*
* @param {HTMLInputElement} summary the summary input element
* @returns {boolean} true if the validation succeed, or false
* otherwise
* @private
*/
function validateSummary(summary) {
const errorMessage = $("#" + summary.id + "-error");
summary.value = summary.value.trim();
if (summary.value.length > 128) {
summary.classList.add("is-invalid");
errorMessage.text(__("This summary is too long (max. 128 characters)."));
return false;
}
summary.classList.remove("is-invalid");
errorMessage.text("");
return true;
}
/**
* Validates the amount column.
*
* @param {HTMLInputElement} amount the amount input element
* @returns {boolean} true if the validation succeed, or false
* otherwise
* @private
*/
function validateAmount(amount) {
const errorMessage = $("#" + amount.id + "-error");
amount.value = amount.value.trim();
if (amount.value === "") {
amount.classList.add("is-invalid");
errorMessage.text(__("Please fill in the amount."));
return false;
}
amount.classList.remove("is-invalid");
errorMessage.text("");
return true;
}
/**
* Validates the balance between debit and credit records
*
* @returns {boolean} true if the validation succeed, or false
* otherwise
* @private
*/
function validateBalance() {
const balanceRows = $(".balance-row");
const errorMessages = $(".balance-error");
let debitTotal = 0;
$(".debit-to-sum").each(function () {
if (this.value !== "") {
debitTotal += parseInt(this.value);
}
});
let creditTotal = 0;
$(".credit-to-sum").each(function () {
if (this.value !== "") {
creditTotal += parseInt(this.value);
}
});
if (debitTotal !== creditTotal) {
balanceRows.addClass("is-invalid");
errorMessages.text(__("The sum of debit and credit are inconsistent."))
return false;
}
balanceRows.removeClass("is-invalid");
errorMessages.text("");
return true;
}
/**
* Validates the note column.
*
* @returns {boolean} true if the validation succeed, or false
* otherwise
* @private
*/
function validateNote() {
const note = $("#txn-note")[0];
const errorMessage = $("#txn-note-error");
note.value = note.value.trim();
if (note.value.length > 128) {
note.classList.add("is-invalid");
errorMessage.text(__("This note is too long (max. 128 characters)."));
return false;
}
note.classList.remove("is-invalid");
errorMessage.text("");
return true;
}

View File

@ -0,0 +1,164 @@
{% comment %}
The Mia Accounting Application
summary-helper.html: The view of the summary-helper dialog
Copyright (c) 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
{% endcomment %}
{% load i18n %}
<!-- the summary helper dialog -->
<!-- The Modal -->
<form id="summary-helper-form" action="" method="get">
<input id="summary-record" type="hidden" value="" />
<div class="modal" id="summary-modal">
<div class="modal-dialog">
<div class="modal-content">
<!-- Modal Header -->
<div class="modal-header">
<h4 class="modal-title">
<label for="summary-summary">
<i class="fas fa-edit"></i>
{% trans "Summary" context "Accounting|" as text %}{{ text|force_escape }}
</label>
</h4>
<button type="button" class="close" data-dismiss="modal">&times;</button>
</div>
<!-- Modal body -->
<div class="modal-body">
<div class="summary-container">
<input id="summary-summary" class="form-control" value="" />
</div>
<ul class="nav nav-tabs">
<li class="nav-item">
<span id="summary-tab-category" class="summary-tab nav-link active">{% trans "General" context "Accounting|Summary|" as text %}{{ text|force_escape }}</span>
</li>
<li class="nav-item">
<span id="summary-tab-travel" class="summary-tab nav-link">{% trans "Travel" context "Accounting|Summary|" as text %}{{ text|force_escape }}</span>
</li>
<li class="nav-item">
<span id="summary-tab-bus" class="summary-tab nav-link">{% trans "Bus" context "Accounting|Summary|" as text %}{{ text|force_escape }}</span>
</li>
<li class="nav-item">
<span id="summary-tab-regular" class="summary-tab nav-link">{% trans "Regular" context "Accounting|Summary|" as text %}{{ text|force_escape }}</span>
</li>
<li class="nav-item">
<span id="summary-tab-count" class="summary-tab nav-link">{% trans "Count" context "Accounting|Summary|" as text %}{{ text|force_escape }}</span>
</li>
</ul>
<!-- A general category -->
<div id="summary-tab-content-category" class="summary-tab-content">
<div class="row">
<label class="col-sm-2 col-form-label" for="summary-general-category">{% trans "Category:" context "Accounting|Summary|" as text %}{{ text|force_escape }}</label>
<div class="col-sm-10">
<input id="summary-general-category" class="form-control summary-helper-input" type="text" value="" />
<div id="summary-general-categories-known" class="summary-categories-known"></div>
</div>
</div>
</div>
<!-- A general travel route -->
<div id="summary-tab-content-travel" class="summary-tab-content d-none">
<div class="row">
<label class="col-sm-2 col-form-label" for="summary-travel-category">{% trans "Category:" context "Accounting|Summary|" as text %}{{ text|force_escape }}</label>
<div class="col-sm-10">
<input id="summary-travel-category" class="form-control summary-helper-input summary-travel-part" type="text" value="" />
<div id="summary-travel-categories-known" class="summary-categories-known"></div>
</div>
</div>
<div class="row">
<label class="col-sm-2 col-form-label" for="summary-travel-from">{% trans "From:" context "Accounting|Summary|" as text %}{{ text|force_escape }}</label>
<div class="col-sm-10">
<input id="summary-travel-from" class="form-control summary-helper-input summary-travel-part" type="text" value="" />
</div>
</div>
<div class="row">
<label class="col-sm-2 col-form-label" for="summary-travel-direction">{% trans "Direction:" context "Accounting|Summary|" as text %}{{ text|force_escape }}</label>
<div class="col-sm-10">
<input id="summary-travel-direction" class="summary-helper-input" type="hidden" value="" />
<span id="btn-summary-one-way" class="btn btn-outline-primary btn-summary-helper btn-summary-travel-direction"><%="→"%></span>
<span class="btn btn-outline-primary btn-summary-helper btn-summary-travel-direction"><%="↔"%></span>
</div>
</div>
<div class="row">
<label class="col-sm-2 col-form-label" for="summary-travel-to">{% trans "To:" context "Accounting|Summary|" as text %}{{ text|force_escape }}</label>
<div class="col-sm-10">
<input id="summary-travel-to" class="form-control summary-helper-input summary-travel-part" type="text" value="" />
</div>
</div>
</div>
<!-- A bus route -->
<div id="summary-tab-content-bus" class="summary-tab-content d-none">
<div class="row">
<label class="col-sm-2 col-form-label" for="summary-bus-category">{% trans "Category:" context "Accounting|Summary|" as text %}{{ text|force_escape }}</label>
<div class="col-sm-10">
<input id="summary-bus-category" class="form-control summary-helper-input summary-bus-part" type="text" value="" />
<div id="summary-bus-categories-known" class="summary-categories-known"></div>
</div>
</div>
<div class="row">
<label class="col-sm-2 col-form-label" for="summary-bus-route">{% trans "Route:" context "Accounting|Summary|" as text %}{{ text|force_escape }}</label>
<div class="col-sm-10">
<input id="summary-bus-route" class="form-control summary-helper-input summary-bus-part" type="text" value="" />
</div>
</div>
<div class="row">
<label class="col-sm-2 col-form-label" for="summary-bus-from">{% trans "From:" context "Accounting|Summary|" as text %}{{ text|force_escape }}</label>
<div class="col-sm-10">
<input id="summary-bus-from" class="form-control summary-helper-input summary-bus-part" type="text" value="" />
</div>
</div>
<div class="row">
<label class="col-sm-2 col-form-label" for="summary-bus-to">{% trans "To:" context "Accounting|Summary|" as text %}{{ text|force_escape }}</label>
<div class="col-sm-10">
<input id="summary-bus-to" class="form-control summary-helper-input summary-bus-part" type="text" value="" />
</div>
</div>
</div>
<!-- Regular payments -->
<div id="summary-tab-content-regular" class="summary-tab-content d-none">
<div class="row">
<div class="col-sm-12">
<div id="summary-regular-payments" class="summary-categories-known"></div>
</div>
</div>
</div>
<div id="summary-tab-content-count" class="summary-tab-content d-none">
<div class="row">
<label class="col-sm-2 col-form-label" for="summary-count">{% trans "Count:" context "Accounting|Summary|" as text %}{{ text|force_escape }}</label>
<div class="col-sm-10">
<input id="summary-count" class="form-control summary-helper-input" type="number" min="1" value="" />
</div>
</div>
</div>
</div>
<!-- Modal footer -->
<div class="modal-footer">
<button id="summary-confirm" class="btn btn-danger" type="submit" data-dismiss="modal">{{ _("Confirm") }}</button>
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ _("Cancel") }}</button>
</div>
</div>
</div>
</div>
</form>

View File

@ -28,10 +28,18 @@ First written: 2020/7/23
{% block settings %} {% block settings %}
{% trans "Cash Expense Transaction" context "Accounting|" as title %} {% trans "Cash Expense Transaction" context "Accounting|" as title %}
{% setvar "title" title %} {% setvar "title" title %}
{% setvar "use_jqueryui" True %}
{% static "accounting/css/transactions.css" as file %}{% add_css file %}
{% static "accounting/css/summary-helper.css" as file %}{% add_css file %}
{% static "accounting/js/transaction-form.js" as file %}{% add_js file %}
{% static "accounting/js/regular-payments.js" as file %}{% add_js file %}
{% static "accounting/js/summary-helper.js" as file %}{% add_js file %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% include "accounting/include/summary-helper.html" %}
<div class="btn-group btn-actions"> <div class="btn-group btn-actions">
<a class="btn btn-primary" role="button" href="{% if item.transaction %}{% url_keep_return "accounting:transactions.show" "expense" item.transaction %}{% elif request.GET.r %}{{ request.GET.r }}{% else %}{% url "accounting:home" %}{% endif %}"> <a class="btn btn-primary" role="button" href="{% if item.transaction %}{% url_keep_return "accounting:transactions.show" "expense" item.transaction %}{% elif request.GET.r %}{{ request.GET.r }}{% else %}{% url "accounting:home" %}{% endif %}">
<i class="fas fa-chevron-circle-left"></i> <i class="fas fa-chevron-circle-left"></i>

View File

@ -28,10 +28,18 @@ First written: 2020/7/23
{% block settings %} {% block settings %}
{% trans "Cash Income Transaction" context "Accounting|" as title %} {% trans "Cash Income Transaction" context "Accounting|" as title %}
{% setvar "title" title %} {% setvar "title" title %}
{% setvar "use_jqueryui" True %}
{% static "accounting/css/transactions.css" as file %}{% add_css file %}
{% static "accounting/css/summary-helper.css" as file %}{% add_css file %}
{% static "accounting/js/transaction-form.js" as file %}{% add_js file %}
{% static "accounting/js/regular-payments.js" as file %}{% add_js file %}
{% static "accounting/js/summary-helper.js" as file %}{% add_js file %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% include "accounting/include/summary-helper.html" %}
<div class="btn-group btn-actions"> <div class="btn-group btn-actions">
<a class="btn btn-primary" role="button" href="{% if item.transaction %}{% url_keep_return "accounting:transactions.show" "income" item.transaction %}{% elif request.GET.r %}{{ request.GET.r }}{% else %}{% url "accounting:home" %}{% endif %}"> <a class="btn btn-primary" role="button" href="{% if item.transaction %}{% url_keep_return "accounting:transactions.show" "income" item.transaction %}{% elif request.GET.r %}{{ request.GET.r }}{% else %}{% url "accounting:home" %}{% endif %}">
<i class="fas fa-chevron-circle-left"></i> <i class="fas fa-chevron-circle-left"></i>

View File

@ -28,10 +28,18 @@ First written: 2020/7/23
{% block settings %} {% block settings %}
{% trans "Transfer Transaction" context "Accounting|" as title %} {% trans "Transfer Transaction" context "Accounting|" as title %}
{% setvar "title" title %} {% setvar "title" title %}
{% setvar "use_jqueryui" True %}
{% static "accounting/css/transactions.css" as file %}{% add_css file %}
{% static "accounting/css/summary-helper.css" as file %}{% add_css file %}
{% static "accounting/js/transaction-form.js" as file %}{% add_js file %}
{% static "accounting/js/regular-payments.js" as file %}{% add_js file %}
{% static "accounting/js/summary-helper.js" as file %}{% add_js file %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% include "accounting/include/summary-helper.html" %}
<div class="btn-group btn-actions"> <div class="btn-group btn-actions">
<a class="btn btn-primary" role="button" href="{% if item.transaction %}{% url_keep_return "accounting:transactions.show" "transfer" item.transaction %}{% elif request.GET.r %}{{ request.GET.r }}{% else %}{% url "accounting:home" %}{% endif %}"> <a class="btn btn-primary" role="button" href="{% if item.transaction %}{% url_keep_return "accounting:transactions.show" "transfer" item.transaction %}{% elif request.GET.r %}{{ request.GET.r }}{% else %}{% url "accounting:home" %}{% endif %}">
<i class="fas fa-chevron-circle-left"></i> <i class="fas fa-chevron-circle-left"></i>

View File

@ -18,6 +18,7 @@
"""The utilities of the accounting application. """The utilities of the accounting application.
""" """
import datetime
import json import json
import re import re
@ -374,7 +375,11 @@ def fill_txn_from_post(txn_type, txn, post):
txn (Transaction): The transaction. txn (Transaction): The transaction.
post (dict): The POSTed data. post (dict): The POSTed data.
""" """
txn.date = post["date"] m = re.match("^([0-9]{4})-([0-9]{2})-([0-9]{2})$", post["date"])
txn.date = datetime.date(
int(m.group(1)),
int(m.group(2)),
int(m.group(3)))
if "notes" in post: if "notes" in post:
txn.notes = post["notes"] txn.notes = post["notes"]
else: else: