feat(CC-090): add Retrospective page (7/30/90 day periods, 4-section retro form, saved as observations)

This commit is contained in:
admin-valentin 2026-08-02 18:00:43 +00:00
parent 17907f0052
commit f918b3229d

View file

@ -0,0 +1,155 @@
'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 Task { id: string; title: string; status: string; tags: string[]; updatedAt: string; createdAt: string; }
interface Goal { id: string; title: string; status: string; progress: number | null; tags: string[]; createdAt: string; }
interface Decision { id: string; title: string; outcome: string | null; tags: string[]; createdAt: string; }
interface Observation { id: string; metric: string; value: string; createdAt: string; }
const PERIODS = [
{ label: '7 zile', days: 7 },
{ label: '30 zile', days: 30 },
{ label: '90 zile (Q)', days: 90 },
];
const RETRO_METRICS = ['went-well', 'improve', 'lessons', 'next-actions'];
export default function RetrospectivePage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [period, setPeriod] = useState(PERIODS[1]);
const [wentWell, setWentWell] = useState('');
const [improve, setImprove] = useState('');
const [lessons, setLessons] = useState('');
const [nextActions, setNextActions] = useState('');
const [saved, setSaved] = useState(false);
const since = new Date(Date.now() - period.days * 86_400_000).toISOString();
const { data: tasks = [] } = useQuery({ queryKey: ['retro-tasks', tenantId], queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=500', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const { data: goals = [] } = useQuery({ queryKey: ['retro-goals', tenantId], queryFn: () => apiFetch<Goal[]>('/v1/goals?limit=200', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const { data: decisions = [] } = useQuery({ queryKey: ['retro-decisions', tenantId], queryFn: () => apiFetch<Decision[]>('/v1/decisions', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const { data: observations = [] } = useQuery({ queryKey: ['retro-obs', tenantId], queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const periodTasks = useMemo(() => tasks.filter((t) => t.createdAt >= since || t.updatedAt >= since), [tasks, since]);
const completedTasks = useMemo(() => periodTasks.filter((t) => t.status === 'completed' && t.updatedAt >= since), [periodTasks, since]);
const periodGoals = useMemo(() => goals.filter((g) => g.createdAt >= since), [goals, since]);
const periodDecs = useMemo(() => decisions.filter((d) => d.createdAt >= since), [decisions, since]);
const periodObs = useMemo(() => observations.filter((o) => o.createdAt >= since && !RETRO_METRICS.includes(o.metric)), [observations, since]);
const pastRetros = useMemo(() =>
observations
.filter((o) => RETRO_METRICS.includes(o.metric))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, 4),
[observations]);
const saveMut = useMutation({
mutationFn: async () => {
const ts = new Date().toISOString();
const pairs = [
{ metric: 'went-well', value: wentWell },
{ metric: 'improve', value: improve },
{ metric: 'lessons', value: lessons },
{ metric: 'next-actions', value: nextActions },
].filter((p) => p.value.trim());
await Promise.all(pairs.map((p) =>
apiFetch('/v1/observations', { tenantId, method: 'POST', body: {
...p, subjectType: 'retrospective', confidence: 1, observedAt: ts,
source: `Retro ${period.label}${new Date().toLocaleDateString('ro-RO')}`,
}}),
));
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['retro-obs', tenantId] });
setSaved(true);
setWentWell(''); setImprove(''); setLessons(''); setNextActions('');
setTimeout(() => setSaved(false), 4000);
},
});
return (
<div className="max-w-3xl space-y-6 p-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Retrospectivă</h1>
<p className="text-sm text-ink-faint mt-1">Analiză periodică a progresului și lecțiilor învățate.</p>
</div>
{/* Period selector */}
<div className="flex gap-2">
{PERIODS.map((p) => (
<button key={p.label} onClick={() => setPeriod(p)}
className={`rounded-full px-4 py-1.5 text-sm border transition-colors ${period.days === p.days ? 'bg-primary text-white border-primary' : 'bg-background text-ink-faint border-border hover:border-primary/40'}`}>
{p.label}
</button>
))}
</div>
{/* Stats snapshot */}
<div className="grid gap-3 sm:grid-cols-4">
{[
{ label: 'Tasks finalizate', value: completedTasks.length, icon: '✅' },
{ label: 'Obiective noi', value: periodGoals.length, icon: '🎯' },
{ label: 'Decizii luate', value: periodDecs.length, icon: '🧠' },
{ label: 'Observații', value: periodObs.length, icon: '📊' },
].map((s) => (
<div key={s.label} className="card p-3 text-center space-y-1">
<p className="text-xl">{s.icon}</p>
<p className="text-xl font-bold text-ink">{s.value}</p>
<p className="text-[10px] text-ink-faint">{s.label}</p>
</div>
))}
</div>
{/* Retro form */}
{saved ? (
<div className="card p-6 text-center space-y-2">
<p className="text-3xl">🎉</p>
<p className="font-semibold text-ink">Retrospectivă salvată!</p>
<p className="text-sm text-ink-faint">Salvată ca observații cu metricile went-well / improve / lessons / next-actions.</p>
</div>
) : (
<div className="space-y-4">
{[
{ key: 'went-well', label: '🟢 Ce a mers bine?', val: wentWell, set: setWentWell },
{ key: 'improve', label: '🟡 Ce ar fi putut merge mai bine?', val: improve, set: setImprove },
{ key: 'lessons', label: '💡 Ce ai învățat?', val: lessons, set: setLessons },
{ key: 'next-actions', label: '🚀 Ce vei face diferit?', val: nextActions, set: setNextActions },
].map((f) => (
<div key={f.key} className="card p-4 space-y-2">
<label className="text-sm font-semibold text-ink">{f.label}</label>
<textarea value={f.val} onChange={(e) => f.set(e.target.value)} rows={3}
className="w-full rounded-lg border bg-background px-3 py-2 text-sm text-ink resize-none focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
))}
<button onClick={() => saveMut.mutate()}
disabled={saveMut.isPending || (!wentWell.trim() && !improve.trim() && !lessons.trim() && !nextActions.trim())}
className="w-full rounded-lg bg-primary py-3 text-sm font-semibold text-white hover:bg-primary/90 disabled:opacity-50">
{saveMut.isPending ? 'Se salvează…' : 'Salvează retrospectiva'}
</button>
</div>
)}
{/* Past retros */}
{pastRetros.length > 0 && (
<div className="card p-4 space-y-3">
<p className="text-sm font-semibold text-ink">Retrospective anterioare</p>
<div className="space-y-2">
{pastRetros.map((o) => (
<div key={o.id} className="flex gap-3 text-xs">
<span className="text-ink-faint shrink-0">{new Date(o.createdAt).toLocaleDateString('ro-RO')}</span>
<span className="text-ink-faint font-medium shrink-0 capitalize">{o.metric.replace(/-/g, ' ')}</span>
<span className="text-ink line-clamp-1">{o.value}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}