feat(CC-079): add Scheduled Actions page (approval queue with approve/reject + schedule info)
This commit is contained in:
parent
77847a8282
commit
6cafc2ea9d
1 changed files with 145 additions and 0 deletions
145
src/app/dashboard/ai/scheduled/page.tsx
Normal file
145
src/app/dashboard/ai/scheduled/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue