feat(CC-067): add Outcome Reviews page (lessons learned, rating, expandable details)

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

View file

@ -0,0 +1,190 @@
'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; selectedOption: string | null; }
interface OutcomeReview {
id: string; title: string; decisionId: string | null;
periodStart: string | null; periodEnd: string | null;
actualOutcome: string | null; wasSuccessful: boolean | null;
lessonsLearned: string | null; nextSteps: string | null;
rating: number | null; createdAt: string;
}
const STARS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
function RatingDisplay({ rating }: { rating: number | null }) {
if (!rating) return <span className="text-ink-faint text-xs"></span>;
const cls = rating >= 8 ? 'text-signal-ok' : rating >= 5 ? 'text-signal-warn' : 'text-signal-danger';
return <span className={`font-display text-lg font-bold ${cls}`}>{rating}/10</span>;
}
export default function OutcomeReviewsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState({
title: '', decisionId: '', periodStart: '', periodEnd: '',
actualOutcome: '', wasSuccessful: '', lessonsLearned: '', nextSteps: '', rating: '',
});
const [expandedId, setExpandedId] = useState<string | null>(null);
const { data: reviews = [], isLoading } = useQuery({
queryKey: ['outcome-reviews', tenantId],
queryFn: () => apiFetch<OutcomeReview[]>('/v1/outcome-reviews', { tenantId }),
enabled: Boolean(tenantId), staleTime: 30_000,
});
const { data: decisions = [] } = useQuery({
queryKey: ['dec-or', tenantId],
queryFn: () => apiFetch<Decision[]>('/v1/decisions?limit=50', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const createMut = useMutation({
mutationFn: (body: Record<string, unknown>) =>
apiFetch<OutcomeReview>('/v1/outcome-reviews', { tenantId, method: 'POST', body }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['outcome-reviews', tenantId] });
setShowCreate(false);
setForm({ title: '', decisionId: '', periodStart: '', periodEnd: '', actualOutcome: '', wasSuccessful: '', lessonsLearned: '', nextSteps: '', rating: '' });
},
});
const successCount = reviews.filter((r) => r.wasSuccessful === true).length;
const failCount = reviews.filter((r) => r.wasSuccessful === false).length;
const avgRating = reviews.filter((r) => r.rating).reduce((s, r) => s + (r.rating ?? 0), 0) / (reviews.filter((r) => r.rating).length || 1);
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">Revizuiri Rezultate</h1>
<p className="text-sm text-ink-faint mt-1">
{isLoading ? 'Se încarcă…' : `${reviews.length} revizuiri · ${successCount} ✓ · ${failCount}${avgRating > 0 ? ` · Rating mediu: ${avgRating.toFixed(1)}/10` : ''}`}
</p>
</div>
<button onClick={() => setShowCreate(!showCreate)} className="btn btn-primary px-4 py-2 text-sm rounded-lg">
+ Revizuire
</button>
</div>
{/* Create form */}
{showCreate && (
<div className="card p-5 space-y-4">
<h2 className="text-sm font-semibold text-ink">Revizuire nouă</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<input placeholder="Titlu revizuire *" value={form.title}
onChange={(e) => setForm({ ...form, title: 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" />
</div>
<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 asociată </option>
{decisions.map((d) => <option key={d.id} value={d.id}>{(d.context ?? '').slice(0, 50)}</option>)}
</select>
<select value={form.wasSuccessful} onChange={(e) => setForm({ ...form, wasSuccessful: 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=""> Succes? </option>
<option value="true"> Da, succes</option>
<option value="false"> Nu, eșec</option>
</select>
<input type="date" placeholder="Perioadă: început" value={form.periodStart}
onChange={(e) => setForm({ ...form, periodStart: 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" />
<input type="date" placeholder="Perioadă: sfârșit" value={form.periodEnd}
onChange={(e) => setForm({ ...form, periodEnd: 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 className="sm:col-span-2">
<textarea placeholder="Rezultat actual (ce s-a întâmplat?)" rows={2} value={form.actualOutcome}
onChange={(e) => setForm({ ...form, actualOutcome: 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>
<div className="sm:col-span-2">
<textarea placeholder="Lecții învățate" rows={2} value={form.lessonsLearned}
onChange={(e) => setForm({ ...form, lessonsLearned: 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>
<div className="sm:col-span-2 flex items-center gap-3">
<span className="text-xs text-ink-faint shrink-0">Rating:</span>
<div className="flex gap-1">
{STARS.map((n) => (
<button key={n} onClick={() => setForm({ ...form, rating: form.rating === String(n) ? '' : String(n) })}
className={`w-7 h-7 rounded text-xs font-medium transition-colors ${
String(n) === form.rating ? 'bg-primary text-white' : 'bg-muted text-ink-faint hover:bg-primary/20'
}`}>{n}</button>
))}
</div>
{form.rating && <span className="text-xs font-medium text-ink">{form.rating}/10</span>}
</div>
</div>
<div className="flex gap-2">
<button onClick={() => createMut.mutate({ title: form.title, decisionId: form.decisionId || undefined, periodStart: form.periodStart || undefined, periodEnd: form.periodEnd || undefined, actualOutcome: form.actualOutcome || undefined, wasSuccessful: form.wasSuccessful ? form.wasSuccessful === 'true' : undefined, lessonsLearned: form.lessonsLearned || undefined, nextSteps: form.nextSteps || undefined, rating: form.rating ? parseInt(form.rating) : undefined })}
disabled={!form.title || createMut.isPending}
className="btn btn-primary px-4 py-1.5 text-sm rounded-lg disabled:opacity-50">
{createMut.isPending ? 'Se creează…' : 'Salvează revizuire'}
</button>
<button onClick={() => setShowCreate(false)} className="text-sm text-ink-faint hover:text-ink px-2">Anulează</button>
</div>
</div>
)}
{!isLoading && reviews.length === 0 ? (
<div className="card p-12 text-center text-sm text-ink-faint">
Nicio revizuire. Creează prima revizuire pentru o decizie trecută.
</div>
) : (
<div className="card divide-y divide-border/50">
{reviews.map((r) => (
<div key={r.id} className="p-5">
<div className="flex items-start gap-4 cursor-pointer" onClick={() => setExpandedId(expandedId === r.id ? null : r.id)}>
<span className="text-xl shrink-0 mt-0.5">
{r.wasSuccessful === true ? '✅' : r.wasSuccessful === false ? '❌' : '📋'}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-ink">{r.title}</p>
<div className="flex items-center gap-3 mt-1 flex-wrap">
{r.periodStart && <span className="text-[10px] text-ink-faint">{r.periodStart}{r.periodEnd ? `${r.periodEnd}` : ''}</span>}
{r.decisionId && <span className="text-[10px] text-ink-faint/60"> decizie</span>}
</div>
</div>
<div className="shrink-0 text-right">
<RatingDisplay rating={r.rating} />
<p className="text-[10px] text-ink-faint mt-0.5">{expandedId === r.id ? '▲' : '▼'}</p>
</div>
</div>
{expandedId === r.id && (r.actualOutcome || r.lessonsLearned || r.nextSteps) && (
<div className="mt-4 pl-9 space-y-3">
{r.actualOutcome && (
<div>
<p className="text-[10px] font-semibold uppercase tracking-wider text-ink-faint mb-1">Rezultat actual</p>
<p className="text-xs text-ink">{r.actualOutcome}</p>
</div>
)}
{r.lessonsLearned && (
<div>
<p className="text-[10px] font-semibold uppercase tracking-wider text-ink-faint mb-1">Lecții învățate</p>
<p className="text-xs text-ink">{r.lessonsLearned}</p>
</div>
)}
{r.nextSteps && (
<div>
<p className="text-[10px] font-semibold uppercase tracking-wider text-ink-faint mb-1">Pași următori</p>
<p className="text-xs text-ink">{r.nextSteps}</p>
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}