From d2f6b02a5f2654705fdb972c06026acc91ed63cc Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:56:33 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20vendor=20console=20=E2=80=94=20tenant?= =?UTF-8?q?=20list,=20create=20wizard,=20detail=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Task 9: shared components (ServerStatusBadge, UsageIndicator, platform.module.css, tierColor utility) and full vendor console pages (VendorTenantsPage, CreateTenantPage, TenantDetailPage). Build passes cleanly. Co-Authored-By: Claude Sonnet 4.6 --- ui/src/components/ServerStatusBadge.tsx | 17 ++ ui/src/components/UsageIndicator.tsx | 27 +++ ui/src/pages/vendor/CreateTenantPage.tsx | 110 ++++++++- ui/src/pages/vendor/TenantDetailPage.tsx | 257 +++++++++++++++++++++- ui/src/pages/vendor/VendorTenantsPage.tsx | 113 +++++++++- ui/src/styles/platform.module.css | 75 +++++-- ui/src/utils/tier.ts | 23 +- 7 files changed, 586 insertions(+), 36 deletions(-) create mode 100644 ui/src/components/ServerStatusBadge.tsx create mode 100644 ui/src/components/UsageIndicator.tsx diff --git a/ui/src/components/ServerStatusBadge.tsx b/ui/src/components/ServerStatusBadge.tsx new file mode 100644 index 0000000..4f3056b --- /dev/null +++ b/ui/src/components/ServerStatusBadge.tsx @@ -0,0 +1,17 @@ +import { Badge } from '@cameleer/design-system'; + +interface Props { + state: string; +} + +const config: Record = { + RUNNING: { color: 'success', label: 'Running' }, + STOPPED: { color: 'error', label: 'Stopped' }, + NOT_FOUND: { color: 'auto', label: 'No Server' }, + ERROR: { color: 'error', label: 'Error' }, +}; + +export function ServerStatusBadge({ state }: Props) { + const c = config[state] ?? { color: 'auto' as const, label: state }; + return ; +} diff --git a/ui/src/components/UsageIndicator.tsx b/ui/src/components/UsageIndicator.tsx new file mode 100644 index 0000000..c62f32d --- /dev/null +++ b/ui/src/components/UsageIndicator.tsx @@ -0,0 +1,27 @@ +import styles from '../styles/platform.module.css'; + +interface Props { + used: number; + limit: number; + label: string; +} + +export function UsageIndicator({ used, limit, label }: Props) { + const unlimited = limit < 0; + const pct = unlimited ? 0 : Math.min((used / limit) * 100, 100); + const color = pct >= 100 ? 'var(--error)' : pct >= 80 ? 'var(--warning)' : 'var(--success)'; + + return ( +
+
+ {label} + {used} / {unlimited ? '\u221e' : limit} +
+ {!unlimited && ( +
+
+
+ )} +
+ ); +} diff --git a/ui/src/pages/vendor/CreateTenantPage.tsx b/ui/src/pages/vendor/CreateTenantPage.tsx index 4dfa9d1..e7f1133 100644 --- a/ui/src/pages/vendor/CreateTenantPage.tsx +++ b/ui/src/pages/vendor/CreateTenantPage.tsx @@ -1 +1,109 @@ -export function CreateTenantPage() { return
CreateTenantPage (TODO)
; } +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router'; +import { Button, Card, FormField, Input, useToast } from '@cameleer/design-system'; +import { useCreateTenant } from '../../api/vendor-hooks'; +import { toSlug } from '../../utils/slug'; + +const TIERS = ['STARTER', 'PROFESSIONAL', 'ENTERPRISE']; + +export function CreateTenantPage() { + const navigate = useNavigate(); + const { toast } = useToast(); + const createTenant = useCreateTenant(); + + const [name, setName] = useState(''); + const [slug, setSlug] = useState(''); + const [slugTouched, setSlugTouched] = useState(false); + const [tier, setTier] = useState('STARTER'); + + useEffect(() => { + if (!slugTouched) { + setSlug(toSlug(name)); + } + }, [name, slugTouched]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + try { + const result = await createTenant.mutateAsync({ name, slug, tier }); + toast({ title: 'Tenant created', variant: 'success' }); + navigate(`/vendor/tenants/${result.id}`); + } catch (err) { + toast({ title: 'Failed to create tenant', description: String(err), variant: 'error' }); + } + } + + return ( +
+
+

