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";
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<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [selectAllMatching, setSelectAllMatching] = useState(false);
const [busyBulk, setBusyBulk] = useState(false);
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 formRef = useRef<HTMLFormElement>(null);
const editRef = useRef<HTMLInputElement>(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 (
<div>
@@ -321,16 +402,27 @@ export function ManageSection(props: SharedProps) {
{filtered.length}
</span>
</h2>
{selected.size > 0 && (
<button
type="button"
onClick={() => setConfirmDel("__bulk__")}
disabled={busyBulk}
className="btn btn-danger btn-sm"
>
{t("deleteSelected", { count: selected.size })}
</button>
)}
<div className="flex items-center gap-2">
{selected.size > 0 && selected.size < sorted.length && (
<button
type="button"
onClick={handleSelectAllMatching}
className="btn btn-outline btn-sm"
>
{t("selectAllMatching", { count: sorted.length })}
</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>
{filtered.length === 0 ? (
<div className="admin-empty">{t("noPermissions")}</div>
@@ -340,20 +432,55 @@ export function ManageSection(props: SharedProps) {
<thead>
<tr>
<th className="w-[36px]">
<input
type="checkbox"
checked={
paginated.length > 0 &&
selected.size === paginated.length
}
onChange={toggleAll}
/>
{selectAllMatching ? (
<input
type="checkbox"
checked={true}
onChange={() => {
setSelected(new Set());
setSelectAllMatching(false);
}}
/>
) : (
<input
type="checkbox"
checked={
paginated.length > 0 &&
selected.size === paginated.length
}
onChange={toggleAll}
/>
)}
</th>
<th className="w-[40px]">#</th>
<th>{t("colPermission")}</th>
<th>{t("colDescription")}</th>
<th className="w-[120px]">{t("colGroup")}</th>
<th className="w-[100px]">{t("colMinRank")}</th>
<th
className={thClass}
onClick={() => handleSort("permission")}
>
{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-[80px]" />
</tr>
@@ -366,7 +493,7 @@ export function ManageSection(props: SharedProps) {
<td>
<input
type="checkbox"
checked={selected.has(sid)}
checked={selectAllMatching || selected.has(sid)}
onChange={() => toggleSelect(sid)}
/>
</td>
@@ -387,47 +514,48 @@ export function ManageSection(props: SharedProps) {
)}
</td>
<td>
<form
onSubmit={(e) => {
e.preventDefault();
handleUpsert(new FormData(e.currentTarget));
}}
style={{ display: "flex", gap: "0.5rem" }}
>
<input
type="hidden"
name="permission"
value={p.permission}
/>
<input
type="hidden"
name="description"
value={p.description ?? ""}
/>
<input
type="hidden"
name="groupName"
value={p.groupName ?? ""}
/>
<input
type="hidden"
name="dependsOn"
value={p.dependsOn ?? ""}
/>
<input
name="minRank"
type="number"
min={0}
defaultValue={p.minRank}
style={{ width: 80 }}
/>
{editingId === sid ? (
<div className="flex gap-1 items-center">
<input
ref={editRef}
type="number"
min={0}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitEdit(p);
} else if (e.key === "Escape") {
cancelEdit();
}
}}
style={{ width: 70 }}
/>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => commitEdit(p)}
>
\u2713
</button>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={cancelEdit}
>
\u2715
</button>
</div>
) : (
<button
type="submit"
className="btn btn-outline btn-sm"
type="button"
className="btn btn-outline btn-sm min-w-[70px] text-center"
onClick={() => startEdit(sid, p.minRank)}
>
{t("save")}
{p.minRank}
</button>
</form>
)}
</td>
<td className="text-xs">
{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) => (
<tr key={log.id}>
<td className="text-xs whitespace-nowrap">
{new Date(log.createdAt).toLocaleString()}
{dateFmt.format(new Date(log.createdAt))}
</td>
<td className="text-xs">{log.username ?? "System"}</td>
<td className="text-xs font-mono">{log.action}</td>
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time",
"auditUser": "User",
"auditAction": "Action",
"auditTarget": "Target"
"auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
},
"rooms": {
"title": "Стаи",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time",
"auditUser": "User",
"auditAction": "Action",
"auditTarget": "Target"
"auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
},
"rooms": {
"title": "Pokoje",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time",
"auditUser": "User",
"auditAction": "Action",
"auditTarget": "Target"
"auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
},
"rooms": {
"title": "Værelser",
+2 -1
View File
@@ -1932,7 +1932,8 @@
"auditTime": "Time",
"auditUser": "User",
"auditAction": "Action",
"auditTarget": "Target"
"auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
},
"rooms": {
"title": "Räume",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time",
"auditUser": "User",
"auditAction": "Action",
"auditTarget": "Target"
"auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
},
"rooms": {
"title": "Δωμάτια",
+1
View File
@@ -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",
+2 -1
View File
@@ -1932,7 +1932,8 @@
"auditTime": "Time",
"auditUser": "User",
"auditAction": "Action",
"auditTarget": "Target"
"auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
},
"rooms": {
"title": "Salas",
+2 -1
View File
@@ -1932,7 +1932,8 @@
"auditTime": "Time",
"auditUser": "User",
"auditAction": "Action",
"auditTarget": "Target"
"auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
},
"rooms": {
"title": "Salons",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time",
"auditUser": "User",
"auditAction": "Action",
"auditTarget": "Target"
"auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
},
"rooms": {
"title": "Sobe",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time",
"auditUser": "User",
"auditAction": "Action",
"auditTarget": "Target"
"auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
},
"rooms": {
"title": "Szobák",
+2 -1
View File
@@ -1932,7 +1932,8 @@
"auditTime": "Time",
"auditUser": "User",
"auditAction": "Action",
"auditTarget": "Target"
"auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
},
"rooms": {
"title": "Stanze",
+1
View File
@@ -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",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time",
"auditUser": "User",
"auditAction": "Action",
"auditTarget": "Target"
"auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
},
"rooms": {
"title": "Rom",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time",
"auditUser": "User",
"auditAction": "Action",
"auditTarget": "Target"
"auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
},
"rooms": {
"title": "Pokoje",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time",
"auditUser": "User",
"auditAction": "Action",
"auditTarget": "Target"
"auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
},
"rooms": {
"title": "Quartos",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time",
"auditUser": "User",
"auditAction": "Action",
"auditTarget": "Target"
"auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
},
"rooms": {
"title": "Camerele",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time",
"auditUser": "User",
"auditAction": "Action",
"auditTarget": "Target"
"auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
},
"rooms": {
"title": "Rooms",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time",
"auditUser": "User",
"auditAction": "Action",
"auditTarget": "Target"
"auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
},
"rooms": {
"title": "Izby",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time",
"auditUser": "User",
"auditAction": "Action",
"auditTarget": "Target"
"auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
},
"rooms": {
"title": "Собе",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time",
"auditUser": "User",
"auditAction": "Action",
"auditTarget": "Target"
"auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
},
"rooms": {
"title": "Rum",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time",
"auditUser": "User",
"auditAction": "Action",
"auditTarget": "Target"
"auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
},
"rooms": {
"title": "Odalar",
+2 -1
View File
@@ -1968,7 +1968,8 @@
"auditTime": "Time",
"auditUser": "User",
"auditAction": "Action",
"auditTarget": "Target"
"auditTarget": "Target",
"selectAllMatching": "Select all {count} matching"
},
"rooms": {
"title": "Кімнати",