From 5c58a2b469f1fe2e69c24c09f6d16d3ea0adc2f9 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 17 Jul 2026 20:03:42 +0200 Subject: [PATCH] refactor(housekeeping): sortable columns, inline edit Enter/Escape, select all matching, i18n date - Sortable columns: click headers to sort by permission/description/group/rank - Inline edit: click rank value to edit, Enter to save, Escape to cancel - Select all matching button when partial selection is active - Select-all checkbox shows all-pages selection state - AuditSection uses Intl.DateTimeFormat with CMS locale instead of toLocaleString - Add selectAllMatching translation key to all 22 locales --- src/app/admin/housekeeping/client.tsx | 270 +++++++++++++++++++------- src/messages/bg.json | 3 +- src/messages/cs.json | 3 +- src/messages/da.json | 3 +- src/messages/de.json | 3 +- src/messages/el.json | 3 +- src/messages/en.json | 1 + src/messages/es.json | 3 +- src/messages/fr.json | 3 +- src/messages/hr.json | 3 +- src/messages/hu.json | 3 +- src/messages/it.json | 3 +- src/messages/nl.json | 1 + src/messages/no.json | 3 +- src/messages/pl.json | 3 +- src/messages/pt.json | 3 +- src/messages/ro.json | 3 +- src/messages/ru.json | 3 +- src/messages/sk.json | 3 +- src/messages/sr.json | 3 +- src/messages/sv.json | 3 +- src/messages/tr.json | 3 +- src/messages/uk.json | 3 +- 23 files changed, 245 insertions(+), 87 deletions(-) diff --git a/src/app/admin/housekeeping/client.tsx b/src/app/admin/housekeeping/client.tsx index cca593a050..7f2bb06d01 100644 --- a/src/app/admin/housekeeping/client.tsx +++ b/src/app/admin/housekeeping/client.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter, useSearchParams } from "next/navigation"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { @@ -96,10 +96,16 @@ export function ManageSection(props: SharedProps) { const [page, setPage] = useState(1); const [pendingDel, setPendingDel] = useState(null); const [selected, setSelected] = useState>(new Set()); + const [selectAllMatching, setSelectAllMatching] = useState(false); const [busyBulk, setBusyBulk] = useState(false); const [confirmDel, setConfirmDel] = useState(null); + const [editingId, setEditingId] = useState(null); + const [editValue, setEditValue] = useState(""); + const [sortKey, setSortKey] = useState("permission"); + const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); const searchTimer = useRef>(null); const formRef = useRef(null); + const editRef = useRef(null); const filtered = useMemo(() => { return props.permissions.filter((p) => { @@ -110,9 +116,35 @@ export function ManageSection(props: SharedProps) { }); }, [props.permissions, search, groupFilter]); - const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const sorted = useMemo(() => { + const arr = [...filtered]; + arr.sort((a, b) => { + let cmp = 0; + switch (sortKey) { + case "permission": + cmp = a.permission.localeCompare(b.permission); + break; + case "minRank": + cmp = a.minRank - b.minRank; + break; + case "groupName": + cmp = (a.groupName ?? "").localeCompare(b.groupName ?? ""); + break; + case "description": + cmp = (a.description ?? "").localeCompare(b.description ?? ""); + break; + case "dependsOn": + cmp = (a.dependsOn ?? "").localeCompare(b.dependsOn ?? ""); + break; + } + return sortDir === "asc" ? cmp : -cmp; + }); + return arr; + }, [filtered, sortKey, sortDir]); + + const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE)); const safePage = Math.min(page, totalPages); - const paginated = filtered.slice( + const paginated = sorted.slice( (safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE, ); @@ -200,15 +232,64 @@ export function ManageSection(props: SharedProps) { else next.add(id); return next; }); + setSelectAllMatching(false); }, []); const toggleAll = useCallback(() => { - if (selected.size === paginated.length) { + if (selected.size === paginated.length && !selectAllMatching) { setSelected(new Set()); } else { setSelected(new Set(paginated.map((p) => String(p.id)))); + setSelectAllMatching(false); } - }, [paginated, selected]); + }, [paginated, selected, selectAllMatching]); + + const handleSelectAllMatching = useCallback(() => { + setSelected(new Set(sorted.map((p) => String(p.id)))); + setSelectAllMatching(true); + }, [sorted]); + + const handleSort = useCallback( + (key: string) => { + setSortDir((prev) => (sortKey === key && prev === "asc" ? "desc" : "asc")); + setSortKey(key); + setPage(1); + }, + [sortKey], + ); + + const startEdit = useCallback((id: string, value: number) => { + setEditingId(id); + setEditValue(String(value)); + setTimeout(() => editRef.current?.focus(), 0); + }, []); + + const cancelEdit = useCallback(() => { + setEditingId(null); + setEditValue(""); + }, []); + + const commitEdit = useCallback( + async (p: HKPerm) => { + const fd = new FormData(); + fd.set("permission", p.permission); + fd.set("minRank", editValue); + fd.set("description", p.description ?? ""); + fd.set("groupName", p.groupName ?? ""); + fd.set("dependsOn", p.dependsOn ?? ""); + await handleUpsert(fd); + setEditingId(null); + }, + [editValue, handleUpsert], + ); + + const thClass = + "cursor-pointer select-none hover:opacity-80 transition-opacity"; + + const sortIcon = (key: string) => { + if (sortKey !== key) return " \u2195"; + return sortDir === "asc" ? " \u2191" : " \u2193"; + }; return (
@@ -321,16 +402,27 @@ export function ManageSection(props: SharedProps) { {filtered.length} - {selected.size > 0 && ( - - )} +
+ {selected.size > 0 && selected.size < sorted.length && ( + + )} + {selected.size > 0 && ( + + )} +
{filtered.length === 0 ? (
{t("noPermissions")}
@@ -340,20 +432,55 @@ export function ManageSection(props: SharedProps) { - 0 && - selected.size === paginated.length - } - onChange={toggleAll} - /> + {selectAllMatching ? ( + { + setSelected(new Set()); + setSelectAllMatching(false); + }} + /> + ) : ( + 0 && + selected.size === paginated.length + } + onChange={toggleAll} + /> + )} # - {t("colPermission")} - {t("colDescription")} - {t("colGroup")} - {t("colMinRank")} + handleSort("permission")} + > + {t("colPermission")} + {sortIcon("permission")} + + handleSort("description")} + > + {t("colDescription")} + {sortIcon("description")} + + handleSort("groupName")} + > + {t("colGroup")} + {sortIcon("groupName")} + + handleSort("minRank")} + > + {t("colMinRank")} + {sortIcon("minRank")} + {t("colDepends")} @@ -366,7 +493,7 @@ export function ManageSection(props: SharedProps) { toggleSelect(sid)} /> @@ -387,47 +514,48 @@ export function ManageSection(props: SharedProps) { )} -
{ - e.preventDefault(); - handleUpsert(new FormData(e.currentTarget)); - }} - style={{ display: "flex", gap: "0.5rem" }} - > - - - - - + {editingId === sid ? ( +
+ setEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitEdit(p); + } else if (e.key === "Escape") { + cancelEdit(); + } + }} + style={{ width: 70 }} + /> + + +
+ ) : ( -
+ )} {p.dependsOn ? ( @@ -813,7 +941,15 @@ interface AuditSectionProps { export function AuditSection({ initialLogs }: AuditSectionProps) { const t = useTranslations("pages.admin.housekeeping"); + const locale = useLocale(); const router = useRouter(); + const dateFmt = useMemo( + () => new Intl.DateTimeFormat(locale, { + dateStyle: "short", + timeStyle: "short", + }), + [locale], + ); if (!initialLogs || initialLogs.rows.length === 0) { return ( @@ -856,7 +992,7 @@ export function AuditSection({ initialLogs }: AuditSectionProps) { {initialLogs.rows.map((log) => ( - {new Date(log.createdAt).toLocaleString()} + {dateFmt.format(new Date(log.createdAt))} {log.username ?? "System"} {log.action} diff --git a/src/messages/bg.json b/src/messages/bg.json index 03f3b65552..0067683928 100644 --- a/src/messages/bg.json +++ b/src/messages/bg.json @@ -1968,7 +1968,8 @@ "auditTime": "Time", "auditUser": "User", "auditAction": "Action", - "auditTarget": "Target" + "auditTarget": "Target", + "selectAllMatching": "Select all {count} matching" }, "rooms": { "title": "Стаи", diff --git a/src/messages/cs.json b/src/messages/cs.json index f196b9866f..34f901db60 100644 --- a/src/messages/cs.json +++ b/src/messages/cs.json @@ -1968,7 +1968,8 @@ "auditTime": "Time", "auditUser": "User", "auditAction": "Action", - "auditTarget": "Target" + "auditTarget": "Target", + "selectAllMatching": "Select all {count} matching" }, "rooms": { "title": "Pokoje", diff --git a/src/messages/da.json b/src/messages/da.json index b577d654b4..7f828df70b 100644 --- a/src/messages/da.json +++ b/src/messages/da.json @@ -1968,7 +1968,8 @@ "auditTime": "Time", "auditUser": "User", "auditAction": "Action", - "auditTarget": "Target" + "auditTarget": "Target", + "selectAllMatching": "Select all {count} matching" }, "rooms": { "title": "Værelser", diff --git a/src/messages/de.json b/src/messages/de.json index 9b5603bf81..bd10988a9f 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -1932,7 +1932,8 @@ "auditTime": "Time", "auditUser": "User", "auditAction": "Action", - "auditTarget": "Target" + "auditTarget": "Target", + "selectAllMatching": "Select all {count} matching" }, "rooms": { "title": "Räume", diff --git a/src/messages/el.json b/src/messages/el.json index ce40b4bd79..15bbbe224f 100644 --- a/src/messages/el.json +++ b/src/messages/el.json @@ -1968,7 +1968,8 @@ "auditTime": "Time", "auditUser": "User", "auditAction": "Action", - "auditTarget": "Target" + "auditTarget": "Target", + "selectAllMatching": "Select all {count} matching" }, "rooms": { "title": "Δωμάτια", diff --git a/src/messages/en.json b/src/messages/en.json index c82ed9249c..fc7c26d639 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -1899,6 +1899,7 @@ "confirmDeletePermission": "Delete this permission permanently?", "confirmBulkDelete": "Delete {count} selected permissions?", "deleteSelected": "Delete {count} selected", + "selectAllMatching": "Select all {count} matching", "cancel": "Cancel", "confirm": "Confirm", "colGroup": "Group", diff --git a/src/messages/es.json b/src/messages/es.json index 82b7467b0d..363a396ba2 100644 --- a/src/messages/es.json +++ b/src/messages/es.json @@ -1932,7 +1932,8 @@ "auditTime": "Time", "auditUser": "User", "auditAction": "Action", - "auditTarget": "Target" + "auditTarget": "Target", + "selectAllMatching": "Select all {count} matching" }, "rooms": { "title": "Salas", diff --git a/src/messages/fr.json b/src/messages/fr.json index 919b20064d..1966c9b277 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -1932,7 +1932,8 @@ "auditTime": "Time", "auditUser": "User", "auditAction": "Action", - "auditTarget": "Target" + "auditTarget": "Target", + "selectAllMatching": "Select all {count} matching" }, "rooms": { "title": "Salons", diff --git a/src/messages/hr.json b/src/messages/hr.json index 15e969fac5..7cce2c48a7 100644 --- a/src/messages/hr.json +++ b/src/messages/hr.json @@ -1968,7 +1968,8 @@ "auditTime": "Time", "auditUser": "User", "auditAction": "Action", - "auditTarget": "Target" + "auditTarget": "Target", + "selectAllMatching": "Select all {count} matching" }, "rooms": { "title": "Sobe", diff --git a/src/messages/hu.json b/src/messages/hu.json index 8317cbe8b5..80f56894cb 100644 --- a/src/messages/hu.json +++ b/src/messages/hu.json @@ -1968,7 +1968,8 @@ "auditTime": "Time", "auditUser": "User", "auditAction": "Action", - "auditTarget": "Target" + "auditTarget": "Target", + "selectAllMatching": "Select all {count} matching" }, "rooms": { "title": "Szobák", diff --git a/src/messages/it.json b/src/messages/it.json index 864d28a987..141e344d05 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -1932,7 +1932,8 @@ "auditTime": "Time", "auditUser": "User", "auditAction": "Action", - "auditTarget": "Target" + "auditTarget": "Target", + "selectAllMatching": "Select all {count} matching" }, "rooms": { "title": "Stanze", diff --git a/src/messages/nl.json b/src/messages/nl.json index 8327162662..a8aafe611e 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -1899,6 +1899,7 @@ "confirmDeletePermission": "Deze permissie permanent verwijderen?", "confirmBulkDelete": "{count} geselecteerde permissies verwijderen?", "deleteSelected": "Verwijder {count} geselecteerd", + "selectAllMatching": "Selecteer alle {count} overeenkomende", "cancel": "Annuleren", "confirm": "Bevestigen", "colGroup": "Groep", diff --git a/src/messages/no.json b/src/messages/no.json index 45c8647d89..1f70ff98dd 100644 --- a/src/messages/no.json +++ b/src/messages/no.json @@ -1968,7 +1968,8 @@ "auditTime": "Time", "auditUser": "User", "auditAction": "Action", - "auditTarget": "Target" + "auditTarget": "Target", + "selectAllMatching": "Select all {count} matching" }, "rooms": { "title": "Rom", diff --git a/src/messages/pl.json b/src/messages/pl.json index fa80c34f38..abde0a8e06 100644 --- a/src/messages/pl.json +++ b/src/messages/pl.json @@ -1968,7 +1968,8 @@ "auditTime": "Time", "auditUser": "User", "auditAction": "Action", - "auditTarget": "Target" + "auditTarget": "Target", + "selectAllMatching": "Select all {count} matching" }, "rooms": { "title": "Pokoje", diff --git a/src/messages/pt.json b/src/messages/pt.json index 995595de03..4b32faedb6 100644 --- a/src/messages/pt.json +++ b/src/messages/pt.json @@ -1968,7 +1968,8 @@ "auditTime": "Time", "auditUser": "User", "auditAction": "Action", - "auditTarget": "Target" + "auditTarget": "Target", + "selectAllMatching": "Select all {count} matching" }, "rooms": { "title": "Quartos", diff --git a/src/messages/ro.json b/src/messages/ro.json index a8e08d0853..8f5836daf5 100644 --- a/src/messages/ro.json +++ b/src/messages/ro.json @@ -1968,7 +1968,8 @@ "auditTime": "Time", "auditUser": "User", "auditAction": "Action", - "auditTarget": "Target" + "auditTarget": "Target", + "selectAllMatching": "Select all {count} matching" }, "rooms": { "title": "Camerele", diff --git a/src/messages/ru.json b/src/messages/ru.json index b3f60e411d..e835a128c6 100644 --- a/src/messages/ru.json +++ b/src/messages/ru.json @@ -1968,7 +1968,8 @@ "auditTime": "Time", "auditUser": "User", "auditAction": "Action", - "auditTarget": "Target" + "auditTarget": "Target", + "selectAllMatching": "Select all {count} matching" }, "rooms": { "title": "Rooms", diff --git a/src/messages/sk.json b/src/messages/sk.json index 6b5cb55069..f38f7d03ac 100644 --- a/src/messages/sk.json +++ b/src/messages/sk.json @@ -1968,7 +1968,8 @@ "auditTime": "Time", "auditUser": "User", "auditAction": "Action", - "auditTarget": "Target" + "auditTarget": "Target", + "selectAllMatching": "Select all {count} matching" }, "rooms": { "title": "Izby", diff --git a/src/messages/sr.json b/src/messages/sr.json index 2f2df4274c..b8a0d3b288 100644 --- a/src/messages/sr.json +++ b/src/messages/sr.json @@ -1968,7 +1968,8 @@ "auditTime": "Time", "auditUser": "User", "auditAction": "Action", - "auditTarget": "Target" + "auditTarget": "Target", + "selectAllMatching": "Select all {count} matching" }, "rooms": { "title": "Собе", diff --git a/src/messages/sv.json b/src/messages/sv.json index 1927f45403..db55b96b1c 100644 --- a/src/messages/sv.json +++ b/src/messages/sv.json @@ -1968,7 +1968,8 @@ "auditTime": "Time", "auditUser": "User", "auditAction": "Action", - "auditTarget": "Target" + "auditTarget": "Target", + "selectAllMatching": "Select all {count} matching" }, "rooms": { "title": "Rum", diff --git a/src/messages/tr.json b/src/messages/tr.json index 4dfcd204d4..b77736c5e2 100644 --- a/src/messages/tr.json +++ b/src/messages/tr.json @@ -1968,7 +1968,8 @@ "auditTime": "Time", "auditUser": "User", "auditAction": "Action", - "auditTarget": "Target" + "auditTarget": "Target", + "selectAllMatching": "Select all {count} matching" }, "rooms": { "title": "Odalar", diff --git a/src/messages/uk.json b/src/messages/uk.json index 142a6d8d92..a582e0d7e1 100644 --- a/src/messages/uk.json +++ b/src/messages/uk.json @@ -1968,7 +1968,8 @@ "auditTime": "Time", "auditUser": "User", "auditAction": "Action", - "auditTarget": "Target" + "auditTarget": "Target", + "selectAllMatching": "Select all {count} matching" }, "rooms": { "title": "Кімнати",