refactor(housekeeping): sortable columns, inline edit Enter/Escape, select all matching, i18n date
Local Build and Deploy / deploy (push) Successful in 1m0s

- 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
This commit is contained in:
openhands committed 2026-07-17 20:03:42 +02:00
1 parent f1e4e32a1e
commit 5c58a2b469
23 files changed
+245 -87

No files matched your search

+203 -67
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useRouter, useSearchParams } from "next/navigation"; 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -96,10 +96,16 @@ export function ManageSection(props: SharedProps) {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pendingDel, setPendingDel] = useState<string | null>(null); const [pendingDel, setPendingDel] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [selectAllMatching, setSelectAllMatching] = useState(false);
const [busyBulk, setBusyBulk] = useState(false); const [busyBulk, setBusyBulk] = useState(false);
const [confirmDel, setConfirmDel] = useState<string | null>(null); const [confirmDel, setConfirmDel] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [sortKey, setSortKey] = useState<string>("permission");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const searchTimer = useRef<ReturnType<typeof setTimeout>>(null); const searchTimer = useRef<ReturnType<typeof setTimeout>>(null);
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
const editRef = useRef<HTMLInputElement>(null);
const filtered = useMemo(() => { const filtered = useMemo(() => {
return props.permissions.filter((p) => { return props.permissions.filter((p) => {
@@ -110,9 +116,35 @@ export function ManageSection(props: SharedProps) {
}); });
}, [props.permissions, search, groupFilter]); }, [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 safePage = Math.min(page, totalPages);
const paginated = filtered.slice( const paginated = sorted.slice(
(safePage - 1) * PAGE_SIZE, (safePage - 1) * PAGE_SIZE,
safePage * PAGE_SIZE, safePage * PAGE_SIZE,
); );
@@ -200,15 +232,64 @@ export function ManageSection(props: SharedProps) {
else next.add(id); else next.add(id);
return next; return next;
}); });
setSelectAllMatching(false);
}, []); }, []);
const toggleAll = useCallback(() => { const toggleAll = useCallback(() => {
if (selected.size === paginated.length) { if (selected.size === paginated.length && !selectAllMatching) {
setSelected(new Set()); setSelected(new Set());
} else { } else {
setSelected(new Set(paginated.map((p) => String(p.id)))); 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 ( return (
<div> <div>
@@ -321,16 +402,27 @@ export function ManageSection(props: SharedProps) {
{filtered.length} {filtered.length}
</span> </span>
</h2> </h2>
{selected.size > 0 && ( <div className="flex items-center gap-2">
<button {selected.size > 0 && selected.size < sorted.length && (
type="button" <button
onClick={() => setConfirmDel("__bulk__")} type="button"
disabled={busyBulk} onClick={handleSelectAllMatching}
className="btn btn-danger btn-sm" className="btn btn-outline btn-sm"
> >
{t("deleteSelected", { count: selected.size })} {t("selectAllMatching", { count: sorted.length })}
</button> </button>
)} )}
{selected.size > 0 && (
<button
type="button"
onClick={() => setConfirmDel("__bulk__")}
disabled={busyBulk}
className="btn btn-danger btn-sm"
>
{t("deleteSelected", { count: selected.size })}
</button>
)}
</div>
</div> </div>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className="admin-empty">{t("noPermissions")}</div> <div className="admin-empty">{t("noPermissions")}</div>
@@ -340,20 +432,55 @@ export function ManageSection(props: SharedProps) {
<thead> <thead>
<tr> <tr>
<th className="w-[36px]"> <th className="w-[36px]">
<input {selectAllMatching ? (
type="checkbox" <input
checked={ type="checkbox"
paginated.length > 0 && checked={true}
selected.size === paginated.length onChange={() => {
} setSelected(new Set());
onChange={toggleAll} setSelectAllMatching(false);
/> }}
/>
) : (
<input
type="checkbox"
checked={
paginated.length > 0 &&
selected.size === paginated.length
}
onChange={toggleAll}
/>
)}
</th> </th>
<th className="w-[40px]">#</th> <th className="w-[40px]">#</th>
<th>{t("colPermission")}</th> <th
<th>{t("colDescription")}</th> className={thClass}
<th className="w-[120px]">{t("colGroup")}</th> onClick={() => handleSort("permission")}
<th className="w-[100px]">{t("colMinRank")}</th> >
{t("colPermission")}
{sortIcon("permission")}
</th>
<th
className={thClass}
onClick={() => handleSort("description")}
>
{t("colDescription")}
{sortIcon("description")}
</th>
<th
className={"w-[120px] " + thClass}
onClick={() => handleSort("groupName")}
>
{t("colGroup")}
{sortIcon("groupName")}
</th>
<th
className={"w-[100px] " + thClass}
onClick={() => handleSort("minRank")}
>
{t("colMinRank")}
{sortIcon("minRank")}
</th>
<th className="w-[180px]">{t("colDepends")}</th> <th className="w-[180px]">{t("colDepends")}</th>
<th className="w-[80px]" /> <th className="w-[80px]" />
</tr> </tr>
@@ -366,7 +493,7 @@ export function ManageSection(props: SharedProps) {
<td> <td>
<input <input
type="checkbox" type="checkbox"
checked={selected.has(sid)} checked={selectAllMatching || selected.has(sid)}
onChange={() => toggleSelect(sid)} onChange={() => toggleSelect(sid)}
/> />
</td> </td>
@@ -387,47 +514,48 @@ export function ManageSection(props: SharedProps) {
)} )}
</td> </td>
<td> <td>
<form {editingId === sid ? (
onSubmit={(e) => { <div className="flex gap-1 items-center">
e.preventDefault(); <input
handleUpsert(new FormData(e.currentTarget)); ref={editRef}
}} type="number"
style={{ display: "flex", gap: "0.5rem" }} min={0}
> value={editValue}
<input onChange={(e) => setEditValue(e.target.value)}
type="hidden" onKeyDown={(e) => {
name="permission" if (e.key === "Enter") {
value={p.permission} e.preventDefault();
/> commitEdit(p);
<input } else if (e.key === "Escape") {
type="hidden" cancelEdit();
name="description" }
value={p.description ?? ""} }}
/> style={{ width: 70 }}
<input />
type="hidden" <button
name="groupName" type="button"
value={p.groupName ?? ""} className="btn btn-outline btn-sm"
/> onClick={() => commitEdit(p)}
<input >
type="hidden" \u2713
name="dependsOn" </button>
value={p.dependsOn ?? ""} <button
/> type="button"
<input className="btn btn-outline btn-sm"
name="minRank" onClick={cancelEdit}
type="number" >
min={0} \u2715
defaultValue={p.minRank} </button>
style={{ width: 80 }} </div>
/> ) : (
<button <button
type="submit" type="button"
className="btn btn-outline btn-sm" className="btn btn-outline btn-sm min-w-[70px] text-center"
onClick={() => startEdit(sid, p.minRank)}
> >
{t("save")} {p.minRank}
</button> </button>
</form> )}
</td> </td>
<td className="text-xs"> <td className="text-xs">
{p.dependsOn ? ( {p.dependsOn ? (
@@ -813,7 +941,15 @@ interface AuditSectionProps {
export function AuditSection({ initialLogs }: AuditSectionProps) { export function AuditSection({ initialLogs }: AuditSectionProps) {
const t = useTranslations("pages.admin.housekeeping"); const t = useTranslations("pages.admin.housekeeping");
const locale = useLocale();
const router = useRouter(); const router = useRouter();
const dateFmt = useMemo(
() => new Intl.DateTimeFormat(locale, {
dateStyle: "short",
timeStyle: "short",
}),
[locale],
);
if (!initialLogs || initialLogs.rows.length === 0) { if (!initialLogs || initialLogs.rows.length === 0) {
return ( return (
@@ -856,7 +992,7 @@ export function AuditSection({ initialLogs }: AuditSectionProps) {
{initialLogs.rows.map((log) => ( {initialLogs.rows.map((log) => (
<tr key={log.id}> <tr key={log.id}>
<td className="text-xs whitespace-nowrap"> <td className="text-xs whitespace-nowrap">
{new Date(log.createdAt).toLocaleString()} {dateFmt.format(new Date(log.createdAt))}
</td> </td>
<td className="text-xs">{log.username ?? "System"}</td> <td className="text-xs">{log.username ?? "System"}</td>
<td className="text-xs font-mono">{log.action}</td> <td className="text-xs font-mono">{log.action}</td>
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time", "auditTime": "Time",
"auditUser": "User", "auditUser": "User",
"auditAction": "Action", "auditAction": "Action",
"auditTarget": "Target" "auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
}, },
"rooms": { "rooms": {
"title": "Стаи", "title": "Стаи",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time", "auditTime": "Time",
"auditUser": "User", "auditUser": "User",
"auditAction": "Action", "auditAction": "Action",
"auditTarget": "Target" "auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
}, },
"rooms": { "rooms": {
"title": "Pokoje", "title": "Pokoje",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time", "auditTime": "Time",
"auditUser": "User", "auditUser": "User",
"auditAction": "Action", "auditAction": "Action",
"auditTarget": "Target" "auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
}, },
"rooms": { "rooms": {
"title": "Værelser", "title": "Værelser",
+2 -1
View File
@@ -1932,7 +1932,8 @@
"auditTime": "Time", "auditTime": "Time",
"auditUser": "User", "auditUser": "User",
"auditAction": "Action", "auditAction": "Action",
"auditTarget": "Target" "auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
}, },
"rooms": { "rooms": {
"title": "Räume", "title": "Räume",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time", "auditTime": "Time",
"auditUser": "User", "auditUser": "User",
"auditAction": "Action", "auditAction": "Action",
"auditTarget": "Target" "auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
}, },
"rooms": { "rooms": {
"title": "Δωμάτια", "title": "Δωμάτια",
+1
View File
@@ -1899,6 +1899,7 @@
"confirmDeletePermission": "Delete this permission permanently?", "confirmDeletePermission": "Delete this permission permanently?",
"confirmBulkDelete": "Delete {count} selected permissions?", "confirmBulkDelete": "Delete {count} selected permissions?",
"deleteSelected": "Delete {count} selected", "deleteSelected": "Delete {count} selected",
"selectAllMatching": "Select all {count} matching",
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Confirm", "confirm": "Confirm",
"colGroup": "Group", "colGroup": "Group",
+2 -1
View File
@@ -1932,7 +1932,8 @@
"auditTime": "Time", "auditTime": "Time",
"auditUser": "User", "auditUser": "User",
"auditAction": "Action", "auditAction": "Action",
"auditTarget": "Target" "auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
}, },
"rooms": { "rooms": {
"title": "Salas", "title": "Salas",
+2 -1
View File
@@ -1932,7 +1932,8 @@
"auditTime": "Time", "auditTime": "Time",
"auditUser": "User", "auditUser": "User",
"auditAction": "Action", "auditAction": "Action",
"auditTarget": "Target" "auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
}, },
"rooms": { "rooms": {
"title": "Salons", "title": "Salons",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time", "auditTime": "Time",
"auditUser": "User", "auditUser": "User",
"auditAction": "Action", "auditAction": "Action",
"auditTarget": "Target" "auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
}, },
"rooms": { "rooms": {
"title": "Sobe", "title": "Sobe",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time", "auditTime": "Time",
"auditUser": "User", "auditUser": "User",
"auditAction": "Action", "auditAction": "Action",
"auditTarget": "Target" "auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
}, },
"rooms": { "rooms": {
"title": "Szobák", "title": "Szobák",
+2 -1
View File
@@ -1932,7 +1932,8 @@
"auditTime": "Time", "auditTime": "Time",
"auditUser": "User", "auditUser": "User",
"auditAction": "Action", "auditAction": "Action",
"auditTarget": "Target" "auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
}, },
"rooms": { "rooms": {
"title": "Stanze", "title": "Stanze",
+1
View File
@@ -1899,6 +1899,7 @@
"confirmDeletePermission": "Deze permissie permanent verwijderen?", "confirmDeletePermission": "Deze permissie permanent verwijderen?",
"confirmBulkDelete": "{count} geselecteerde permissies verwijderen?", "confirmBulkDelete": "{count} geselecteerde permissies verwijderen?",
"deleteSelected": "Verwijder {count} geselecteerd", "deleteSelected": "Verwijder {count} geselecteerd",
"selectAllMatching": "Selecteer alle {count} overeenkomende",
"cancel": "Annuleren", "cancel": "Annuleren",
"confirm": "Bevestigen", "confirm": "Bevestigen",
"colGroup": "Groep", "colGroup": "Groep",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time", "auditTime": "Time",
"auditUser": "User", "auditUser": "User",
"auditAction": "Action", "auditAction": "Action",
"auditTarget": "Target" "auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
}, },
"rooms": { "rooms": {
"title": "Rom", "title": "Rom",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time", "auditTime": "Time",
"auditUser": "User", "auditUser": "User",
"auditAction": "Action", "auditAction": "Action",
"auditTarget": "Target" "auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
}, },
"rooms": { "rooms": {
"title": "Pokoje", "title": "Pokoje",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time", "auditTime": "Time",
"auditUser": "User", "auditUser": "User",
"auditAction": "Action", "auditAction": "Action",
"auditTarget": "Target" "auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
}, },
"rooms": { "rooms": {
"title": "Quartos", "title": "Quartos",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time", "auditTime": "Time",
"auditUser": "User", "auditUser": "User",
"auditAction": "Action", "auditAction": "Action",
"auditTarget": "Target" "auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
}, },
"rooms": { "rooms": {
"title": "Camerele", "title": "Camerele",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time", "auditTime": "Time",
"auditUser": "User", "auditUser": "User",
"auditAction": "Action", "auditAction": "Action",
"auditTarget": "Target" "auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
}, },
"rooms": { "rooms": {
"title": "Rooms", "title": "Rooms",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time", "auditTime": "Time",
"auditUser": "User", "auditUser": "User",
"auditAction": "Action", "auditAction": "Action",
"auditTarget": "Target" "auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
}, },
"rooms": { "rooms": {
"title": "Izby", "title": "Izby",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time", "auditTime": "Time",
"auditUser": "User", "auditUser": "User",
"auditAction": "Action", "auditAction": "Action",
"auditTarget": "Target" "auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
}, },
"rooms": { "rooms": {
"title": "Собе", "title": "Собе",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time", "auditTime": "Time",
"auditUser": "User", "auditUser": "User",
"auditAction": "Action", "auditAction": "Action",
"auditTarget": "Target" "auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
}, },
"rooms": { "rooms": {
"title": "Rum", "title": "Rum",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time", "auditTime": "Time",
"auditUser": "User", "auditUser": "User",
"auditAction": "Action", "auditAction": "Action",
"auditTarget": "Target" "auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
}, },
"rooms": { "rooms": {
"title": "Odalar", "title": "Odalar",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time", "auditTime": "Time",
"auditUser": "User", "auditUser": "User",
"auditAction": "Action", "auditAction": "Action",
"auditTarget": "Target" "auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
}, },
"rooms": { "rooms": {
"title": "Кімнати", "title": "Кімнати",