feat(CC-061): add Approval Queue page (pending AI actions with approve/reject flow)
This commit is contained in:
parent
493b90c5e1
commit
255145fe8f
1 changed files with 168 additions and 0 deletions
168
src/app/dashboard/ai/approvals/page.tsx
Normal file
168
src/app/dashboard/ai/approvals/page.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../../../lib/api';
|
||||
import { useSession } from '../../../../components/session-provider';
|
||||
|
||||
interface AiRequest {
|
||||
id: string;
|
||||
purpose: string;
|
||||
actionClass: string;
|
||||
model: string;
|
||||
templateVersion: string | null;
|
||||
resultStatus: string;
|
||||
costUsd: string | null;
|
||||
contextManifestHash: string;
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
const RISK_BY_CLASS: Record<string, { label: string; cls: string }> = {
|
||||
read: { label: 'Citire', cls: 'bg-sky-500/10 text-sky-700 dark:text-sky-300' },
|
||||
write: { label: 'Scriere', cls: 'bg-amber-500/10 text-amber-700 dark:text-amber-300' },
|
||||
send: { label: 'Trimitere', cls: 'bg-orange-500/10 text-orange-700 dark:text-orange-300' },
|
||||
delete: { label: 'Ștergere', cls: 'bg-signal-danger/10 text-signal-danger' },
|
||||
high_risk: { label: 'Risc înalt', cls: 'bg-signal-danger/10 text-signal-danger font-bold' },
|
||||
};
|
||||
|
||||
export default function ApprovalQueuePage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [rejecting, setRejecting] = useState<string | null>(null);
|
||||
|
||||
const { data: pending = [], isLoading } = useQuery({
|
||||
queryKey: ['ai-approvals', tenantId],
|
||||
queryFn: () => apiFetch<AiRequest[]>('/v1/ai-requests?status=pending&limit=100', { tenantId }),
|
||||
enabled: Boolean(tenantId),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const { mutate: updateStatus, isPending: isUpdating } = useMutation({
|
||||
mutationFn: ({ id, resultStatus }: { id: string; resultStatus: 'completed' | 'cancelled' }) =>
|
||||
apiFetch(`/v1/ai-requests/${id}/status`, { method: 'PATCH', body: { resultStatus }, tenantId }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['ai-approvals', tenantId] }),
|
||||
});
|
||||
|
||||
const approve = (id: string) => updateStatus({ id, resultStatus: 'completed' });
|
||||
const reject = (id: string) => { updateStatus({ id, resultStatus: 'cancelled' }); setRejecting(null); };
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Coadă de Aprobare</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
Acțiuni AI care necesită autorizare înainte de execuție
|
||||
</p>
|
||||
</div>
|
||||
{pending.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5 bg-signal-warn/10 text-signal-warn text-sm font-semibold px-3 py-1 rounded-full">
|
||||
<span className="w-2 h-2 rounded-full bg-signal-warn animate-pulse" />
|
||||
{pending.length} în așteptare
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="card p-12 text-center text-sm text-ink-faint">Se încarcă…</div>
|
||||
) : pending.length === 0 ? (
|
||||
<div className="card p-12 text-center space-y-3">
|
||||
<p className="text-4xl">✅</p>
|
||||
<p className="text-sm font-medium text-ink">Coada este goală</p>
|
||||
<p className="text-xs text-ink-faint">
|
||||
Nicio acțiune AI nu este în așteptare de aprobare.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{pending.map((req) => {
|
||||
const risk = RISK_BY_CLASS[req.actionClass] ?? { label: req.actionClass, cls: 'bg-muted text-ink-faint' };
|
||||
const estimatedCost = req.costUsd ? `$${parseFloat(req.costUsd).toFixed(5)}` : null;
|
||||
const waitingSince = new Date(req.createdAt);
|
||||
const minsWaiting = Math.round((Date.now() - waitingSince.getTime()) / 60_000);
|
||||
const isConfirmingReject = rejecting === req.id;
|
||||
|
||||
return (
|
||||
<div key={req.id} className="rounded-xl border bg-card p-5 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold text-ink">{req.purpose}</p>
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${risk.cls}`}>
|
||||
{risk.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-ink-faint">
|
||||
<span className="font-mono">{req.model.split('/').pop()}</span>
|
||||
{estimatedCost && <span>cuv. estimat {estimatedCost}</span>}
|
||||
{req.templateVersion && <span>template v{req.templateVersion}</span>}
|
||||
<span>în așteptare {minsWaiting > 60 ? `${Math.round(minsWaiting / 60)}h` : `${minsWaiting}m`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context hash */}
|
||||
<div className="rounded-lg bg-muted/30 px-3 py-2 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-ink-faint uppercase tracking-wider mb-0.5">
|
||||
Context Manifest Hash
|
||||
</p>
|
||||
<p className="font-mono text-[10px] text-ink break-all">{req.contextManifestHash}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{isConfirmingReject ? (
|
||||
<div className="flex items-center gap-3 border-t border-border/50 pt-3">
|
||||
<p className="text-xs text-ink-faint flex-1">Confirmi respingerea acestei acțiuni?</p>
|
||||
<button
|
||||
onClick={() => reject(req.id)}
|
||||
disabled={isUpdating}
|
||||
className="text-xs font-medium bg-signal-danger text-white px-3 py-1.5 rounded-lg hover:bg-signal-danger/90 disabled:opacity-50"
|
||||
>
|
||||
Confirma respingerea
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRejecting(null)}
|
||||
className="text-xs text-ink-faint hover:text-ink"
|
||||
>
|
||||
Anulează
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 border-t border-border/50 pt-3">
|
||||
<button
|
||||
onClick={() => approve(req.id)}
|
||||
disabled={isUpdating}
|
||||
className="flex-1 text-xs font-medium bg-signal-ok text-white px-3 py-2 rounded-lg hover:bg-signal-ok/90 disabled:opacity-50"
|
||||
>
|
||||
✓ Aprobă
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRejecting(req.id)}
|
||||
disabled={isUpdating}
|
||||
className="flex-1 text-xs font-medium border border-signal-danger/30 text-signal-danger px-3 py-2 rounded-lg hover:bg-signal-danger/5 disabled:opacity-50"
|
||||
>
|
||||
✗ Respinge
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Explainer */}
|
||||
<div className="rounded-lg border border-border/50 bg-muted/30 p-4">
|
||||
<p className="text-xs text-ink-faint leading-relaxed">
|
||||
<strong className="text-ink">Cum funcționează:</strong> Acțiunile AI cu risc de scriere, trimitere sau ștergere
|
||||
necesită aprobare explicită înainte de execuție. Aprobarea autorizează acțiunea; respingerea o anulează permanent.
|
||||
Pagina se actualizează automat la fiecare 15 secunde.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue