feat(CC-071): add AI Approvals page with approve/reject workflow
This commit is contained in:
parent
faf13320c2
commit
b2587ef629
1 changed files with 144 additions and 121 deletions
|
|
@ -1,168 +1,191 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiFetch } from '../../../../lib/api';
|
import { apiFetch } from '../../../../lib/api';
|
||||||
import { useSession } from '../../../../components/session-provider';
|
import { useSession } from '../../../../components/session-provider';
|
||||||
|
|
||||||
interface AiRequest {
|
interface Approval {
|
||||||
id: string;
|
id: string; tenantId: string; requestType: string; title: string;
|
||||||
purpose: string;
|
description: string | null; requestedBy: string | null;
|
||||||
actionClass: string;
|
status: 'pending' | 'approved' | 'rejected' | 'expired';
|
||||||
model: string;
|
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||||
templateVersion: string | null;
|
expiresAt: string | null; approvedAt: string | null;
|
||||||
resultStatus: string;
|
rejectedAt: string | null; reviewNotes: string | null;
|
||||||
costUsd: string | null;
|
metadata: Record<string, unknown> | null; createdAt: string;
|
||||||
contextManifestHash: string;
|
|
||||||
createdAt: string;
|
|
||||||
completedAt: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const RISK_BY_CLASS: Record<string, { label: string; cls: string }> = {
|
const STATUS_META: Record<string, { label: string; cls: string }> = {
|
||||||
read: { label: 'Citire', cls: 'bg-sky-500/10 text-sky-700 dark:text-sky-300' },
|
pending: { label: 'În așteptare', cls: 'bg-warn/10 text-warn border-warn/30' },
|
||||||
write: { label: 'Scriere', cls: 'bg-amber-500/10 text-amber-700 dark:text-amber-300' },
|
approved: { label: 'Aprobat', cls: 'bg-signal-ok/10 text-signal-ok border-signal-ok/30' },
|
||||||
send: { label: 'Trimitere', cls: 'bg-orange-500/10 text-orange-700 dark:text-orange-300' },
|
rejected: { label: 'Respins', cls: 'bg-signal-danger/10 text-signal-danger border-signal-danger/30' },
|
||||||
delete: { label: 'Ștergere', cls: 'bg-signal-danger/10 text-signal-danger' },
|
expired: { label: 'Expirat', cls: 'bg-muted text-ink-faint border-border' },
|
||||||
high_risk: { label: 'Risc înalt', cls: 'bg-signal-danger/10 text-signal-danger font-bold' },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ApprovalQueuePage() {
|
const PRIORITY_CLS: Record<string, string> = {
|
||||||
|
critical: 'text-signal-danger', high: 'text-warn',
|
||||||
|
medium: 'text-ink', low: 'text-ink-faint',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AIApprovalsPage() {
|
||||||
const { activeTenant } = useSession();
|
const { activeTenant } = useSession();
|
||||||
const tenantId = activeTenant?.tenantId ?? '';
|
const tenantId = activeTenant?.tenantId ?? '';
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [rejecting, setRejecting] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { data: pending = [], isLoading } = useQuery({
|
const [filter, setFilter] = useState<'all' | 'pending' | 'approved' | 'rejected'>('pending');
|
||||||
queryKey: ['ai-approvals', tenantId],
|
const [reviewNotes, setReviewNotes] = useState<Record<string, string>>({});
|
||||||
queryFn: () => apiFetch<AiRequest[]>('/v1/ai-requests?status=pending&limit=100', { tenantId }),
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
enabled: Boolean(tenantId),
|
|
||||||
refetchInterval: 15_000,
|
const { data: approvals = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['approvals', tenantId, filter],
|
||||||
|
queryFn: () => {
|
||||||
|
const q = filter !== 'all' ? `?status=${filter}` : '';
|
||||||
|
return apiFetch<Approval[]>(`/v1/approvals${q}`, { tenantId });
|
||||||
|
},
|
||||||
|
enabled: Boolean(tenantId), staleTime: 15_000, refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutate: updateStatus, isPending: isUpdating } = useMutation({
|
const approveMut = useMutation({
|
||||||
mutationFn: ({ id, resultStatus }: { id: string; resultStatus: 'completed' | 'cancelled' }) =>
|
mutationFn: ({ id, notes }: { id: string; notes: string }) =>
|
||||||
apiFetch(`/v1/ai-requests/${id}/status`, { method: 'PATCH', body: { resultStatus }, tenantId }),
|
apiFetch<Approval>(`/v1/approvals/${id}/approve`, { tenantId, method: 'POST', body: { reviewNotes: notes } }),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['ai-approvals', tenantId] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['approvals', tenantId] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const approve = (id: string) => updateStatus({ id, resultStatus: 'completed' });
|
const rejectMut = useMutation({
|
||||||
const reject = (id: string) => { updateStatus({ id, resultStatus: 'cancelled' }); setRejecting(null); };
|
mutationFn: ({ id, notes }: { id: string; notes: string }) =>
|
||||||
|
apiFetch<Approval>(`/v1/approvals/${id}/reject`, { tenantId, method: 'POST', body: { reviewNotes: notes } }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['approvals', tenantId] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pending = approvals.filter((a) => a.status === 'pending').length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl space-y-6 p-6">
|
<div className="max-w-3xl space-y-6 p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-display text-2xl font-semibold text-ink">Coadă de Aprobare</h1>
|
<h1 className="font-display text-2xl font-semibold text-ink">Aprobări AI</h1>
|
||||||
<p className="text-sm text-ink-faint mt-1">
|
<p className="text-sm text-ink-faint mt-1">
|
||||||
Acțiuni AI care necesită autorizare înainte de execuție
|
Acțiuni generate de AI care necesită confirmare umană.
|
||||||
|
{pending > 0 && <span className="ml-1 font-semibold text-warn">{pending} în așteptare</span>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div className="flex gap-1 border-b border-border/50">
|
||||||
|
{(['pending', 'approved', 'rejected', 'all'] as const).map((s) => (
|
||||||
|
<button key={s} onClick={() => setFilter(s)}
|
||||||
|
className={`pb-2 px-3 text-sm font-medium border-b-2 transition-colors ${filter === s ? 'border-primary text-ink' : 'border-transparent text-ink-faint hover:text-ink'}`}>
|
||||||
|
{s === 'all' ? 'Toate' : STATUS_META[s]?.label ?? s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="card p-12 text-center text-sm text-ink-faint">Se încarcă…</div>
|
<div className="text-center text-sm text-ink-faint py-8">Se încarcă…</div>
|
||||||
) : pending.length === 0 ? (
|
) : approvals.length === 0 ? (
|
||||||
<div className="card p-12 text-center space-y-3">
|
<div className="card p-10 text-center space-y-2">
|
||||||
<p className="text-4xl">✅</p>
|
<p className="text-2xl">{filter === 'pending' ? '✅' : '📋'}</p>
|
||||||
<p className="text-sm font-medium text-ink">Coada este goală</p>
|
<p className="text-sm text-ink-faint">
|
||||||
<p className="text-xs text-ink-faint">
|
{filter === 'pending' ? 'Nicio aprobare în așteptare. Totul e la zi.' : 'Niciun rezultat.'}
|
||||||
Nicio acțiune AI nu este în așteptare de aprobare.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{pending.map((req) => {
|
{approvals.map((a) => {
|
||||||
const risk = RISK_BY_CLASS[req.actionClass] ?? { label: req.actionClass, cls: 'bg-muted text-ink-faint' };
|
const sm = STATUS_META[a.status] ?? STATUS_META.pending;
|
||||||
const estimatedCost = req.costUsd ? `$${parseFloat(req.costUsd).toFixed(5)}` : null;
|
const isExp = expanded === a.id;
|
||||||
const waitingSince = new Date(req.createdAt);
|
const notes = reviewNotes[a.id] ?? '';
|
||||||
const minsWaiting = Math.round((Date.now() - waitingSince.getTime()) / 60_000);
|
const isExpired = a.expiresAt && new Date(a.expiresAt) < new Date();
|
||||||
const isConfirmingReject = rejecting === req.id;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={req.id} className="rounded-xl border bg-card p-5 space-y-4">
|
<div key={a.id} className={`card overflow-hidden ${a.status === 'pending' && isExpired ? 'opacity-60' : ''}`}>
|
||||||
<div className="flex items-start gap-3">
|
<div className="p-4 flex items-start gap-3">
|
||||||
<div className="flex-1 space-y-1">
|
{/* Priority indicator */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className={`w-1.5 self-stretch rounded-full shrink-0 ${a.priority === 'critical' ? 'bg-signal-danger' : a.priority === 'high' ? 'bg-warn' : a.priority === 'medium' ? 'bg-primary/40' : 'bg-muted'}`} />
|
||||||
<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}`}>
|
<div className="flex-1 min-w-0">
|
||||||
{risk.label}
|
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-ink">{a.title}</p>
|
||||||
|
<p className={`text-[10px] font-medium uppercase tracking-wide mt-0.5 ${PRIORITY_CLS[a.priority]}`}>
|
||||||
|
{a.requestType} · {a.priority}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`rounded-full border px-2 py-0.5 text-[10px] font-medium ${sm.cls}`}>
|
||||||
|
{sm.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-3 text-xs text-ink-faint">
|
|
||||||
<span className="font-mono">{req.model.split('/').pop()}</span>
|
{a.description && (
|
||||||
{estimatedCost && <span>cuv. estimat {estimatedCost}</span>}
|
<p className="text-xs text-ink-faint mt-2 line-clamp-2">{a.description}</p>
|
||||||
{req.templateVersion && <span>template v{req.templateVersion}</span>}
|
)}
|
||||||
<span>în așteptare {minsWaiting > 60 ? `${Math.round(minsWaiting / 60)}h` : `${minsWaiting}m`}</span>
|
|
||||||
|
<div className="flex items-center gap-3 mt-2 text-[10px] text-ink-faint">
|
||||||
|
{a.requestedBy && <span>de: {a.requestedBy}</span>}
|
||||||
|
<span>{new Date(a.createdAt).toLocaleDateString('ro-RO')}</span>
|
||||||
|
{a.expiresAt && (
|
||||||
|
<span className={isExpired ? 'text-signal-danger' : 'text-warn'}>
|
||||||
|
{isExpired ? '⚠ expirat' : `expiră ${new Date(a.expiresAt).toLocaleDateString('ro-RO')}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata preview */}
|
||||||
|
{a.metadata && Object.keys(a.metadata).length > 0 && (
|
||||||
|
<button onClick={() => setExpanded(isExp ? null : a.id)}
|
||||||
|
className="text-[10px] text-primary hover:underline mt-1">
|
||||||
|
{isExp ? 'Ascunde detalii ▲' : 'Detalii ▼'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isExp && a.metadata && (
|
||||||
|
<pre className="mt-2 rounded bg-muted p-2 text-[10px] font-mono text-ink-faint overflow-x-auto">
|
||||||
|
{JSON.stringify(a.metadata, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions for pending */}
|
||||||
|
{a.status === 'pending' && !isExpired && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<textarea
|
||||||
|
placeholder="Note opționale pentru aprobare/respingere…"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setReviewNotes({ ...reviewNotes, [a.id]: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded-lg border bg-card px-3 py-2 text-xs text-ink focus:outline-none focus:ring-1 focus:ring-ring resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => approveMut.mutate({ id: a.id, notes })}
|
||||||
|
disabled={approveMut.isPending}
|
||||||
|
className="rounded-lg bg-signal-ok/10 border border-signal-ok/30 px-4 py-1.5 text-xs font-medium text-signal-ok hover:bg-signal-ok/20 disabled:opacity-50">
|
||||||
|
✅ Aprobă
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => rejectMut.mutate({ id: a.id, notes })}
|
||||||
|
disabled={rejectMut.isPending}
|
||||||
|
className="rounded-lg bg-signal-danger/10 border border-signal-danger/30 px-4 py-1.5 text-xs font-medium text-signal-danger hover:bg-signal-danger/20 disabled:opacity-50">
|
||||||
|
❌ Respinge
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show review notes on completed */}
|
||||||
|
{a.reviewNotes && a.status !== 'pending' && (
|
||||||
|
<p className="text-xs text-ink-faint mt-2 border-t border-border/50 pt-2">
|
||||||
|
{a.status === 'approved' ? '✅' : '❌'} {a.reviewNotes}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue