Files
pn-new-crm/src/components/admin/ai-budget-card.tsx
Matt 4233aa3ac3 fix(audit-wave-9): standardize on Sheet for previews; doctrine in CLAUDE.md
Swap the one outlier (client-interests-tab.tsx) from Vaul Drawer to
Sheet side=right so every detail-preview surface uses the same
primitive. Document the doctrine: Sheet for side panels on both desktop
and mobile; Vaul Drawer reserved for mobile-only bottom-sheet UX
(currently just MoreSheet).

Closes ui/ux M11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:50:07 +02:00

209 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { apiFetch } from '@/lib/api/client';
type Period = 'day' | 'week' | 'month';
interface BudgetResp {
data: {
budget: { enabled: boolean; softCapTokens: number; hardCapTokens: number; period: Period };
used: number;
breakdown: Array<{ feature: string; tokens: number; calls: number }>;
};
}
function formatNum(n: number): string {
return n.toLocaleString();
}
export function AiBudgetCard() {
const qc = useQueryClient();
const queryKey = ['admin-ai-budget'];
const { data, isLoading } = useQuery<BudgetResp>({
queryKey,
queryFn: () => apiFetch<BudgetResp>('/api/v1/admin/ai-budget'),
});
// Key-based remount: the form body is keyed on the loaded payload
// signature so its useState initializers seed from server data on
// first load. Replaces the prior useEffect(setState, [data]) sync.
const sig = data?.data
? `${data.data.budget.enabled}:${data.data.budget.softCapTokens}:${data.data.budget.hardCapTokens}:${data.data.budget.period}`
: 'loading';
return (
<AiBudgetCardBody key={sig} data={data} isLoading={isLoading} qc={qc} queryKey={queryKey} />
);
}
function AiBudgetCardBody({
data,
isLoading,
qc,
queryKey,
}: {
data: BudgetResp | undefined;
isLoading: boolean;
qc: ReturnType<typeof useQueryClient>;
queryKey: string[];
}) {
const [enabled, setEnabled] = useState(data?.data.budget.enabled ?? false);
const [softCap, setSoftCap] = useState(data ? String(data.data.budget.softCapTokens) : '100000');
const [hardCap, setHardCap] = useState(data ? String(data.data.budget.hardCapTokens) : '500000');
const [period, setPeriod] = useState<Period>(data?.data.budget.period ?? 'month');
const save = useMutation({
mutationFn: () =>
apiFetch('/api/v1/admin/ai-budget', {
method: 'PUT',
body: {
enabled,
softCapTokens: Number.parseInt(softCap || '0', 10),
hardCapTokens: Number.parseInt(hardCap || '0', 10),
period,
},
}),
onSuccess: () => qc.invalidateQueries({ queryKey }),
});
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>AI cost guardrails</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading
</CardContent>
</Card>
);
}
const used = data?.data.used ?? 0;
const hard = data?.data.budget.hardCapTokens ?? 0;
const soft = data?.data.budget.softCapTokens ?? 0;
const pctOfHard = hard > 0 ? Math.min(100, Math.round((used / hard) * 100)) : 0;
const breakdown = data?.data.breakdown ?? [];
return (
<Card>
<CardHeader>
<CardTitle>AI cost guardrails</CardTitle>
<p className="text-sm text-muted-foreground">
Cap how many AI tokens this port can spend per period. The hard cap blocks new calls; the
soft cap surfaces a warning banner. Tokens are the unit both OpenAI and Anthropic bill on,
so the cap survives model price changes.
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border bg-muted/30 p-3 space-y-2">
<div className="flex items-baseline justify-between text-sm">
<span className="font-medium">
This {period}: {formatNum(used)} tokens
</span>
<span className="text-muted-foreground">
soft {formatNum(soft)} · hard {formatNum(hard)}
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className={`h-full transition-all ${
used >= hard ? 'bg-destructive' : used >= soft ? 'bg-amber-500' : 'bg-emerald-500'
}`}
style={{ width: `${pctOfHard}%` }}
/>
</div>
{breakdown.length > 0 ? (
<ul className="text-xs text-muted-foreground space-y-0.5 pt-1">
{breakdown.map((b) => (
<li key={b.feature} className="flex justify-between">
<span className="capitalize">{b.feature.replace(/_/g, ' ')}</span>
<span>
{formatNum(b.tokens)} tokens · {b.calls} call{b.calls === 1 ? '' : 's'}
</span>
</li>
))}
</ul>
) : null}
</div>
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
<Checkbox
id="ai-budget-enabled"
checked={enabled}
onCheckedChange={(v) => setEnabled(v === true)}
/>
<div className="space-y-0.5">
<Label htmlFor="ai-budget-enabled" className="text-sm font-medium">
Enforce token caps for this port
</Label>
<p className="text-xs text-muted-foreground">
When off, usage is still recorded for visibility but no requests are blocked.
</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor="period">Period</Label>
<Select value={period} onValueChange={(v) => setPeriod(v as Period)}>
<SelectTrigger id="period">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">Day (UTC)</SelectItem>
<SelectItem value="week">Week (MonSun UTC)</SelectItem>
<SelectItem value="month">Calendar month (UTC)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="soft-cap">Soft cap (tokens)</Label>
<Input
id="soft-cap"
type="number"
min="0"
value={softCap}
onChange={(e) => setSoftCap(e.target.value)}
disabled={!enabled}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="hard-cap">Hard cap (tokens)</Label>
<Input
id="hard-cap"
type="number"
min="0"
value={hardCap}
onChange={(e) => setHardCap(e.target.value)}
disabled={!enabled}
/>
</div>
</div>
<div className="flex gap-2">
<Button onClick={() => save.mutate()} disabled={save.isPending}>
{save.isPending ? <Loader2 className="mr-1.5 h-3 w-3 animate-spin" /> : null}
Save guardrails
</Button>
</div>
</CardContent>
</Card>
);
}