feat(CC-067): add Assumptions Tracker page (CRUD, confirm/refute, per-decision)

This commit is contained in:
admin-valentin 2026-08-02 12:13:02 +00:00
parent 84c50f68cf
commit a8d8c349eb

View file

@ -0,0 +1,175 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
interface Decision { id: string; context: string; }
interface Assumption {
id: string; statement: string; source: string | null;
confidence: string; status: string; reviewDate: string | null;
evidenceUrl: string | null; notes: string | null; decisionId: string | null;
createdAt: string;
}
const STATUS_META: Record<string, { label: string; cls: string; icon: string }> = {
unverified: { label: 'Neverificată', cls: 'text-ink-faint bg-muted border-border/50', icon: '❓' },
pending_review: { label: 'În revizuire', cls: 'text-signal-warn bg-signal-warn/10 border-signal-warn/20', icon: '🔄' },
confirmed: { label: 'Confirmată', cls: 'text-signal-ok bg-signal-ok/10 border-signal-ok/20', icon: '✅' },
refuted: { label: 'Infirmată', cls: 'text-signal-danger bg-signal-danger/10 border-signal-danger/20', icon: '❌' },
};
const CONF_META: Record<string, { label: string; cls: string }> = {
low: { label: 'Scăzută', cls: 'text-sky-500' },
medium: { label: 'Medie', cls: 'text-signal-warn' },
high: { label: 'Ridicată',cls: 'text-signal-ok' },
};
export default function AssumptionsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [filterStatus, setFilterStatus] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState({ statement: '', confidence: 'medium', source: '', decisionId: '', reviewDate: '' });
const { data: assumptions = [], isLoading } = useQuery({
queryKey: ['assumptions', tenantId, filterStatus],
queryFn: () => apiFetch<Assumption[]>(`/v1/assumptions${filterStatus ? `?status=${filterStatus}` : ''}`, { tenantId }),
enabled: Boolean(tenantId), staleTime: 30_000,
});
const { data: decisions = [] } = useQuery({
queryKey: ['dec-list', tenantId],
queryFn: () => apiFetch<Decision[]>('/v1/decisions?limit=50', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const createMut = useMutation({
mutationFn: (body: Record<string, string>) =>
apiFetch<Assumption>('/v1/assumptions', { tenantId, method: 'POST', body }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['assumptions', tenantId] }); setShowCreate(false); setForm({ statement: '', confidence: 'medium', source: '', decisionId: '', reviewDate: '' }); },
});
const patchMut = useMutation({
mutationFn: ({ id, ...body }: { id: string; status: string }) =>
apiFetch(`/v1/assumptions/${id}`, { tenantId, method: 'PATCH', body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['assumptions', tenantId] }),
});
const unverified = assumptions.filter((a) => a.status === 'unverified').length;
const refuted = assumptions.filter((a) => a.status === 'refuted').length;
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">Tracker Asumpții</h1>
<p className="text-sm text-ink-faint mt-1">
{isLoading ? 'Se încarcă…' : `${assumptions.length} asumpții${refuted > 0 ? ` · ${refuted} infirmate` : ''}${unverified > 0 ? ` · ${unverified} neverificate` : ''}`}
</p>
</div>
<button onClick={() => setShowCreate(!showCreate)} className="btn btn-primary px-4 py-2 text-sm rounded-lg">
+ Asumpție
</button>
</div>
{/* Filter */}
<div className="flex gap-2 flex-wrap">
{[['', 'Toate'], ...Object.entries(STATUS_META).map(([k, v]) => [k, v.label])].map(([key, label]) => (
<button key={key} onClick={() => setFilterStatus(key)}
className={`rounded-full px-3 py-1 text-xs border transition-colors ${
filterStatus === key ? 'bg-primary/10 border-primary text-ink' : 'bg-card border-border text-ink-faint hover:border-primary/30'
}`}>
{key && STATUS_META[key]?.icon} {label} ({key ? assumptions.filter((a) => a.status === key).length : assumptions.length})
</button>
))}
</div>
{/* Create form */}
{showCreate && (
<div className="card p-5 space-y-4">
<h2 className="text-sm font-semibold text-ink">Asumpție nouă</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<textarea placeholder="Asumpție *" rows={2} value={form.statement}
onChange={(e) => setForm({ ...form, statement: e.target.value })}
className="w-full rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring resize-none" />
</div>
<select value={form.confidence} onChange={(e) => setForm({ ...form, confidence: e.target.value })}
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring">
<option value="low">Încredere scăzută</option>
<option value="medium">Încredere medie</option>
<option value="high">Încredere ridicată</option>
</select>
<input placeholder="Sursă" value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })}
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
<select value={form.decisionId} onChange={(e) => setForm({ ...form, decisionId: e.target.value })}
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring">
<option value=""> Fără decizie </option>
{decisions.map((d) => <option key={d.id} value={d.id}>{(d.context ?? '').slice(0, 50)}</option>)}
</select>
<input type="date" placeholder="Data revizuire" value={form.reviewDate}
onChange={(e) => setForm({ ...form, reviewDate: e.target.value })}
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
<div className="flex gap-2">
<button onClick={() => createMut.mutate({ statement: form.statement, confidence: form.confidence, source: form.source, decisionId: form.decisionId, reviewDate: form.reviewDate })}
disabled={!form.statement || createMut.isPending}
className="btn btn-primary px-4 py-1.5 text-sm rounded-lg disabled:opacity-50">
{createMut.isPending ? 'Se creează…' : 'Adaugă'}
</button>
<button onClick={() => setShowCreate(false)} className="text-sm text-ink-faint hover:text-ink px-2">Anulează</button>
</div>
</div>
)}
{!isLoading && assumptions.length === 0 ? (
<div className="card p-12 text-center text-sm text-ink-faint">
Nicio asumpție înregistrată. Adaugă prima asumpție pentru o decizie.
</div>
) : (
<div className="card divide-y divide-border/50">
{assumptions.map((a) => {
const sm = STATUS_META[a.status] ?? STATUS_META.unverified;
const cm = CONF_META[a.confidence] ?? CONF_META.medium;
return (
<div key={a.id} className="p-4 space-y-2">
<div className="flex items-start gap-3">
<span className="text-base shrink-0 mt-0.5">{sm.icon}</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-ink">{a.statement}</p>
<div className="flex items-center gap-3 mt-1 flex-wrap">
<span className={`text-[10px] font-medium ${cm.cls}`}>{cm.label}</span>
{a.source && <span className="text-[10px] text-ink-faint">🔬 {a.source}</span>}
{a.reviewDate && <span className="text-[10px] text-ink-faint">📅 {a.reviewDate}</span>}
<span className={`rounded-full px-2 py-0.5 text-[10px] border ${sm.cls}`}>{sm.label}</span>
</div>
{a.evidenceUrl && (
<a href={a.evidenceUrl} target="_blank" rel="noopener noreferrer"
className="text-xs text-bronze-deep hover:underline truncate block mt-1">
🔗 {a.evidenceUrl}
</a>
)}
</div>
<div className="flex gap-1 shrink-0">
{a.status === 'unverified' && (
<>
<button onClick={() => patchMut.mutate({ id: a.id, status: 'confirmed' })}
className="rounded border px-2 py-1 text-[10px] text-signal-ok hover:bg-signal-ok/5"></button>
<button onClick={() => patchMut.mutate({ id: a.id, status: 'refuted' })}
className="rounded border px-2 py-1 text-[10px] text-signal-danger hover:bg-signal-danger/5"></button>
</>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}