feat(CC-067): add Assumptions Tracker page (CRUD, confirm/refute, per-decision)
This commit is contained in:
parent
84c50f68cf
commit
a8d8c349eb
1 changed files with 175 additions and 0 deletions
175
src/app/dashboard/assumptions/page.tsx
Normal file
175
src/app/dashboard/assumptions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue