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>
257 lines
8.7 KiB
TypeScript
257 lines
8.7 KiB
TypeScript
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>
|
|
);
|
|
}
|