diff --git a/src/accounting/account/forms.py b/src/accounting/account/forms.py index 068c3e0..cee24a2 100644 --- a/src/accounting/account/forms.py +++ b/src/accounting/account/forms.py @@ -18,6 +18,7 @@ """ import sqlalchemy as sa +from flask import request from flask_wtf import FlaskForm from wtforms import StringField, BooleanField from wtforms.validators import DataRequired, ValidationError @@ -128,3 +129,48 @@ def sort_accounts_in(base_code: str, exclude: int) -> None: for i in range(len(accounts)): if accounts[i].no != i + 1: accounts[i].no = i + 1 + + +class AccountSortForm: + """The form to sort the accounts.""" + + def __init__(self, base: BaseAccount): + """Constructs the form to sort the accounts under a base account. + + :param base: The base account. + """ + self.base: BaseAccount = base + self.is_modified: bool = False + + def save_order(self) -> None: + """Saves the order of the account. + + :return: + """ + accounts: list[Account] = self.base.accounts + + # Collects the specified order. + orders: dict[Account, int] = {} + for account in accounts: + if f"{account.id}-no" in request.form: + try: + orders[account] = int(request.form[f"{account.id}-no"]) + except ValueError: + pass + + # Missing and invalid orders are appended to the end. + missing: list[Account] = [x for x in accounts if x not in orders] + if len(missing) > 0: + next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1 + for account in missing: + orders[account] = next_no + + # Sort by the specified order first, and their original order. + accounts = sorted(accounts, key=lambda x: (orders[x], x.no, x.code)) + + # Update the orders. + with db.session.no_autoflush: + for i in range(len(accounts)): + if accounts[i].no != i + 1: + accounts[i].no = i + 1 + self.is_modified = True diff --git a/src/accounting/account/views.py b/src/accounting/account/views.py index aa4f0d9..d7538b5 100644 --- a/src/accounting/account/views.py +++ b/src/accounting/account/views.py @@ -29,7 +29,7 @@ from accounting.models import Account, BaseAccount from accounting.utils.next_url import inherit_next, or_next from accounting.utils.pagination import Pagination from accounting.utils.permission import can_view, has_permission, can_edit -from .forms import AccountForm, sort_accounts_in +from .forms import AccountForm, sort_accounts_in, AccountSortForm bp: Blueprint = Blueprint("account", __name__) """The view blueprint for the account management.""" @@ -162,3 +162,33 @@ def delete_account(account: Account) -> redirect: db.session.commit() flash(lazy_gettext("The account is deleted successfully."), "success") return redirect(or_next(url_for("accounting.account.list"))) + + +@bp.get("/sort/", endpoint="sort-form") +@has_permission(can_edit) +def show_sort_form(base: BaseAccount) -> str: + """Shows the form to sort the accounts under a base account. + + :param base: The base account. + :return: The form to sort the accounts under the base account. + """ + return render_template("accounting/account/sort.html", + base=base) + + +@bp.post("/sort/", endpoint="sort") +@has_permission(can_edit) +def sort_accounts(base: BaseAccount) -> redirect: + """Sorts the accounts under a base account. + + :param base: The base account. + :return: Sorts the accounts under the base account. + """ + form: AccountSortForm = AccountSortForm(base) + form.save_order() + if not form.is_modified: + flash(lazy_gettext("The order was not modified."), "success") + return redirect(or_next(url_for("accounting.account.list"))) + db.session.commit() + flash(lazy_gettext("The order is updated successfully."), "success") + return redirect(or_next(url_for("accounting.account.list"))) diff --git a/src/accounting/static/js/account-sort.js b/src/accounting/static/js/account-sort.js new file mode 100644 index 0000000..9a56e53 --- /dev/null +++ b/src/accounting/static/js/account-sort.js @@ -0,0 +1,37 @@ +/* The Mia! Accounting Flask Project + * account-sort.js: The JavaScript for the account sorting form + */ + +/* Copyright (c) 2023 imacat. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* Author: imacat@mail.imacat.idv.tw (imacat) + * First written: 2023/2/2 + */ + +// Initializes the page JavaScript. +document.addEventListener("DOMContentLoaded", function () { + const list = document.getElementById("sort-account-list"); + const onReorder = function () { + const accounts = Array.from(list.children); + for (let i = 0; i < accounts.length; i++) { + const input = document.getElementById("sort-" + accounts[i].dataset.id + "-no"); + const code = document.getElementById("sort-" + accounts[i].dataset.id + "-code"); + input.value = i + 1; + code.innerText = list.dataset.baseCode + "-" + ("000" + (i + 1)).slice(-3); + } + }; + initializeDragAndDropSorting(list, onReorder); +}); diff --git a/src/accounting/static/js/drag-and-drop-sorting.js b/src/accounting/static/js/drag-and-drop-sorting.js new file mode 100644 index 0000000..c90b9e7 --- /dev/null +++ b/src/accounting/static/js/drag-and-drop-sorting.js @@ -0,0 +1,108 @@ +/* The Mia! Accounting Flask Project + * drag-and-drop-sorting.js: The JavaScript for the sorting with drag-and-drop + */ + +/* 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/3 + */ + +/** + * Initializes the drag-and-drop sorting on a list. + * + * @param list {HTMLElement} the list to be sorted + * @param onReorder {(function())|*} The callback to reorder the items + */ +function initializeDragAndDropSorting(list, onReorder) { + initializeMouseDragAndDropSorting(list, onReorder); + initializeTouchDragAndDropSorting(list, onReorder); +} + +/** + * Initializes the drag-and-drop sorting with mouse. + * + * @param list {HTMLElement} the list to be sorted + * @param onReorder {(function())|*} The callback to reorder the items + * @private + */ +function initializeMouseDragAndDropSorting(list, onReorder) { + const items = Array.from(list.children); + let dragged = null; + items.forEach(function (item) { + item.draggable = true; + item.addEventListener("dragstart", function () { + dragged = item; + dragged.classList.add("list-group-item-dark"); + }); + item.addEventListener("dragover", function () { + onDragOver(dragged, item); + onReorder(); + }); + item.addEventListener("dragend", function () { + dragged.classList.remove("list-group-item-dark"); + dragged = null; + }); + }); +} + +/** + * Initializes the drag-and-drop sorting with touch devices. + * + * @param list {HTMLElement} the list to be sorted + * @param onReorder {(function())|*} The callback to reorder the items + * @private + */ +function initializeTouchDragAndDropSorting(list, onReorder) { + const items = Array.from(list.children); + items.forEach(function (item) { + item.addEventListener("touchstart", function () { + item.classList.add("list-group-item-dark"); + }); + item.addEventListener("touchmove", function (event) { + const touch = event.targetTouches[0]; + const target = document.elementFromPoint(touch.pageX, touch.pageY); + onDragOver(item, target); + onReorder(); + }); + item.addEventListener("touchend", function () { + item.classList.remove("list-group-item-dark"); + }); + }); +} + +/** + * Handles when an item is dragged over the other item. + * + * @param dragged {Element} the item that was dragged + * @param target {Element} the other item that was dragged over + */ +function onDragOver(dragged, target) { + if (target.parentElement !== dragged.parentElement || target === dragged) { + return; + } + let isBefore = false; + for (let p = target; p !== null; p = p.previousSibling) { + if (p === dragged) { + isBefore = true; + } + } + if (isBefore) { + target.parentElement.insertBefore(dragged, target.nextSibling); + } else { + target.parentElement.insertBefore(dragged, target); + } +} diff --git a/src/accounting/templates/accounting/account/detail.html b/src/accounting/templates/accounting/account/detail.html index 6c2fc3a..f84e9bf 100644 --- a/src/accounting/templates/accounting/account/detail.html +++ b/src/accounting/templates/accounting/account/detail.html @@ -35,6 +35,10 @@ First written: 2023/1/31 {{ A_("Settings") }} + + + {{ A_("Sort") }} + + + +
+ +
+ + +{% endblock %} diff --git a/src/accounting/translations/zh_Hant/LC_MESSAGES/accounting.po b/src/accounting/translations/zh_Hant/LC_MESSAGES/accounting.po index be7059a..6764ded 100644 --- a/src/accounting/translations/zh_Hant/LC_MESSAGES/accounting.po +++ b/src/accounting/translations/zh_Hant/LC_MESSAGES/accounting.po @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: Mia! Accounting Flask 0.0.0\n" "Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n" -"POT-Creation-Date: 2023-02-01 19:51+0800\n" -"PO-Revision-Date: 2023-02-01 19:52+0800\n" +"POT-Creation-Date: 2023-02-03 07:40+0800\n" +"PO-Revision-Date: 2023-02-03 07:42+0800\n" "Last-Translator: imacat \n" "Language: zh_Hant\n" "Language-Team: zh_Hant \n" @@ -19,19 +19,25 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.11.0\n" -#: src/accounting/account/forms.py:39 +#: src/accounting/account/forms.py:41 msgid "The base account does not exist." msgstr "沒有這個基本科目。" -#: src/accounting/account/forms.py:48 +#: src/accounting/account/forms.py:50 #: src/accounting/static/js/account-form.js:110 msgid "Please select the base account." msgstr "請選擇基本科目。" -#: src/accounting/account/forms.py:53 +#: src/accounting/account/forms.py:55 msgid "Please fill in the title" msgstr "請填上標題。" +#: src/accounting/account/query.py:50 +#: src/accounting/templates/accounting/account/detail.html:88 +#: src/accounting/templates/accounting/account/list.html:62 +msgid "Offset needed" +msgstr "逐筆核銷" + #: src/accounting/account/views.py:88 msgid "The account is added successfully" msgstr "科目加好了。" @@ -48,6 +54,14 @@ msgstr "科目存好了。" msgid "The account is deleted successfully." msgstr "科目刪掉了" +#: src/accounting/account/views.py:190 +msgid "The order was not modified." +msgstr "順序未異動。" + +#: src/accounting/account/views.py:193 +msgid "The order is updated successfully." +msgstr "順序存好了。" + #: src/accounting/static/js/account-form.js:130 msgid "Please fill in the title." msgstr "請填上標題。" @@ -58,6 +72,7 @@ msgstr "新增科目" #: src/accounting/templates/accounting/account/detail.html:31 #: src/accounting/templates/accounting/account/include/form.html:33 +#: src/accounting/templates/accounting/account/sort.html:35 #: src/accounting/templates/accounting/base-account/detail.html:31 msgid "Back" msgstr "回上頁" @@ -67,36 +82,35 @@ msgid "Settings" msgstr "設定" #: src/accounting/templates/accounting/account/detail.html:40 +msgid "Sort" +msgstr "排序" + +#: src/accounting/templates/accounting/account/detail.html:44 msgid "Delete" msgstr "刪除" -#: src/accounting/templates/accounting/account/detail.html:63 +#: src/accounting/templates/accounting/account/detail.html:67 msgid "Delete Account Confirmation" msgstr "科目刪除確認" -#: src/accounting/templates/accounting/account/detail.html:67 +#: src/accounting/templates/accounting/account/detail.html:71 msgid "Do you really want to delete this account?" msgstr "你確定要刪掉這個科目嗎?" -#: src/accounting/templates/accounting/account/detail.html:70 +#: src/accounting/templates/accounting/account/detail.html:74 #: src/accounting/templates/accounting/account/include/form.html:111 msgid "Cancel" msgstr "取消" -#: src/accounting/templates/accounting/account/detail.html:71 +#: src/accounting/templates/accounting/account/detail.html:75 msgid "Confirm" msgstr "確定" -#: src/accounting/templates/accounting/account/detail.html:84 -#: src/accounting/templates/accounting/account/list.html:62 -msgid "Offset needed" -msgstr "逐筆核銷" - -#: src/accounting/templates/accounting/account/detail.html:88 +#: src/accounting/templates/accounting/account/detail.html:92 msgid "Created" msgstr "建檔" -#: src/accounting/templates/accounting/account/detail.html:89 +#: src/accounting/templates/accounting/account/detail.html:93 msgid "Updated" msgstr "更新" @@ -124,6 +138,16 @@ msgstr "搜尋" msgid "There is no data." msgstr "沒有資料。" +#: src/accounting/templates/accounting/account/sort.html:28 +#, python-format +msgid "Sort the Accounts of %(base)s" +msgstr "%(base)s下的科目排序" + +#: src/accounting/templates/accounting/account/include/form.html:75 +#: src/accounting/templates/accounting/account/sort.html:67 +msgid "Save" +msgstr "儲存" + #: src/accounting/templates/accounting/account/include/form.html:45 msgid "Base account" msgstr "基本科目" @@ -140,10 +164,6 @@ msgstr "標題" msgid "The entries in the account need offsets." msgstr "帳目要逐筆核銷。" -#: src/accounting/templates/accounting/account/include/form.html:75 -msgid "Save" -msgstr "儲存" - #: src/accounting/templates/accounting/account/include/form.html:90 msgid "Select Base Account" msgstr "選擇基本科目"