diff --git a/app/app.css b/app/app.css index 611481e..5a718f4 100644 --- a/app/app.css +++ b/app/app.css @@ -13,6 +13,10 @@ @source "../../lib-action-bus/src"; @source "../../lib-arcadia-client/src"; @source "../../lib-arcadia-auth-ui/src"; +@source "../../lib-table-ui/src"; +@source "../../lib-search-ui/src"; +@source "../../lib-feedback-ui/src"; +@source "../../lib-auth-ui/src"; /* CREMA:SOURCES */ @custom-variant dark (&:is(.dark *)); diff --git a/app/routes/tenants.tsx b/app/routes/tenants.tsx index 32b67b7..749f207 100644 --- a/app/routes/tenants.tsx +++ b/app/routes/tenants.tsx @@ -1,11 +1,23 @@ import { useCallback, useEffect, useMemo, useState } from "react" import { Link } from "react-router" -import { AlertTriangle, Loader2, MoreHorizontal, Pause, Play, Plus, RefreshCw, Search, X } from "lucide-react" +import { Pause, Play, Plus, RefreshCw } from "lucide-react" import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client" +import { + ActionsCell, + BadgeCell, + DataTable, + DateCell, + Pagination, + useTable, + type ActionItem, + type BadgeTone, + type Column, +} from "@crema/table-ui" +import { SearchInput } from "@crema/search-ui" +import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui" import { AppShell } from "~/components/layout/app-shell" -import { Badge } from "~/components/ui/badge" import { Button } from "~/components/ui/button" import { Card, @@ -15,27 +27,23 @@ import { CardTitle, } from "~/components/ui/card" import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "~/components/ui/dropdown-menu" -import { Input } from "~/components/ui/input" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "~/components/ui/table" -import { activateTenant, deactivateTenant, listTenants, suspendTenant, type Tenant, type TenantStatus } from "~/lib/arcadia/tenants" + activateTenant, + deactivateTenant, + listTenants, + suspendTenant, + type Tenant, + type TenantStatus, +} from "~/lib/arcadia/tenants" import { pageTitle } from "~/lib/page-meta" import { useSession } from "~/lib/session" export const meta = () => pageTitle("Tenants") +type PendingAction = { + kind: "suspend" | "deactivate" + tenant: Tenant +} | null + export default function TenantsRoute() { const session = useSession() const arcadia = useArcadiaClient() @@ -43,7 +51,8 @@ export default function TenantsRoute() { const [tenants, setTenants] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const [query, setQuery] = useState("") + const [pending, setPending] = useState(null) + const [search, setSearch] = useState("") const refresh = useCallback(async () => { setError(null) @@ -62,16 +71,84 @@ export default function TenantsRoute() { if (session) refresh() }, [session, refresh]) - const filtered = useMemo(() => { - const q = query.trim().toLowerCase() - if (!q) return tenants - return tenants.filter( - (t) => - t.name.toLowerCase().includes(q) || - t.slug.toLowerCase().includes(q) || - t.id.toLowerCase().includes(q), - ) - }, [tenants, query]) + const runAction = useCallback( + async (action: PendingAction) => { + if (!action) return + try { + if (action.kind === "suspend") await suspendTenant(arcadia, action.tenant.id) + else await deactivateTenant(arcadia, action.tenant.id) + setPending(null) + await refresh() + } catch (err) { + setError(err instanceof ArcadiaError ? err.message : "Action failed.") + setPending(null) + } + }, + [arcadia, refresh], + ) + + const columns = useMemo[]>( + () => [ + { + id: "name", + header: "Name", + accessor: "name", + sortable: true, + cell: (t) => {t.name}, + }, + { + id: "slug", + header: "Slug", + accessor: "slug", + sortable: true, + cell: (t) => ( + {t.slug} + ), + }, + { + id: "status", + header: "Status", + accessor: "status", + sortable: true, + cell: (t) => , + }, + { + id: "plan", + header: "Plan", + accessor: (t) => t.plan?.name ?? "", + sortable: true, + cell: (t) => {t.plan?.name ?? "—"}, + }, + { + id: "created", + header: "Created", + accessor: "inserted_at", + sortable: true, + cell: (t) => , + }, + { + id: "actions", + header: "", + align: "right", + cell: (t) => ( + + ), + }, + ], + [arcadia, refresh], + ) + + const table = useTable({ + data: tenants, + columns, + getRowId: (t) => t.id, + initialPageSize: 25, + initialSearch: search, + }) + // Keep useTable's search in lockstep with our SearchInput. + useEffect(() => { + table.setSearch(search) + }, [search, table]) if (!session) { return ( @@ -123,154 +200,132 @@ export default function TenantsRoute() { + {error ? ( + setError(null)}> + {error} + + ) : null} + -
- - setQuery(e.target.value)} - className="pl-9" - /> - {query ? ( - - ) : null} -
+
- {filtered.length} of {tenants.length} + {table.total} of {tenants.length}
- - {error ? ( -
- - {error} -
- ) : null} - - {loading && tenants.length === 0 ? ( -
- - Loading tenants… -
- ) : filtered.length === 0 ? ( -
- {query ? "No tenants match that search." : "No tenants yet."} -
+ + + {table.total === 0 && !loading ? ( + ) : ( - - - - Name - Slug - Status - Plan - Created - - - - - {filtered.map((t) => ( - - ))} - -
+ <> + t.id} + sort={table.sort} + onSortToggle={table.toggleSort} + loading={loading && tenants.length > 0} + stickyHeader + /> + + )}
+ + !o && setPending(null)} + title="Suspend tenant?" + description={ + pending + ? `${pending.tenant.name} will be suspended. Members won't be able to sign in until you reactivate.` + : "" + } + confirmLabel="Suspend" + variant="default" + onConfirm={() => runAction(pending)} + /> + !o && setPending(null)} + title="Deactivate tenant?" + description={ + pending + ? `${pending.tenant.name} will be deactivated. This is more severe than suspending.` + : "" + } + confirmLabel="Deactivate" + variant="danger" + onConfirm={() => runAction(pending)} + /> ) } -function TenantRow({ tenant, onChange }: { tenant: Tenant; onChange: () => void | Promise }) { - const arcadia = useArcadiaClient() - const [busy, setBusy] = useState(false) +function statusTone(status: TenantStatus): BadgeTone { + if (status === "active") return "success" + if (status === "suspended") return "warning" + if (status === "deactivated") return "danger" + return "default" +} - const act = async (fn: () => Promise) => { - setBusy(true) - try { - await fn() - await onChange() - } finally { - setBusy(false) - } - } - - return ( - - {tenant.name} - - {tenant.slug} - - - - - - {tenant.plan?.name ?? "—"} - - - {formatDate(tenant.inserted_at)} - - - - - - - - {tenant.status === "active" ? ( - act(() => suspendTenant(arcadia, tenant.id))}> - - Suspend - - ) : ( - act(() => activateTenant(arcadia, tenant.id))}> - - Activate - - )} - - act(() => deactivateTenant(arcadia, tenant.id))}> - Deactivate - - - - - - ) -} - -function StatusBadge({ status }: { status: TenantStatus }) { - const variant: "default" | "secondary" | "destructive" | "outline" = - status === "active" ? "default" : status === "suspended" ? "secondary" : "outline" - return ( - - {status} - - ) -} - -function formatDate(iso: string): string { - try { - return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }) - } catch { - return iso +function rowActions( + t: Tenant, + arcadia: ReturnType, + refresh: () => Promise, + setPending: (p: PendingAction) => void, + setError: (msg: string | null) => void, +): ActionItem[] { + const items: ActionItem[] = [] + if (t.status === "active") { + items.push({ + id: "suspend", + label: "Suspend", + icon: , + onSelect: () => setPending({ kind: "suspend", tenant: t }), + }) + } else { + items.push({ + id: "activate", + label: "Activate", + icon: , + onSelect: async () => { + try { + await activateTenant(arcadia, t.id) + await refresh() + } catch (err) { + setError(err instanceof ArcadiaError ? err.message : "Activate failed.") + } + }, + }) } + items.push({ + id: "deactivate", + label: "Deactivate", + destructive: true, + onSelect: () => setPending({ kind: "deactivate", tenant: t }), + }) + return items } diff --git a/tsconfig.json b/tsconfig.json index f54f74e..05ca354 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,14 @@ "@crema/arcadia-client/*": ["../lib-arcadia-client/src/*"], "@crema/arcadia-auth-ui": ["../lib-arcadia-auth-ui/src/index.tsx"], "@crema/arcadia-auth-ui/*": ["../lib-arcadia-auth-ui/src/*"], + "@crema/table-ui": ["../lib-table-ui/src/index.tsx"], + "@crema/table-ui/*": ["../lib-table-ui/src/*"], + "@crema/search-ui": ["../lib-search-ui/src/index.tsx"], + "@crema/search-ui/*": ["../lib-search-ui/src/*"], + "@crema/feedback-ui": ["../lib-feedback-ui/src/index.tsx"], + "@crema/feedback-ui/*": ["../lib-feedback-ui/src/*"], + "@crema/auth-ui": ["../lib-auth-ui/src/index.tsx"], + "@crema/auth-ui/*": ["../lib-auth-ui/src/*"], "// CREMA:PATHS": [""], "react": ["./node_modules/@types/react"], "react/*": ["./node_modules/@types/react/*"],