refactor(housekeeping): sortable columns, inline edit Enter/Escape, select all matching, i18n date
Local Build and Deploy / deploy (push) Successful in 1m0s
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:
1 parent
f1e4e32a1e
commit
5c58a2b469
23 files changed
+245
-87
No files matched your search
@@ -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>
|
||||
|
||||
@@ -1968,7 +1968,8 @@
|
||||
"auditTime": "Time",
|
||||
"auditUser": "User",
|
||||
"auditAction": "Action",
|
||||
"auditTarget": "Target"
|
||||
"auditTarget": "Target",
|
||||
"selectAllMatching": "Select all {count} matching"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Стаи",
|
||||
|
||||
@@ -1968,7 +1968,8 @@
|
||||
"auditTime": "Time",
|
||||
"auditUser": "User",
|
||||
"auditAction": "Action",
|
||||
"auditTarget": "Target"
|
||||
"auditTarget": "Target",
|
||||
"selectAllMatching": "Select all {count} matching"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Pokoje",
|
||||
|
||||
@@ -1968,7 +1968,8 @@
|
||||
"auditTime": "Time",
|
||||
"auditUser": "User",
|
||||
"auditAction": "Action",
|
||||
"auditTarget": "Target"
|
||||
"auditTarget": "Target",
|
||||
"selectAllMatching": "Select all {count} matching"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Værelser",
|
||||
|
||||
@@ -1932,7 +1932,8 @@
|
||||
"auditTime": "Time",
|
||||
"auditUser": "User",
|
||||
"auditAction": "Action",
|
||||
"auditTarget": "Target"
|
||||
"auditTarget": "Target",
|
||||
"selectAllMatching": "Select all {count} matching"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Räume",
|
||||
|
||||
@@ -1968,7 +1968,8 @@
|
||||
"auditTime": "Time",
|
||||
"auditUser": "User",
|
||||
"auditAction": "Action",
|
||||
"auditTarget": "Target"
|
||||
"auditTarget": "Target",
|
||||
"selectAllMatching": "Select all {count} matching"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Δωμάτια",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1932,7 +1932,8 @@
|
||||
"auditTime": "Time",
|
||||
"auditUser": "User",
|
||||
"auditAction": "Action",
|
||||
"auditTarget": "Target"
|
||||
"auditTarget": "Target",
|
||||
"selectAllMatching": "Select all {count} matching"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Salas",
|
||||
|
||||
@@ -1932,7 +1932,8 @@
|
||||
"auditTime": "Time",
|
||||
"auditUser": "User",
|
||||
"auditAction": "Action",
|
||||
"auditTarget": "Target"
|
||||
"auditTarget": "Target",
|
||||
"selectAllMatching": "Select all {count} matching"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Salons",
|
||||
|
||||
@@ -1968,7 +1968,8 @@
|
||||
"auditTime": "Time",
|
||||
"auditUser": "User",
|
||||
"auditAction": "Action",
|
||||
"auditTarget": "Target"
|
||||
"auditTarget": "Target",
|
||||
"selectAllMatching": "Select all {count} matching"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Sobe",
|
||||
|
||||
@@ -1968,7 +1968,8 @@
|
||||
"auditTime": "Time",
|
||||
"auditUser": "User",
|
||||
"auditAction": "Action",
|
||||
"auditTarget": "Target"
|
||||
"auditTarget": "Target",
|
||||
"selectAllMatching": "Select all {count} matching"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Szobák",
|
||||
|
||||
@@ -1932,7 +1932,8 @@
|
||||
"auditTime": "Time",
|
||||
"auditUser": "User",
|
||||
"auditAction": "Action",
|
||||
"auditTarget": "Target"
|
||||
"auditTarget": "Target",
|
||||
"selectAllMatching": "Select all {count} matching"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Stanze",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1968,7 +1968,8 @@
|
||||
"auditTime": "Time",
|
||||
"auditUser": "User",
|
||||
"auditAction": "Action",
|
||||
"auditTarget": "Target"
|
||||
"auditTarget": "Target",
|
||||
"selectAllMatching": "Select all {count} matching"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Rom",
|
||||
|
||||
@@ -1968,7 +1968,8 @@
|
||||
"auditTime": "Time",
|
||||
"auditUser": "User",
|
||||
"auditAction": "Action",
|
||||
"auditTarget": "Target"
|
||||
"auditTarget": "Target",
|
||||
"selectAllMatching": "Select all {count} matching"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Pokoje",
|
||||
|
||||
@@ -1968,7 +1968,8 @@
|
||||
"auditTime": "Time",
|
||||
"auditUser": "User",
|
||||
"auditAction": "Action",
|
||||
"auditTarget": "Target"
|
||||
"auditTarget": "Target",
|
||||
"selectAllMatching": "Select all {count} matching"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Quartos",
|
||||
|
||||
@@ -1968,7 +1968,8 @@
|
||||
"auditTime": "Time",
|
||||
"auditUser": "User",
|
||||
"auditAction": "Action",
|
||||
"auditTarget": "Target"
|
||||
"auditTarget": "Target",
|
||||
"selectAllMatching": "Select all {count} matching"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Camerele",
|
||||
|
||||
@@ -1968,7 +1968,8 @@
|
||||
"auditTime": "Time",
|
||||
"auditUser": "User",
|
||||
"auditAction": "Action",
|
||||
"auditTarget": "Target"
|
||||
"auditTarget": "Target",
|
||||
"selectAllMatching": "Select all {count} matching"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Rooms",
|
||||
|
||||
@@ -1968,7 +1968,8 @@
|
||||
"auditTime": "Time",
|
||||
"auditUser": "User",
|
||||
"auditAction": "Action",
|
||||
"auditTarget": "Target"
|
||||
"auditTarget": "Target",
|
||||
"selectAllMatching": "Select all {count} matching"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Izby",
|
||||
|
||||
@@ -1968,7 +1968,8 @@
|
||||
"auditTime": "Time",
|
||||
"auditUser": "User",
|
||||
"auditAction": "Action",
|
||||
"auditTarget": "Target"
|
||||
"auditTarget": "Target",
|
||||
"selectAllMatching": "Select all {count} matching"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Собе",
|
||||
|
||||
@@ -1968,7 +1968,8 @@
|
||||
"auditTime": "Time",
|
||||
"auditUser": "User",
|
||||
"auditAction": "Action",
|
||||
"auditTarget": "Target"
|
||||
"auditTarget": "Target",
|
||||
"selectAllMatching": "Select all {count} matching"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Rum",
|
||||
|
||||
@@ -1968,7 +1968,8 @@
|
||||
"auditTime": "Time",
|
||||
"auditUser": "User",
|
||||
"auditAction": "Action",
|
||||
"auditTarget": "Target"
|
||||
"auditTarget": "Target",
|
||||
"selectAllMatching": "Select all {count} matching"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Odalar",
|
||||
|
||||
@@ -1968,7 +1968,8 @@
|
||||
"auditTime": "Time",
|
||||
"auditUser": "User",
|
||||
"auditAction": "Action",
|
||||
"auditTarget": "Target"
|
||||
"auditTarget": "Target",
|
||||
"selectAllMatching": "Select all {count} matching"
|
||||
},
|
||||
"rooms": {
|
||||
"title": "Кімнати",
|
||||
|
||||
Reference in new issue
Block a user