feat(CC-092): add Decision Journal page (outcome tracking, lessons, 30/90-day review alerts)

This commit is contained in:
admin-valentin 2026-08-02 18:17:09 +00:00
parent bbd76779ae
commit eca28e6bac

View file

@ -0,0 +1,209 @@
'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 Decision { id: string; title: string; description: string | null; outcome: string | null; status: string; tags: string[]; rationale: string | null; createdAt: string; updatedAt: string; }
interface Observation { id: string; metric: string; value: string; source: string | null; subjectType: string; createdAt: string; }
const REVIEW_WINDOWS = [
{ label: '30 zile', days: 30 },
{ label: '90 zile', days: 90 },
{ label: '180 zile', days: 180 },
];
export default function DecisionJournalPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [outcomeText, setOutcomeText] = useState('');
const [lessonText, setLessonText] = useState('');
const [reviewWindow, setReviewWindow] = useState(REVIEW_WINDOWS[0]);
const [searchText, setSearchText] = useState('');
const { data: decisions = [], isLoading } = useQuery({
queryKey: ['dec-journal', tenantId],
queryFn: () => apiFetch<Decision[]>('/v1/decisions', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: observations = [] } = useQuery({
queryKey: ['dec-obs', tenantId],
queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
function decisionNotes(decId: string) {
return observations.filter((o) =>
o.source === decId || o.subjectType === `decision-${decId}` || o.metric === `decision-outcome-${decId}`,
).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
const dueForReview = useMemo(() => {
const cutoff = new Date(Date.now() - reviewWindow.days * 86_400_000).toISOString();
return decisions.filter((d) => {
if (d.status === 'cancelled') return false;
if (d.outcome) return false;
const notes = decisionNotes(d.id);
const hasOutcomeNote = notes.some((n) => n.metric.includes('outcome') || n.metric.includes('lesson'));
return d.createdAt <= cutoff && !hasOutcomeNote;
});
}, [decisions, observations, reviewWindow]);
const searched = useMemo(() => {
if (!searchText) return decisions;
const q = searchText.toLowerCase();
return decisions.filter((d) =>
d.title.toLowerCase().includes(q) ||
(d.description ?? '').toLowerCase().includes(q) ||
(d.rationale ?? '').toLowerCase().includes(q),
);
}, [decisions, searchText]);
const patchMut = useMutation({
mutationFn: ({ id, outcome }: { id: string; outcome: string }) =>
apiFetch(`/v1/decisions/${id}`, { tenantId, method: 'PATCH', body: { outcome } }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['dec-journal', tenantId] }),
});
const addLessonMut = useMutation({
mutationFn: ({ decId, lesson }: { decId: string; lesson: string }) =>
apiFetch('/v1/observations', { tenantId, method: 'POST', body: {
metric: `decision-lesson`, value: lesson,
subjectType: `decision-${decId}`, source: decId,
confidence: 1, observedAt: new Date().toISOString(),
}}),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['dec-obs', tenantId] }); setLessonText(''); },
});
const selected = decisions.find((d) => d.id === selectedId);
function daysAgo(iso: string) {
return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
}
function outcomeColor(outcome: string | null): string {
if (!outcome) return 'text-ink-faint';
const l = outcome.toLowerCase();
if (['bun','pozitiv','corect','ok','succes','da','yes'].some((w) => l.includes(w))) return 'text-signal-ok';
if (['rau','gresit','esec','nu','negativ','fail'].some((w) => l.includes(w))) return 'text-signal-danger';
return 'text-warn';
}
return (
<div className="max-w-4xl space-y-6 p-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Decision Journal</h1>
<p className="text-sm text-ink-faint mt-1">{decisions.length} decizii · {dueForReview.length} necesită review</p>
</div>
{/* Due for review alert */}
{dueForReview.length > 0 && (
<div className="card p-4 border-warn/30 bg-warn/5 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-warn"> Decizii fără outcome după {reviewWindow.label}</p>
<div className="flex gap-1">
{REVIEW_WINDOWS.map((w) => (
<button key={w.days} onClick={() => setReviewWindow(w)}
className={`rounded-full px-2 py-0.5 text-[10px] border ${reviewWindow.days === w.days ? 'bg-warn text-white border-warn' : 'border-border text-ink-faint'}`}>
{w.label}
</button>
))}
</div>
</div>
<div className="space-y-2">
{dueForReview.slice(0, 5).map((d) => (
<button key={d.id} onClick={() => setSelectedId(d.id)}
className="w-full text-left flex items-center justify-between p-2 rounded-lg hover:bg-warn/10 transition-colors">
<p className="text-sm text-ink truncate">{d.title}</p>
<span className="text-[10px] text-ink-faint shrink-0 ml-2">{daysAgo(d.createdAt)}z în urmă</span>
</button>
))}
</div>
</div>
)}
{/* Selected decision detail */}
{selected && (
<div className="card p-5 space-y-4 border-primary/20">
<div className="flex items-start justify-between">
<div>
<p className="text-base font-semibold text-ink">{selected.title}</p>
<p className="text-xs text-ink-faint mt-0.5">{new Date(selected.createdAt).toLocaleDateString('ro-RO')} · {daysAgo(selected.createdAt)} zile în urmă</p>
</div>
<button onClick={() => setSelectedId(null)} className="text-ink-faint hover:text-ink ml-2 shrink-0"></button>
</div>
{selected.description && <p className="text-sm text-ink-faint">{selected.description}</p>}
{selected.rationale && (
<div className="border-l-2 border-primary/30 pl-3">
<p className="text-[10px] text-ink-faint font-medium">RAȚIONAL</p>
<p className="text-sm text-ink">{selected.rationale}</p>
</div>
)}
{/* Outcome */}
<div className="space-y-2">
<p className="text-xs font-semibold text-ink-faint">OUTCOME</p>
{selected.outcome ? (
<p className={`text-sm font-medium ${outcomeColor(selected.outcome)}`}>{selected.outcome}</p>
) : (
<div className="flex gap-2">
<input placeholder="Ce s-a întâmplat? A fost decizia corectă?" value={outcomeText}
onChange={(e) => setOutcomeText(e.target.value)}
className="flex-1 rounded-lg border bg-background px-3 py-1.5 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
<button onClick={() => { if (outcomeText.trim()) { patchMut.mutate({ id: selected.id, outcome: outcomeText }); setOutcomeText(''); } }}
className="rounded-lg bg-primary px-3 py-1.5 text-sm text-white">Salvează</button>
</div>
)}
</div>
{/* Lesson */}
<div className="space-y-2">
<p className="text-xs font-semibold text-ink-faint">LECȚIE ÎNVĂȚATĂ</p>
<div className="flex gap-2">
<input placeholder="Ce ai învățat din această decizie?" value={lessonText}
onChange={(e) => setLessonText(e.target.value)}
className="flex-1 rounded-lg border bg-background px-3 py-1.5 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
<button onClick={() => { if (lessonText.trim()) addLessonMut.mutate({ decId: selected.id, lesson: lessonText }); }}
className="rounded-lg bg-primary px-3 py-1.5 text-sm text-white">+ Lecție</button>
</div>
{decisionNotes(selected.id).filter((n) => n.metric === 'decision-lesson').map((n) => (
<p key={n.id} className="text-xs text-ink border-l-2 border-signal-ok/40 pl-2">💡 {n.value}</p>
))}
</div>
</div>
)}
{/* All decisions */}
<input placeholder="Caută în decizii…" value={searchText} onChange={(e) => setSearchText(e.target.value)}
className="w-full rounded-lg border bg-background px-4 py-2.5 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
{isLoading ? (
<div className="text-center text-sm text-ink-faint py-8">Se încarcă</div>
) : (
<div className="card divide-y divide-border/50">
{searched.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).map((d) => {
const lessons = decisionNotes(d.id).filter((n) => n.metric === 'decision-lesson');
return (
<button key={d.id} onClick={() => setSelectedId(d.id === selectedId ? null : d.id)}
className="w-full text-left flex items-start gap-3 p-4 hover:bg-muted/30 transition-colors">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-ink truncate">{d.title}</p>
<p className="text-[10px] text-ink-faint">{new Date(d.createdAt).toLocaleDateString('ro-RO')} · {daysAgo(d.createdAt)}z</p>
{d.outcome && <p className={`text-xs mt-0.5 ${outcomeColor(d.outcome)}`}>Outcome: {d.outcome.slice(0, 80)}</p>}
{lessons.length > 0 && <p className="text-[10px] text-signal-ok mt-0.5">💡 {lessons.length} lecție{lessons.length > 1 ? 'i' : ''}</p>}
</div>
{!d.outcome && daysAgo(d.createdAt) >= 30 && (
<span className="text-[10px] text-warn shrink-0 font-semibold">Review!</span>
)}
</button>
);
})}
</div>
)}
</div>
);
}