feat(CC-092): add Decision Journal page (outcome tracking, lessons, 30/90-day review alerts)
This commit is contained in:
parent
bbd76779ae
commit
eca28e6bac
1 changed files with 209 additions and 0 deletions
209
src/app/dashboard/decisions/journal/page.tsx
Normal file
209
src/app/dashboard/decisions/journal/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue