diff --git a/src/components/admin/admin-panel.tsx b/src/components/admin/admin-panel.tsx index 01a3f60..fb91158 100644 --- a/src/components/admin/admin-panel.tsx +++ b/src/components/admin/admin-panel.tsx @@ -5,6 +5,14 @@ import { useTranslations, useFormatter } from "next-intl"; import type { PiecedTenant, TenantRequest } from "@/types"; import { StatusBadge } from "@/components/ui/status-badge"; import { Modal } from "@/components/ui/modal"; +import { + applyTableView, + nextSort, + SearchInput, + SortableTh, + Pagination, + type SortState, +} from "@/components/admin/table-controls"; import { formatDateTime, formatRelative } from "@/lib/format"; import Link from "next/link"; @@ -53,6 +61,21 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) { // Shared const [error, setError] = useState(""); + + // Client-side table view state (search / sort / page) for each tab. + const [reqSearch, setReqSearch] = useState(""); + const [reqSort, setReqSort] = useState({ + key: "created", + dir: "desc", + }); + const [reqPage, setReqPage] = useState(1); + + const [tenSearch, setTenSearch] = useState(""); + const [tenSort, setTenSort] = useState({ + key: "created", + dir: "desc", + }); + const [tenPage, setTenPage] = useState(1); // Action-scoped error — shown inside the active confirmation modal so // a failed approve/reject/delete surfaces next to the action that // caused it (and keeps the modal open), rather than as a detached @@ -246,6 +269,53 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) { const pendingCount = requests.filter((r) => r.status === "pending").length; + // Derived table views: search → sort → paginate, applied client-side + // on top of the already-fetched lists. + const reqView = applyTableView(requests, { + search: reqSearch, + searchOf: (r) => [ + r.companyName, + r.contactName, + r.contactEmail, + r.agentName, + r.tenantName, + ], + sort: reqSort, + sortOf: (r, key) => + key === "company" + ? r.companyName || "" + : key === "status" + ? r.status || "" + : r.createdAt || "", + page: reqPage, + }); + + const tenView = applyTableView(tenants, { + search: tenSearch, + searchOf: (tn) => [ + tn.metadata.name, + tn.spec.displayName, + tn.spec.agentName, + ], + sort: tenSort, + sortOf: (tn, key) => + key === "name" + ? tn.spec.displayName || tn.metadata.name + : key === "phase" + ? tn.status?.phase || "Pending" + : tn.metadata.creationTimestamp || "", + page: tenPage, + }); + + const onReqSort = (key: string) => { + setReqSort((s) => nextSort(s, key)); + setReqPage(1); + }; + const onTenSort = (key: string) => { + setTenSort((s) => nextSort(s, key)); + setTenPage(1); + }; + return ( <> {/* Tab bar */} @@ -315,20 +385,33 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) { {/* ───── REQUESTS TAB ───── */} {tab === "requests" && ( <> -
- {FILTERS.map((f) => ( - - ))} +
+
+ {FILTERS.map((f) => ( + + ))} +
+ { + setReqSearch(v); + setReqPage(1); + }} + placeholder={t("searchRequestsPlaceholder")} + />
{loadingRequests ? ( @@ -340,15 +423,22 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {

{t("noRequests")}

+ ) : reqView.total === 0 ? ( +
+

{t("noMatches")}

+
) : (
- + @@ -358,19 +448,26 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) { - - + + - {requests.map((req) => ( + {reqView.paged.map((req) => (
- {t("company")} - {t("contact")} {t("packages")} - {t("status")} - - {t("submitted")} - {t("actions")}
+
)} @@ -543,6 +646,17 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) { />
+
+ { + setTenSearch(v); + setTenPage(1); + }} + placeholder={t("searchTenantsPlaceholder")} + /> +
+ {loadingTenants ? (
@@ -552,37 +666,51 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {

{t("noTenants")}

+ ) : tenView.total === 0 ? ( +
+

{t("noMatches")}

+
) : (
- + - + - + - {tenants.map((tenant) => { + {tenView.paged.map((tenant) => { const tenantSpend = health?.spend?.perTenant?.[tenant.metadata.name]; return ( @@ -680,6 +808,12 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
- {t("name")} - {t("displayName")} - {t("phase")} - {t("packages")} {t("spendChf")} - {t("created")} - {t("actions")}
+
)} diff --git a/src/components/admin/table-controls.tsx b/src/components/admin/table-controls.tsx new file mode 100644 index 0000000..04dff46 --- /dev/null +++ b/src/components/admin/table-controls.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +/** + * Shared client-side table controls for the admin panel. + * + * The admin tables (requests, tenants) load their full result set into + * state already, so search/sort/pagination are applied client-side on + * top — no new API surface. At pilot scale the lists are small enough + * that filtering/sorting in memory is free; if they grow past a few + * hundred rows this is the seam to move server-side (the page/sort + * state would become query params). + */ + +export const PAGE_SIZE = 15; + +export interface SortState { + key: string; + dir: "asc" | "desc"; +} + +/** + * Filter → sort → paginate a list. Pure function, called during render. + * `searchOf` returns the haystack strings for a row; `sortOf` returns + * the comparable value for the active sort key (string or number). + */ +export function applyTableView( + items: T[], + opts: { + search: string; + searchOf: (item: T) => (string | null | undefined)[]; + sort: SortState; + sortOf: (item: T, key: string) => string | number; + page: number; + pageSize?: number; + } +): { paged: T[]; total: number; totalPages: number; page: number } { + const pageSize = opts.pageSize ?? PAGE_SIZE; + + const q = opts.search.trim().toLowerCase(); + const filtered = q + ? items.filter((it) => + opts + .searchOf(it) + .some((v) => (v ?? "").toString().toLowerCase().includes(q)) + ) + : items; + + const sorted = [...filtered].sort((a, b) => { + const av = opts.sortOf(a, opts.sort.key); + const bv = opts.sortOf(b, opts.sort.key); + const cmp = + typeof av === "number" && typeof bv === "number" + ? av - bv + : String(av).localeCompare(String(bv)); + return opts.sort.dir === "asc" ? cmp : -cmp; + }); + + const total = sorted.length; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const page = Math.min(Math.max(1, opts.page), totalPages); + const paged = sorted.slice((page - 1) * pageSize, page * pageSize); + + return { paged, total, totalPages, page }; +} + +/** Toggle helper: same key flips direction, new key starts ascending. */ +export function nextSort(current: SortState, key: string): SortState { + if (current.key === key) { + return { key, dir: current.dir === "asc" ? "desc" : "asc" }; + } + return { key, dir: "asc" }; +} + +export function SearchInput({ + value, + onChange, + placeholder, +}: { + value: string; + onChange: (v: string) => void; + placeholder: string; +}) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + className="w-full sm:w-72 pl-8 pr-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors" + /> +
+ ); +} + +export function SortableTh({ + label, + sortKey, + sort, + onSort, + className, +}: { + label: string; + sortKey: string; + sort: SortState; + onSort: (key: string) => void; + className?: string; +}) { + const active = sort.key === sortKey; + return ( + + + + ); +} + +export function Pagination({ + page, + totalPages, + total, + onPage, +}: { + page: number; + totalPages: number; + total: number; + onPage: (p: number) => void; +}) { + const t = useTranslations("admin"); + if (totalPages <= 1) { + return ( +
+ {t("paginationCount", { total })} +
+ ); + } + return ( +
+ {t("paginationCount", { total })} +
+ + + {t("paginationPage", { page, total: totalPages })} + + +
+
+ ); +} diff --git a/src/messages/de.json b/src/messages/de.json index 7e837b1..ba1156f 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -436,7 +436,14 @@ "approveTitle": "Anfrage genehmigen?", "approveWarning": "Dadurch wird die Infrastruktur des Mandanten bereitgestellt, die Einrichtungsgebühr berechnet und der Kunde benachrichtigt. Bitte prüfen Sie die Angaben, bevor Sie fortfahren.", "approveReapproveWarning": "Dies genehmigt eine zuvor abgelehnte Anfrage erneut: Die Infrastruktur des Mandanten wird bereitgestellt, die Einrichtungsgebühr berechnet und der Kunde benachrichtigt.", - "confirmApprove": "Genehmigen & bereitstellen" + "confirmApprove": "Genehmigen & bereitstellen", + "searchRequestsPlaceholder": "Anfragen suchen…", + "searchTenantsPlaceholder": "Mandanten suchen…", + "paginationPrev": "Zurück", + "paginationNext": "Weiter", + "paginationPage": "Seite {page} von {total}", + "paginationCount": "{total} gesamt", + "noMatches": "Keine Treffer." }, "channelUsers": { "title": "Autorisierte Benutzer", @@ -847,7 +854,8 @@ "orgsPayByInvoiceOn": "ein", "orgsPayByInvoiceOff": "aus", "orgsAutoChargeOn": "ein", - "orgsAutoChargeOff": "aus" + "orgsAutoChargeOff": "aus", + "newInvoiceOrgNoMatches": "Keine passenden Kunden." }, "skillCostDialog": { "title": "Aktivierungskosten bestätigen", diff --git a/src/messages/en.json b/src/messages/en.json index cc30343..c3cb74c 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -436,7 +436,14 @@ "approveTitle": "Approve request?", "approveWarning": "This provisions the tenant's infrastructure, charges the setup fee, and notifies the customer. Check the request details are correct before continuing.", "approveReapproveWarning": "This re-approves a previously rejected request: it provisions the tenant's infrastructure, charges the setup fee, and notifies the customer.", - "confirmApprove": "Approve & provision" + "confirmApprove": "Approve & provision", + "searchRequestsPlaceholder": "Search requests…", + "searchTenantsPlaceholder": "Search tenants…", + "paginationPrev": "Previous", + "paginationNext": "Next", + "paginationPage": "Page {page} of {total}", + "paginationCount": "{total} total", + "noMatches": "No matches." }, "channelUsers": { "title": "Authorized Users", @@ -847,7 +854,8 @@ "orgsPayByInvoiceOn": "on", "orgsPayByInvoiceOff": "off", "orgsAutoChargeOn": "on", - "orgsAutoChargeOff": "off" + "orgsAutoChargeOff": "off", + "newInvoiceOrgNoMatches": "No matching customers." }, "skillCostDialog": { "title": "Confirm activation cost", diff --git a/src/messages/fr.json b/src/messages/fr.json index 5c301a5..58ff7fa 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -436,7 +436,14 @@ "approveTitle": "Approuver la demande ?", "approveWarning": "Cela provisionne l'infrastructure du locataire, facture les frais d'installation et notifie le client. Vérifiez l'exactitude des détails de la demande avant de continuer.", "approveReapproveWarning": "Ceci réapprouve une demande précédemment rejetée : l'infrastructure du locataire est provisionnée, les frais d'installation sont facturés et le client est notifié.", - "confirmApprove": "Approuver et provisionner" + "confirmApprove": "Approuver et provisionner", + "searchRequestsPlaceholder": "Rechercher des demandes…", + "searchTenantsPlaceholder": "Rechercher des locataires…", + "paginationPrev": "Précédent", + "paginationNext": "Suivant", + "paginationPage": "Page {page} sur {total}", + "paginationCount": "{total} au total", + "noMatches": "Aucun résultat." }, "channelUsers": { "title": "Utilisateurs autorisés", @@ -847,7 +854,8 @@ "orgsPayByInvoiceOn": "actif", "orgsPayByInvoiceOff": "inactif", "orgsAutoChargeOn": "actif", - "orgsAutoChargeOff": "inactif" + "orgsAutoChargeOff": "inactif", + "newInvoiceOrgNoMatches": "Aucun client correspondant." }, "skillCostDialog": { "title": "Confirmer le coût d'activation", diff --git a/src/messages/it.json b/src/messages/it.json index ff2d0e2..d39e258 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -436,7 +436,14 @@ "approveTitle": "Approvare la richiesta?", "approveWarning": "Questa operazione effettua il provisioning dell'infrastruttura del tenant, addebita il costo di attivazione e notifica il cliente. Verifica che i dettagli della richiesta siano corretti prima di continuare.", "approveReapproveWarning": "Questo riapprova una richiesta precedentemente rifiutata: effettua il provisioning dell'infrastruttura del tenant, addebita il costo di attivazione e notifica il cliente.", - "confirmApprove": "Approva e avvia provisioning" + "confirmApprove": "Approva e avvia provisioning", + "searchRequestsPlaceholder": "Cerca richieste…", + "searchTenantsPlaceholder": "Cerca tenant…", + "paginationPrev": "Precedente", + "paginationNext": "Successivo", + "paginationPage": "Pagina {page} di {total}", + "paginationCount": "{total} totali", + "noMatches": "Nessun risultato." }, "channelUsers": { "title": "Utenti autorizzati", @@ -847,7 +854,8 @@ "orgsPayByInvoiceOn": "attivo", "orgsPayByInvoiceOff": "disattivo", "orgsAutoChargeOn": "attivo", - "orgsAutoChargeOff": "disattivo" + "orgsAutoChargeOff": "disattivo", + "newInvoiceOrgNoMatches": "Nessun cliente corrispondente." }, "skillCostDialog": { "title": "Confermi costi di attivazione",