feat(CC-067): add Outcome Reviews page (lessons learned, rating, expandable details)
This commit is contained in:
parent
a8d8c349eb
commit
b74ab35ff9
1 changed files with 190 additions and 0 deletions
190
src/app/dashboard/outcome-reviews/page.tsx
Normal file
190
src/app/dashboard/outcome-reviews/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue