feat(CC-079): add Scheduled Actions page (approval queue with approve/reject + schedule info)

This commit is contained in:
admin-valentin 2026-08-02 12:58:11 +00:00
parent 77847a8282
commit 6cafc2ea9d

View file

@ -0,0 +1,145 @@
'use client';
import { useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../../../lib/api';
import { useSession } from '../../../../components/session-provider';
interface Approval {
id: string; title: string; description: string | null; status: string;
priority: string | null; metadata: Record<string, unknown> | null;
requestedAt: string; scheduledFor: string | null;
}
const STATUS_BADGE: Record<string, string> = {
pending: 'bg-warn/10 text-warn',
approved: 'bg-signal-ok/10 text-signal-ok',
rejected: 'bg-signal-danger/10 text-signal-danger',
running: 'bg-primary/10 text-primary',
done: 'bg-muted text-ink-faint',
};
function relTime(s: string) {
const diff = (new Date(s).getTime() - Date.now()) / 1000;
if (diff < -86400 * 7) return new Date(s).toLocaleDateString('ro-RO');
if (diff < -3600) return `acum ${Math.floor(-diff/3600)}h`;
if (diff < -60) return `acum ${Math.floor(-diff/60)}min`;
if (diff < 0) return 'acum';
if (diff < 3600) return `în ${Math.floor(diff/60)}min`;
if (diff < 86400) return `în ${Math.floor(diff/3600)}h`;
return `în ${Math.floor(diff/86400)} zile`;
}
export default function ScheduledActionsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [statusFilter, setStatusFilter] = useState('all');
const { data: approvals = [], isLoading } = useQuery({
queryKey: ['scheduled-approvals', tenantId],
queryFn: () => apiFetch<Approval[]>('/v1/approvals?limit=200', { tenantId }),
enabled: Boolean(tenantId), staleTime: 30_000, refetchInterval: 30_000,
});
const scheduled = useMemo(() => approvals.filter((a) => a.scheduledFor), [approvals]);
const unscheduled = useMemo(() => approvals.filter((a) => !a.scheduledFor), [approvals]);
const approveMut = useMutation({
mutationFn: (id: string) => apiFetch(`/v1/approvals/${id}/approve`, { tenantId, method: 'POST', body: { notes: 'Aprobat din Scheduled Actions' } }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['scheduled-approvals', tenantId] }),
});
const rejectMut = useMutation({
mutationFn: (id: string) => apiFetch(`/v1/approvals/${id}/reject`, { tenantId, method: 'POST', body: { notes: 'Respins din Scheduled Actions' } }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['scheduled-approvals', tenantId] }),
});
const filtered = (statusFilter === 'all' ? approvals : approvals.filter((a) => a.status === statusFilter));
const pending = approvals.filter((a) => a.status === 'pending');
return (
<div className="max-w-4xl space-y-6 p-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Acțiuni Programate</h1>
<p className="text-sm text-ink-faint mt-1">
{isLoading ? 'Se încarcă…' : `${approvals.length} total · ${pending.length} în așteptare · ${scheduled.length} programate`}
</p>
</div>
{pending.length > 0 && (
<div className="card p-4 bg-warn/5 border-warn/30 space-y-1">
<p className="text-sm font-semibold text-warn"> {pending.length} acțiuni necesită aprobare</p>
<p className="text-xs text-ink-faint">Revizuiește și aprobă sau respinge acțiunile pendinte.</p>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{Object.entries(STATUS_BADGE).map(([status, cls]) => {
const count = approvals.filter((a) => a.status === status).length;
return (
<button key={status} onClick={() => setStatusFilter(statusFilter === status ? 'all' : status)}
className={`card p-3 text-center cursor-pointer hover:border-primary/40 transition-colors ${statusFilter === status ? 'border-primary/50 bg-primary/5' : ''}`}>
<p className="text-xl font-bold text-ink">{count}</p>
<span className={`rounded-full px-2 py-0.5 text-[9px] font-bold ${cls}`}>{status}</span>
</button>
);
})}
</div>
{/* Filter tabs */}
<div className="flex gap-1 border-b border-border/50 overflow-x-auto">
{([['all', 'Toate'], ['pending', 'Pending'], ['approved', 'Aprobate'], ['rejected', 'Respinse']] as const).map(([key, label]) => (
<button key={key} onClick={() => setStatusFilter(key)}
className={`pb-2 px-3 text-sm font-medium border-b-2 whitespace-nowrap transition-colors ${statusFilter === key ? 'border-primary text-ink' : 'border-transparent text-ink-faint hover:text-ink'}`}>
{label}
</button>
))}
</div>
{isLoading ? (
<div className="text-center text-sm text-ink-faint py-8">Se încarcă</div>
) : filtered.length === 0 ? (
<div className="card p-8 text-center space-y-2">
<p className="text-2xl">🤖</p>
<p className="text-sm text-ink-faint">Nicio acțiune AI în această categorie.</p>
</div>
) : (
<div className="space-y-2">
{filtered.map((a) => {
const badge = STATUS_BADGE[a.status] ?? 'bg-muted text-ink-faint';
return (
<div key={a.id} className={`card p-4 border-l-4 ${a.priority === 'urgent' ? 'border-l-signal-danger' : a.priority === 'high' ? 'border-l-warn' : 'border-l-border'}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={`rounded-full px-2 py-0.5 text-[9px] font-bold ${badge}`}>{a.status}</span>
<p className="text-sm font-medium text-ink">{a.title}</p>
</div>
{a.description && <p className="text-xs text-ink-faint mt-1 line-clamp-2">{a.description}</p>}
<div className="flex gap-3 mt-1 text-[10px] text-ink-faint">
<span>Solicitat: {relTime(a.requestedAt)}</span>
{a.scheduledFor && <span>Programat: {relTime(a.scheduledFor)}</span>}
</div>
</div>
{a.status === 'pending' && (
<div className="flex gap-1 shrink-0">
<button onClick={() => approveMut.mutate(a.id)} disabled={approveMut.isPending}
className="rounded bg-signal-ok/10 text-signal-ok px-2 py-1 text-xs hover:bg-signal-ok/20 disabled:opacity-50">
</button>
<button onClick={() => rejectMut.mutate(a.id)} disabled={rejectMut.isPending}
className="rounded bg-signal-danger/10 text-signal-danger px-2 py-1 text-xs hover:bg-signal-danger/20 disabled:opacity-50">
</button>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}