feat(brand): replace placeholder mark with logo + favicon, fix connect button

This commit is contained in:
2026-05-30 12:23:09 +02:00
parent 73f1af185f
commit 610572eafe
6 changed files with 398 additions and 42 deletions

View File

@@ -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<SortState>({
key: "created",
dir: "desc",
});
const [reqPage, setReqPage] = useState(1);
const [tenSearch, setTenSearch] = useState("");
const [tenSort, setTenSort] = useState<SortState>({
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" && (
<>
<div className="flex gap-1.5 mb-4 flex-wrap">
{FILTERS.map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
filter === f
? "bg-accent text-surface-0"
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-border"
}`}
>
{t(`filter_${f}`)}
</button>
))}
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
<div className="flex gap-1.5 flex-wrap">
{FILTERS.map((f) => (
<button
key={f}
onClick={() => {
setFilter(f);
setReqPage(1);
}}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
filter === f
? "bg-accent text-surface-0"
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-border"
}`}
>
{t(`filter_${f}`)}
</button>
))}
</div>
<SearchInput
value={reqSearch}
onChange={(v) => {
setReqSearch(v);
setReqPage(1);
}}
placeholder={t("searchRequestsPlaceholder")}
/>
</div>
{loadingRequests ? (
@@ -340,15 +423,22 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
<p className="text-text-secondary text-sm">{t("noRequests")}</p>
</div>
) : reqView.total === 0 ? (
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
<p className="text-text-secondary text-sm">{t("noMatches")}</p>
</div>
) : (
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left">
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("company")}
</th>
<SortableTh
label={t("company")}
sortKey="company"
sort={reqSort}
onSort={onReqSort}
/>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("contact")}
</th>
@@ -358,19 +448,26 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden lg:table-cell">
{t("packages")}
</th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("status")}
</th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
{t("submitted")}
</th>
<SortableTh
label={t("status")}
sortKey="status"
sort={reqSort}
onSort={onReqSort}
/>
<SortableTh
label={t("submitted")}
sortKey="created"
sort={reqSort}
onSort={onReqSort}
className="hidden md:table-cell"
/>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("actions")}
</th>
</tr>
</thead>
<tbody>
{requests.map((req) => (
{reqView.paged.map((req) => (
<tr
key={req.id}
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
@@ -506,6 +603,12 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
</tbody>
</table>
</div>
<Pagination
page={reqView.page}
totalPages={reqView.totalPages}
total={reqView.total}
onPage={setReqPage}
/>
</div>
)}
</>
@@ -543,6 +646,17 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
/>
</div>
<div className="flex justify-end mb-4">
<SearchInput
value={tenSearch}
onChange={(v) => {
setTenSearch(v);
setTenPage(1);
}}
placeholder={t("searchTenantsPlaceholder")}
/>
</div>
{loadingTenants ? (
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
<div className="h-5 w-5 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-2" />
@@ -552,37 +666,51 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
<p className="text-text-secondary text-sm">{t("noTenants")}</p>
</div>
) : tenView.total === 0 ? (
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
<p className="text-text-secondary text-sm">{t("noMatches")}</p>
</div>
) : (
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left">
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("name")}
</th>
<SortableTh
label={t("name")}
sortKey="name"
sort={tenSort}
onSort={onTenSort}
/>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("displayName")}
</th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("phase")}
</th>
<SortableTh
label={t("phase")}
sortKey="phase"
sort={tenSort}
onSort={onTenSort}
/>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
{t("packages")}
</th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
{t("spendChf")}
</th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
{t("created")}
</th>
<SortableTh
label={t("created")}
sortKey="created"
sort={tenSort}
onSort={onTenSort}
className="hidden md:table-cell"
/>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("actions")}
</th>
</tr>
</thead>
<tbody>
{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) {
</tbody>
</table>
</div>
<Pagination
page={tenView.page}
totalPages={tenView.totalPages}
total={tenView.total}
onPage={setTenPage}
/>
</div>
)}
</>

View File

@@ -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<T>(
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 (
<div className="relative">
<svg
className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-text-muted pointer-events-none"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.75}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-4.35-4.35M17 11a6 6 0 11-12 0 6 6 0 0112 0z"
/>
</svg>
<input
type="search"
value={value}
onChange={(e) => 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"
/>
</div>
);
}
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 (
<th className={`px-4 py-3 ${className ?? ""}`}>
<button
type="button"
onClick={() => onSort(sortKey)}
className={`inline-flex items-center gap-1 text-xs font-semibold uppercase tracking-wider transition-colors cursor-pointer ${
active ? "text-text-secondary" : "text-text-muted hover:text-text-secondary"
}`}
aria-sort={active ? (sort.dir === "asc" ? "ascending" : "descending") : "none"}
>
{label}
<span className="inline-block w-2 text-[9px]" aria-hidden="true">
{active ? (sort.dir === "asc" ? "▲" : "▼") : ""}
</span>
</button>
</th>
);
}
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 (
<div className="flex items-center justify-end px-4 py-2.5 border-t border-border text-xs text-text-muted">
<span>{t("paginationCount", { total })}</span>
</div>
);
}
return (
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border text-xs text-text-muted gap-3">
<span className="tabular-nums">{t("paginationCount", { total })}</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onPage(page - 1)}
disabled={page <= 1}
className="px-2.5 py-1 rounded-md border border-border hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
{t("paginationPrev")}
</button>
<span className="tabular-nums">
{t("paginationPage", { page, total: totalPages })}
</span>
<button
type="button"
onClick={() => onPage(page + 1)}
disabled={page >= totalPages}
className="px-2.5 py-1 rounded-md border border-border hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
{t("paginationNext")}
</button>
</div>
</div>
);
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",