From 6ba89287918c006da9eb18372417d804469af9ab Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 17 Jul 2026 19:17:12 +0200 Subject: [PATCH] fix(admin): add client-side search, pagination, toast feedback, confirm dialogs and test result display - Rewrite manage/import/presets/test tabs as client component with sonner toasts - Add client-side search filtering (no page reload) - Add pagination (25 per page) - Add confirm dialog before delete and clear-all - Show test results inline after submission - Add toast feedback for import, preset apply, save, delete - Add prev/next pagination labels --- src/app/admin/housekeeping/client.tsx | 536 +++++++++++++++++++++++++- src/app/admin/housekeeping/page.tsx | 362 ++--------------- src/messages/en.json | 12 +- src/messages/nl.json | 12 +- 4 files changed, 580 insertions(+), 342 deletions(-) diff --git a/src/app/admin/housekeeping/client.tsx b/src/app/admin/housekeeping/client.tsx index efd0fb1f..c36176f7 100644 --- a/src/app/admin/housekeeping/client.tsx +++ b/src/app/admin/housekeeping/client.tsx @@ -1,29 +1,541 @@ "use client"; +import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { + applyPreset, + bulkImportPermissions, + clearAllPermissions, + deletePermission, + exportPermissions, + testRankPermission, + upsertPermission, +} from "@/actions/admin-housekeeping"; -export function HousekeepingClient() { +const PAGE_SIZE = 25; + +interface HKPerm { + id: bigint; + permission: string; + minRank: number; + description: string | null; + groupName: string | null; + dependsOn: string | null; +} + +interface ManageClientProps { + permissions: HKPerm[]; + groups: string[]; + groupCounts: Record; + permSet: Set; +} + +export function ManageClient({ + permissions, + groups, + groupCounts, + permSet, +}: ManageClientProps) { const t = useTranslations("pages.admin.housekeeping"); - const [exportData, setExportData] = useState(null); + const router = useRouter(); + const [search, setSearch] = useState(""); + const [groupFilter, setGroupFilter] = useState(""); + const [page, setPage] = useState(1); + const [pendingDel, setPendingDel] = useState(null); + + const filtered = useMemo(() => { + return permissions.filter((p) => { + if (search && !p.permission.toLowerCase().includes(search.toLowerCase())) + return false; + if (groupFilter && (p.groupName ?? "") !== groupFilter) return false; + return true; + }); + }, [permissions, search, groupFilter]); + + const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const safePage = Math.min(page, totalPages); + const paginated = filtered.slice( + (safePage - 1) * PAGE_SIZE, + safePage * PAGE_SIZE, + ); + + const handleUpsert = useCallback( + async (formData: FormData) => { + try { + await upsertPermission(formData); + toast.success(t("saved")); + router.refresh(); + } catch { + toast.error(t("errorOccurred")); + } + }, + [router, t], + ); + + const handleDelete = useCallback( + async (id: string) => { + if (!confirm(t("confirmDelete"))) return; + try { + const fd = new FormData(); + fd.set("id", id); + await deletePermission(fd); + toast.success(t("deleted")); + router.refresh(); + } catch { + toast.error(t("errorOccurred")); + } + }, + [router, t], + ); + + const handleBulkImport = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + const fd = new FormData(e.currentTarget); + const raw = String(fd.get("json") ?? "").trim(); + if (!raw) return toast.error(t("emptyInput")); + + try { + JSON.parse(raw); + } catch { + return toast.error(t("invalidJson")); + } + + try { + await bulkImportPermissions(fd); + toast.success(t("imported")); + (e.currentTarget.querySelector( + "textarea", + ) as HTMLTextAreaElement)!.value = ""; + router.refresh(); + } catch { + toast.error(t("errorOccurred")); + } + }, + [router, t], + ); + + const handleApplyPreset = useCallback( + async (preset: string) => { + try { + const fd = new FormData(); + fd.set("preset", preset); + await applyPreset(fd); + toast.success(t("presetApplied")); + router.refresh(); + } catch { + toast.error(t("errorOccurred")); + } + }, + [router, t], + ); + + const handleClearAll = useCallback(async () => { + if (!confirm(t("confirmClearAll"))) return; + try { + await clearAllPermissions(); + toast.success(t("cleared")); + router.refresh(); + } catch { + toast.error(t("errorOccurred")); + } + }, [router, t]); + + const [testRank, setTestRank] = useState(""); + const [testResult, setTestResult] = useState<{ + rank: number; + permissions: string[]; + } | null>(null); + const [testing, setTesting] = useState(false); + + const handleTest = useCallback(async () => { + const rank = Number(testRank); + if (!Number.isFinite(rank) || rank < 0) + return toast.error(t("invalidRank")); + setTesting(true); + try { + const fd = new FormData(); + fd.set("testRank", testRank); + await testRankPermission(fd); + // The action revalidates the page; we also show result client-side + const matching = permissions.filter((p) => p.minRank <= rank); + setTestResult({ rank, permissions: matching.map((p) => p.permission) }); + toast.success(t("testDone", { count: matching.length })); + } catch { + toast.error(t("errorOccurred")); + } + setTesting(false); + }, [testRank, permissions, t]); + + return ( +
+ {/* ── Manage tab ── */} +
+
+ { + setSearch(e.target.value); + setPage(1); + }} + placeholder={t("searchPermission")} + className="flex-1 min-w-[200px]" + /> + +
+
+ +
+

{t("addPermission")}

+
{ + e.preventDefault(); + handleUpsert(new FormData(e.currentTarget)); + }} + className="admin-card" + > +
+ + + + + + +
+
+
+ +
+

+ {t("permissions")}{" "} + + {filtered.length} + +

+ {filtered.length === 0 ? ( +
{t("noPermissions")}
+ ) : ( +
+ + + + + + + + + + + + + {paginated.map((p, i) => ( + + + + + + + + + + ))} + +
#{t("colPermission")}{t("colDescription")}{t("colGroup")}{t("colMinRank")}{t("colDepends")} +
+ {(safePage - 1) * PAGE_SIZE + i + 1} + + {p.permission} + {p.description || "\u2014"} + {p.groupName ? ( + + {p.groupName} + + ) : ( + \u2014 + )} + +
{ + e.preventDefault(); + handleUpsert(new FormData(e.currentTarget)); + }} + style={{ display: "flex", gap: "0.5rem" }} + > + + + + + + +
+
+ {p.dependsOn ? ( + + {p.dependsOn} + {!permSet.has(p.dependsOn) ? " " + t("missing") : ""} + + ) : ( + \u2014 + )} + + +
+
+ )} + {totalPages > 1 && ( +
+ + + {safePage} / {totalPages} + + +
+ )} +
+ + {/* ── Presets ── */} +
+

{t("presets")}

+

+ {t("presetsHint")} +

+
+ {(["moderator", "admin", "superadmin"] as const).map((preset) => { + const labels: Record = { + moderator: t("presetModerator"), + admin: t("presetAdmin"), + superadmin: t("presetSuperAdmin"), + }; + const descs: Record = { + moderator: t("presetModeratorDesc"), + admin: t("presetAdminDesc"), + superadmin: t("presetSuperAdminDesc"), + }; + return ( +
+

{labels[preset]}

+

{descs[preset]}

+ +
+ ); + })} +
+
+ +
+
+ + {/* ── Test ── */} +
+

{t("testTitle")}

+

+ {t("testHint")} +

+
+ setTestRank(e.target.value)} + type="number" + min={0} + placeholder={t("rank")} + className="w-[150px]" + /> + +
+ {testResult && ( +
+
+ {t("rank")} {testResult.rank}: {testResult.permissions.length}{" "} + {t("permissions")} +
+
+ {testResult.permissions.map((p) => ( +
+ {p} +
+ ))} +
+
+ )} +
+ + {/* ── Import ── */} +
+

{t("importTitle")}

+

+ {t("importHint")} +

+
+
+