Create Tenant

+
+ + +
+ + setName(e.target.value)} + placeholder="Acme Corp" + required + /> + + + + { + setSlug(e.target.value); + setSlugTouched(true); + }} + placeholder="acme-corp" + required + /> + + + + + + +
+ + +
+
+
+
+ ); +} diff --git a/ui/src/pages/vendor/TenantDetailPage.tsx b/ui/src/pages/vendor/TenantDetailPage.tsx index 1f623b3..bf0e9ed 100644 --- a/ui/src/pages/vendor/TenantDetailPage.tsx +++ b/ui/src/pages/vendor/TenantDetailPage.tsx @@ -1 +1,256 @@ -export function TenantDetailPage() { return
TenantDetailPage (TODO)
; } +import { useState } from 'react'; +import { useParams, useNavigate } from 'react-router'; +import { + AlertDialog, + Badge, + Button, + Card, + KpiStrip, + Spinner, + useToast, +} from '@cameleer/design-system'; +import { ArrowLeft, RefreshCw, Trash2 } from 'lucide-react'; +import { + useVendorTenant, + useSuspendTenant, + useActivateTenant, + useDeleteTenant, + useRenewLicense, +} from '../../api/vendor-hooks'; +import { ServerStatusBadge } from '../../components/ServerStatusBadge'; +import { tierColor } from '../../utils/tier'; +import styles from '../../styles/platform.module.css'; + +function licenseDaysRemaining(expiresAt: string | null | undefined): number { + if (!expiresAt) return 0; + return Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 86_400_000); +} + +function statusColor(status: string): 'success' | 'error' | 'warning' | 'auto' { + switch (status?.toUpperCase()) { + case 'ACTIVE': return 'success'; + case 'SUSPENDED': return 'warning'; + case 'PROVISIONING': return 'auto'; + case 'ERROR': return 'error'; + default: return 'auto'; + } +} + +export function TenantDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { toast } = useToast(); + + const { data, isLoading } = useVendorTenant(id ?? null); + const suspendTenant = useSuspendTenant(); + const activateTenant = useActivateTenant(); + const deleteTenant = useDeleteTenant(); + const renewLicense = useRenewLicense(); + + const [deleteOpen, setDeleteOpen] = useState(false); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!data) { + return ( +
+

Tenant not found.

+
+ ); + } + + const { tenant, serverState, license } = data; + const daysRemaining = licenseDaysRemaining(license?.expiresAt); + const isSuspended = tenant.status?.toUpperCase() === 'SUSPENDED'; + + async function handleSuspendToggle() { + if (!id) return; + try { + if (isSuspended) { + await activateTenant.mutateAsync(id); + toast({ title: 'Tenant activated', variant: 'success' }); + } else { + await suspendTenant.mutateAsync(id); + toast({ title: 'Tenant suspended', variant: 'warning' }); + } + } catch (err) { + toast({ title: 'Action failed', description: String(err), variant: 'error' }); + } + } + + async function handleDelete() { + if (!id) return; + try { + await deleteTenant.mutateAsync(id); + toast({ title: 'Tenant deleted', variant: 'success' }); + navigate('/vendor/tenants'); + } catch (err) { + toast({ title: 'Delete failed', description: String(err), variant: 'error' }); + } + } + + async function handleRenewLicense() { + if (!id) return; + try { + await renewLicense.mutateAsync(id); + toast({ title: 'License renewed', variant: 'success' }); + } catch (err) { + toast({ title: 'Renewal failed', description: String(err), variant: 'error' }); + } + } + + return ( +
+ {/* Header */} +
+ +

{tenant.name}

+ + +
+ + {/* KPI Strip */} + + + {/* Cards grid */} +
+ {/* Server Info */} + +
+
+ State + +
+
+ Endpoint + {tenant.serverEndpoint ?? '—'} +
+ {tenant.provisionError && ( +
+ Error + + {tenant.provisionError} + +
+ )} +
+
+ + {/* License Info */} + + {license ? ( +
+
+ Tier + +
+
+ Expires + + {new Date(license.expiresAt).toLocaleDateString()} + +
+
+ Days remaining + + {daysRemaining}d + +
+
+ +
+
+ ) : ( +
+

No license issued.

+ +
+ )} +
+ + {/* Tenant Info */} + +
+
+ ID + {tenant.id} +
+
+ Slug + {tenant.slug} +
+
+ Created + {new Date(tenant.createdAt).toLocaleDateString()} +
+
+ Updated + {new Date(tenant.updatedAt).toLocaleDateString()} +
+
+
+ + {/* Actions */} + +
+ + +
+
+
+ + {/* Delete confirmation dialog */} + setDeleteOpen(false)} + onConfirm={handleDelete} + title="Delete Tenant" + description={`Are you sure you want to delete "${tenant.name}"? This action cannot be undone.`} + confirmLabel="Delete" + cancelLabel="Cancel" + variant="danger" + loading={deleteTenant.isPending} + /> +
+ ); +} diff --git a/ui/src/pages/vendor/VendorTenantsPage.tsx b/ui/src/pages/vendor/VendorTenantsPage.tsx index 5856667..9164d9a 100644 --- a/ui/src/pages/vendor/VendorTenantsPage.tsx +++ b/ui/src/pages/vendor/VendorTenantsPage.tsx @@ -1 +1,112 @@ -export function VendorTenantsPage() { return
VendorTenantsPage (TODO)
; } +import { useNavigate } from 'react-router'; +import { Badge, Button, DataTable, EmptyState, Spinner } from '@cameleer/design-system'; +import type { Column } from '@cameleer/design-system'; +import { Plus, Building } from 'lucide-react'; +import { useVendorTenants } from '../../api/vendor-hooks'; +import { ServerStatusBadge } from '../../components/ServerStatusBadge'; +import { tierColor } from '../../utils/tier'; +import type { VendorTenantSummary } from '../../types/api'; + +function licenseDaysLabel(expiry: string | null): string { + if (!expiry) return 'None'; + const diff = Math.ceil((new Date(expiry).getTime() - Date.now()) / 86_400_000); + if (diff < 0) return 'Expired'; + return `${diff}d`; +} + +function statusColor(status: string): 'success' | 'error' | 'warning' | 'auto' { + switch (status?.toUpperCase()) { + case 'ACTIVE': return 'success'; + case 'SUSPENDED': return 'warning'; + case 'PROVISIONING': return 'auto'; + case 'ERROR': return 'error'; + default: return 'auto'; + } +} + +const columns: Column[] = [ + { + key: 'name', + header: 'Name', + render: (_v, row) => row.name, + }, + { + key: 'slug', + header: 'Slug', + render: (_v, row) => ( + {row.slug} + ), + }, + { + key: 'tier', + header: 'Tier', + render: (_v, row) => , + }, + { + key: 'status', + header: 'Status', + render: (_v, row) => , + }, + { + key: 'serverState', + header: 'Server', + render: (_v, row) => , + }, + { + key: 'licenseExpiry', + header: 'License', + render: (_v, row) => { + const label = licenseDaysLabel(row.licenseExpiry); + const color = label === 'Expired' ? 'error' : label === 'None' ? 'auto' : 'auto'; + return ; + }, + }, +]; + +export function VendorTenantsPage() { + const navigate = useNavigate(); + const { data: tenants, isLoading } = useVendorTenants(); + + return ( +
+
+

Tenants

+ +
+ + {isLoading && ( +
+ +
+ )} + + {!isLoading && (!tenants || tenants.length === 0) && ( + } + title="No tenants yet" + description="Create your first tenant to get started." + action={ + + } + /> + )} + + {!isLoading && tenants && tenants.length > 0 && ( + navigate(`/vendor/tenants/${row.id}`)} + /> + )} +
+ ); +} diff --git a/ui/src/styles/platform.module.css b/ui/src/styles/platform.module.css index ed61388..76f8746 100644 --- a/ui/src/styles/platform.module.css +++ b/ui/src/styles/platform.module.css @@ -1,20 +1,63 @@ -.heading { font-size: 1.5rem; font-weight: 600; color: var(--text-primary); } -.textPrimary { color: var(--text-primary); } -.textMuted { color: var(--text-muted); } -.mono { font-family: var(--font-mono); } +/* Platform shared styles */ -.kvRow { display: flex; align-items: center; justify-content: space-between; width: 100%; } -.kvLabel { font-size: 0.875rem; color: var(--text-muted); } -.kvValue { font-size: 0.875rem; color: var(--text-primary); } -.kvValueMono { font-size: 0.875rem; color: var(--text-primary); font-family: var(--font-mono); } +.heading { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} -.dividerList { display: flex; flex-direction: column; } -.dividerList > * + * { border-top: 1px solid var(--border-subtle); } -.dividerRow { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 0; } -.dividerRow:first-child { padding-top: 0; } -.dividerRow:last-child { padding-bottom: 0; } +.kvRow { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 2px 0; +} -.description { font-size: 0.875rem; color: var(--text-muted); } +.kvLabel { + font-size: 0.8125rem; + color: var(--text-secondary); + white-space: nowrap; +} -.tokenBlock { margin-top: 0.5rem; border-radius: var(--radius-sm); background: var(--bg-inset); border: 1px solid var(--border-subtle); padding: 0.75rem; overflow-x: auto; } -.tokenCode { font-size: 0.75rem; font-family: var(--font-mono); color: var(--text-secondary); word-break: break-all; } +.kvValue { + font-size: 0.8125rem; + color: var(--text-primary); + text-align: right; +} + +.kvValueMono { + font-size: 0.8125rem; + color: var(--text-primary); + font-family: var(--font-mono, monospace); + text-align: right; +} + +.dividerList { + display: flex; + flex-direction: column; + gap: 6px; +} + +.description { + font-size: 0.875rem; + color: var(--text-secondary); + line-height: 1.5; +} + +.tokenBlock { + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 6px; + padding: 10px 12px; + position: relative; +} + +.tokenCode { + font-family: var(--font-mono, monospace); + font-size: 0.8125rem; + color: var(--text-primary); + word-break: break-all; + white-space: pre-wrap; +} diff --git a/ui/src/utils/tier.ts b/ui/src/utils/tier.ts index 13c02b7..f5274f4 100644 --- a/ui/src/utils/tier.ts +++ b/ui/src/utils/tier.ts @@ -1,20 +1,9 @@ -export type TierColor = 'primary' | 'success' | 'warning' | 'error' | 'auto'; - -export function tierColor(tier: string): TierColor { +/** Map a tier name to a Badge color. */ +export function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto' { switch (tier?.toUpperCase()) { - case 'BUSINESS': - case 'ENTERPRISE': - return 'success'; - case 'HIGH': - case 'PRO': - return 'primary'; - case 'MID': - case 'STARTER': - return 'warning'; - case 'LOW': - case 'FREE': - return 'auto'; - default: - return 'auto'; + case 'ENTERPRISE': return 'success'; + case 'PROFESSIONAL': return 'primary'; + case 'STARTER': return 'warning'; + default: return 'auto'; } }