From eca28e6bac5ba1231c81828abd305c7abca92b16 Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Sun, 2 Aug 2026 18:17:09 +0000 Subject: [PATCH] feat(CC-092): add Decision Journal page (outcome tracking, lessons, 30/90-day review alerts) --- src/app/dashboard/decisions/journal/page.tsx | 209 +++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 src/app/dashboard/decisions/journal/page.tsx diff --git a/src/app/dashboard/decisions/journal/page.tsx b/src/app/dashboard/decisions/journal/page.tsx new file mode 100644 index 0000000..2b6a739 --- /dev/null +++ b/src/app/dashboard/decisions/journal/page.tsx @@ -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(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('/v1/decisions', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + + const { data: observations = [] } = useQuery({ + queryKey: ['dec-obs', tenantId], + queryFn: () => apiFetch('/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 ( +
+
+

Decision Journal

+

{decisions.length} decizii · {dueForReview.length} necesită review

+
+ + {/* Due for review alert */} + {dueForReview.length > 0 && ( +
+
+

⏰ Decizii fără outcome după {reviewWindow.label}

+
+ {REVIEW_WINDOWS.map((w) => ( + + ))} +
+
+
+ {dueForReview.slice(0, 5).map((d) => ( + + ))} +
+
+ )} + + {/* Selected decision detail */} + {selected && ( +
+
+
+

{selected.title}

+

{new Date(selected.createdAt).toLocaleDateString('ro-RO')} · {daysAgo(selected.createdAt)} zile în urmă

+
+ +
+ {selected.description &&

{selected.description}

} + {selected.rationale && ( +
+

RAȚIONAL

+

{selected.rationale}

+
+ )} + + {/* Outcome */} +
+

OUTCOME

+ {selected.outcome ? ( +

{selected.outcome}

+ ) : ( +
+ 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" /> + +
+ )} +
+ + {/* Lesson */} +
+

LECȚIE ÎNVĂȚATĂ

+
+ 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" /> + +
+ {decisionNotes(selected.id).filter((n) => n.metric === 'decision-lesson').map((n) => ( +

💡 {n.value}

+ ))} +
+
+ )} + + {/* All decisions */} + 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 ? ( +
Se încarcă…
+ ) : ( +
+ {searched.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).map((d) => { + const lessons = decisionNotes(d.id).filter((n) => n.metric === 'decision-lesson'); + return ( + + ); + })} +
+ )} +
+ ); +}