feat(CC-090): add Retrospective page (7/30/90 day periods, 4-section retro form, saved as observations)
This commit is contained in:
parent
17907f0052
commit
f918b3229d
1 changed files with 155 additions and 0 deletions
155
src/app/dashboard/retrospective/page.tsx
Normal file
155
src/app/dashboard/retrospective/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue