feat: vendor console — tenant list, create wizard, detail page

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 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-09 21:56:33 +02:00
parent bf3aa57274
commit d2f6b02a5f
7 changed files with 586 additions and 36 deletions

View File

@@ -0,0 +1,17 @@
import { Badge } from '@cameleer/design-system';
interface Props {
state: string;
}
const config: Record<string, { color: 'success' | 'error' | 'warning' | 'auto'; label: string }> = {
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 <Badge color={c.color} label={c.label} />;
}

View File

@@ -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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>{label}</span>
<span className={styles.kvValue}>{used} / {unlimited ? '\u221e' : limit}</span>
</div>
{!unlimited && (
<div style={{ height: 4, borderRadius: 2, background: 'var(--bg-inset)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', borderRadius: 2, background: color, transition: 'width 0.3s' }} />
</div>
)}
</div>
);
}

View File

@@ -1 +1,109 @@
export function CreateTenantPage() { return <div>CreateTenantPage (TODO)</div>; } 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 (
<div style={{ padding: '24px', maxWidth: 560 }}>
<div style={{ marginBottom: 20 }}>
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>Create Tenant</h1>
</div>
<Card title="Tenant details">
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: '8px 0' }}>
<FormField label="Name" htmlFor="tenant-name" required>
<Input
id="tenant-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Acme Corp"
required
/>
</FormField>
<FormField label="Slug" htmlFor="tenant-slug" required hint="URL-safe identifier, auto-generated from name">
<Input
id="tenant-slug"
value={slug}
onChange={(e) => {
setSlug(e.target.value);
setSlugTouched(true);
}}
placeholder="acme-corp"
required
/>
</FormField>
<FormField label="Tier" htmlFor="tenant-tier" required>
<select
id="tenant-tier"
value={tier}
onChange={(e) => setTier(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid var(--border)',
borderRadius: 6,
background: 'var(--bg-surface)',
color: 'var(--text-primary)',
fontSize: '0.875rem',
}}
>
{TIERS.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</FormField>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 8 }}>
<Button
type="button"
variant="ghost"
onClick={() => navigate('/vendor/tenants')}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
loading={createTenant.isPending}
disabled={!name || !slug}
>
Create Tenant
</Button>
</div>
</form>
</Card>
</div>
);
}

View File

@@ -1 +1,256 @@
export function TenantDetailPage() { return <div>TenantDetailPage (TODO)</div>; } 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 (
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
<Spinner />
</div>
);
}
if (!data) {
return (
<div style={{ padding: 24 }}>
<p className={styles.description}>Tenant not found.</p>
</div>
);
}
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 (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Button variant="ghost" onClick={() => navigate('/vendor/tenants')} style={{ padding: '4px 8px' }}>
<ArrowLeft size={16} />
</Button>
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>{tenant.name}</h1>
<Badge label={tenant.tier} color={tierColor(tenant.tier)} />
<Badge label={tenant.status} color={statusColor(tenant.status)} />
</div>
{/* KPI Strip */}
<KpiStrip
items={[
{ label: 'Server', value: serverState },
{ label: 'Tier', value: tenant.tier },
{ label: 'Status', value: tenant.status },
{ label: 'License days', value: license ? `${daysRemaining}d` : 'None' },
]}
/>
{/* Cards grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 16 }}>
{/* Server Info */}
<Card title="Server">
<div className={styles.dividerList}>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>State</span>
<ServerStatusBadge state={serverState} />
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Endpoint</span>
<span className={styles.kvValueMono}>{tenant.serverEndpoint ?? '—'}</span>
</div>
{tenant.provisionError && (
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Error</span>
<span className={styles.kvValue} style={{ color: 'var(--error)', maxWidth: 200, textAlign: 'right' }}>
{tenant.provisionError}
</span>
</div>
)}
</div>
</Card>
{/* License Info */}
<Card title="License">
{license ? (
<div className={styles.dividerList}>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Tier</span>
<Badge label={license.tier} color={tierColor(license.tier)} />
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Expires</span>
<span className={styles.kvValue}>
{new Date(license.expiresAt).toLocaleDateString()}
</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Days remaining</span>
<span className={styles.kvValue} style={{ color: daysRemaining <= 30 ? 'var(--warning)' : undefined }}>
{daysRemaining}d
</span>
</div>
<div style={{ paddingTop: 8 }}>
<Button
variant="secondary"
onClick={handleRenewLicense}
loading={renewLicense.isPending}
>
<RefreshCw size={14} style={{ marginRight: 6 }} />
Renew License
</Button>
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<p className={styles.description}>No license issued.</p>
<Button
variant="secondary"
onClick={handleRenewLicense}
loading={renewLicense.isPending}
>
Issue License
</Button>
</div>
)}
</Card>
{/* Tenant Info */}
<Card title="Tenant Info">
<div className={styles.dividerList}>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>ID</span>
<span className={styles.kvValueMono}>{tenant.id}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Slug</span>
<span className={styles.kvValueMono}>{tenant.slug}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Created</span>
<span className={styles.kvValue}>{new Date(tenant.createdAt).toLocaleDateString()}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Updated</span>
<span className={styles.kvValue}>{new Date(tenant.updatedAt).toLocaleDateString()}</span>
</div>
</div>
</Card>
{/* Actions */}
<Card title="Actions">
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<Button
variant="secondary"
onClick={handleSuspendToggle}
loading={suspendTenant.isPending || activateTenant.isPending}
>
{isSuspended ? 'Activate Tenant' : 'Suspend Tenant'}
</Button>
<Button
variant="danger"
onClick={() => setDeleteOpen(true)}
>
<Trash2 size={14} style={{ marginRight: 6 }} />
Delete Tenant
</Button>
</div>
</Card>
</div>
{/* Delete confirmation dialog */}
<AlertDialog
open={deleteOpen}
onClose={() => 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}
/>
</div>
);
}

View File

@@ -1 +1,112 @@
export function VendorTenantsPage() { return <div>VendorTenantsPage (TODO)</div>; } 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<VendorTenantSummary>[] = [
{
key: 'name',
header: 'Name',
render: (_v, row) => row.name,
},
{
key: 'slug',
header: 'Slug',
render: (_v, row) => (
<span style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}>{row.slug}</span>
),
},
{
key: 'tier',
header: 'Tier',
render: (_v, row) => <Badge label={row.tier} color={tierColor(row.tier)} />,
},
{
key: 'status',
header: 'Status',
render: (_v, row) => <Badge label={row.status} color={statusColor(row.status)} />,
},
{
key: 'serverState',
header: 'Server',
render: (_v, row) => <ServerStatusBadge state={row.serverState} />,
},
{
key: 'licenseExpiry',
header: 'License',
render: (_v, row) => {
const label = licenseDaysLabel(row.licenseExpiry);
const color = label === 'Expired' ? 'error' : label === 'None' ? 'auto' : 'auto';
return <Badge label={label} color={color} />;
},
},
];
export function VendorTenantsPage() {
const navigate = useNavigate();
const { data: tenants, isLoading } = useVendorTenants();
return (
<div style={{ padding: '24px', display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>Tenants</h1>
<Button
variant="primary"
onClick={() => navigate('/vendor/tenants/new')}
>
<Plus size={16} style={{ marginRight: 6 }} />
Create Tenant
</Button>
</div>
{isLoading && (
<div style={{ display: 'flex', justifyContent: 'center', padding: 48 }}>
<Spinner />
</div>
)}
{!isLoading && (!tenants || tenants.length === 0) && (
<EmptyState
icon={<Building size={32} />}
title="No tenants yet"
description="Create your first tenant to get started."
action={
<Button variant="primary" onClick={() => navigate('/vendor/tenants/new')}>
<Plus size={16} style={{ marginRight: 6 }} />
Create Tenant
</Button>
}
/>
)}
{!isLoading && tenants && tenants.length > 0 && (
<DataTable
columns={columns}
data={tenants}
onRowClick={(row) => navigate(`/vendor/tenants/${row.id}`)}
/>
)}
</div>
);
}

View File

@@ -1,20 +1,63 @@
.heading { font-size: 1.5rem; font-weight: 600; color: var(--text-primary); } /* Platform shared styles */
.textPrimary { color: var(--text-primary); }
.textMuted { color: var(--text-muted); }
.mono { font-family: var(--font-mono); }
.kvRow { display: flex; align-items: center; justify-content: space-between; width: 100%; } .heading {
.kvLabel { font-size: 0.875rem; color: var(--text-muted); } font-size: 1.25rem;
.kvValue { font-size: 0.875rem; color: var(--text-primary); } font-weight: 600;
.kvValueMono { font-size: 0.875rem; color: var(--text-primary); font-family: var(--font-mono); } color: var(--text-primary);
margin: 0;
}
.dividerList { display: flex; flex-direction: column; } .kvRow {
.dividerList > * + * { border-top: 1px solid var(--border-subtle); } display: flex;
.dividerRow { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 0; } justify-content: space-between;
.dividerRow:first-child { padding-top: 0; } align-items: center;
.dividerRow:last-child { padding-bottom: 0; } 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; } .kvValue {
.tokenCode { font-size: 0.75rem; font-family: var(--font-mono); color: var(--text-secondary); word-break: break-all; } 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;
}

View File

@@ -1,20 +1,9 @@
export type TierColor = 'primary' | 'success' | 'warning' | 'error' | 'auto'; /** Map a tier name to a Badge color. */
export function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto' {
export function tierColor(tier: string): TierColor {
switch (tier?.toUpperCase()) { switch (tier?.toUpperCase()) {
case 'BUSINESS': case 'ENTERPRISE': return 'success';
case 'ENTERPRISE': case 'PROFESSIONAL': return 'primary';
return 'success'; case 'STARTER': return 'warning';
case 'HIGH': default: return 'auto';
case 'PRO':
return 'primary';
case 'MID':
case 'STARTER':
return 'warning';
case 'LOW':
case 'FREE':
return 'auto';
default:
return 'auto';
} }
} }