feat(CC-092): add Annual Review page (year stats, 5-section form, multi-year selector)

This commit is contained in:
admin-valentin 2026-08-02 18:17:11 +00:00
parent 6aa653ab4a
commit 99bfb467c6

View file

@ -0,0 +1,172 @@
'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; createdAt: string; updatedAt: 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; createdAt: string; }
interface Observation { id: string; metric: string; value: string; subjectType: string; source: string | null; createdAt: string; observedAt: string | null; }
interface Contract { id: string; value: number | null; status: string; createdAt: string; }
const CURRENT_YEAR = new Date().getFullYear();
const YEAR_OPTIONS = Array.from({ length: 4 }, (_, i) => CURRENT_YEAR - i);
const ANNUAL_METRICS = ['annual-highlight','annual-challenge','annual-lesson','annual-word','annual-next'];
function inYear(iso: string, year: number) { return new Date(iso).getFullYear() === year; }
function parseNum(v: string) { return parseFloat(v.replace(/[^0-9.-]/g, '')) || 0; }
export default function AnnualReviewPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [year, setYear] = useState(CURRENT_YEAR);
const [highlights, setHighlights] = useState('');
const [challenges, setChallenges] = useState('');
const [lessons, setLessons] = useState('');
const [wordOfYear, setWordOfYear] = useState('');
const [nextYear, setNextYear] = useState('');
const [saved, setSaved] = useState(false);
const { data: tasks = [] } = useQuery({ queryKey: ['ar-tasks', tenantId], queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=1000', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const { data: goals = [] } = useQuery({ queryKey: ['ar-goals', tenantId], queryFn: () => apiFetch<Goal[]>('/v1/goals?limit=500', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const { data: decisions = [] } = useQuery({ queryKey: ['ar-decisions', tenantId],queryFn: () => apiFetch<Decision[]>('/v1/decisions', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const { data: observations = [] } = useQuery({ queryKey: ['ar-obs', tenantId], queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const { data: contracts = [] } = useQuery({ queryKey: ['ar-contracts', tenantId],queryFn: () => apiFetch<Contract[]>('/v1/contracts?limit=500', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const stats = useMemo(() => {
const yearTasks = tasks.filter((t) => inYear(t.createdAt, year));
const doneTasks = tasks.filter((t) => inYear(t.updatedAt, year) && t.status === 'completed');
const yearGoals = goals.filter((g) => inYear(g.createdAt, year));
const doneGoals = yearGoals.filter((g) => g.status === 'completed');
const yearDec = decisions.filter((d) => inYear(d.createdAt, year));
const yearObs = observations.filter((o) => inYear(o.observedAt ?? o.createdAt, year) && !ANNUAL_METRICS.includes(o.metric));
const yearRevenue = contracts
.filter((c) => (c.status === 'signed' || c.status === 'completed') && inYear(c.createdAt, year))
.reduce((s, c) => s + (c.value ?? 0), 0);
const incomeObs = observations
.filter((o) => inYear(o.observedAt ?? o.createdAt, year) && ['venit','income','revenue','incasare'].some((m) => o.metric.toLowerCase().includes(m)))
.reduce((s, o) => s + parseNum(o.value), 0);
return { yearTasks: yearTasks.length, doneTasks: doneTasks.length, yearGoals: yearGoals.length, doneGoals: doneGoals.length, yearDec: yearDec.length, yearObs: yearObs.length, yearRevenue: yearRevenue + incomeObs };
}, [tasks, goals, decisions, observations, contracts, year]);
const pastReviews = useMemo(() =>
observations
.filter((o) => ANNUAL_METRICS.includes(o.metric) && inYear(o.observedAt ?? o.createdAt, year))
.sort((a, b) => a.metric.localeCompare(b.metric)),
[observations, year]);
const hasReview = pastReviews.length > 0;
const saveMut = useMutation({
mutationFn: async () => {
const ts = new Date(`${year}-12-31T23:59:59.000Z`).toISOString();
const pairs = [
{ metric: 'annual-highlight', value: highlights },
{ metric: 'annual-challenge', value: challenges },
{ metric: 'annual-lesson', value: lessons },
{ metric: 'annual-word', value: wordOfYear },
{ metric: 'annual-next', value: nextYear },
].filter((p) => p.value.trim());
await Promise.all(pairs.map((p) =>
apiFetch('/v1/observations', { tenantId, method: 'POST', body: {
...p, subjectType: 'annual-review', confidence: 1,
source: String(year), observedAt: ts,
}}),
));
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['ar-obs', tenantId] });
setSaved(true);
setHighlights(''); setChallenges(''); setLessons(''); setWordOfYear(''); setNextYear('');
setTimeout(() => setSaved(false), 4000);
},
});
return (
<div className="max-w-3xl space-y-6 p-6">
<div className="flex items-start justify-between flex-wrap gap-3">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Annual Review</h1>
<p className="text-sm text-ink-faint mt-1">Retrospectivă de an complet</p>
</div>
<div className="flex gap-1">
{YEAR_OPTIONS.map((y) => (
<button key={y} onClick={() => setYear(y)}
className={`rounded-full px-3 py-1.5 text-sm border ${year === y ? 'bg-primary text-white border-primary' : 'bg-background text-ink-faint border-border hover:border-primary/40'}`}>
{y}
</button>
))}
</div>
</div>
{/* Year stats */}
<div className="grid gap-3 sm:grid-cols-3">
{[
{ label: 'Tasks finalizate', value: `${stats.doneTasks}/${stats.yearTasks}` },
{ label: 'Obiective atinse', value: `${stats.doneGoals}/${stats.yearGoals}` },
{ label: 'Decizii luate', value: stats.yearDec },
{ label: 'Observații', value: stats.yearObs },
{ label: 'Venituri', value: `${stats.yearRevenue.toLocaleString('ro-RO')}` },
{ label: 'Focus sessions', value: '—' },
].map((s) => (
<div key={s.label} className="card p-3 space-y-0.5">
<p className="text-xs text-ink-faint">{s.label}</p>
<p className="text-xl font-bold text-ink">{s.value}</p>
</div>
))}
</div>
{hasReview ? (
<div className="card p-5 space-y-4">
<p className="text-sm font-semibold text-ink">Review {year} salvat</p>
{pastReviews.map((o) => {
const labels: Record<string, string> = {
'annual-highlight':'🌟 Highlight', 'annual-challenge':'⚡ Provocare',
'annual-lesson':'💡 Lecție', 'annual-word':'🔤 Cuvântul anului', 'annual-next':'🚀 Focusul anului următor',
};
return (
<div key={o.id} className="space-y-0.5">
<p className="text-[10px] font-semibold text-ink-faint uppercase">{labels[o.metric] ?? o.metric}</p>
<p className="text-sm text-ink whitespace-pre-line">{o.value}</p>
</div>
);
})}
</div>
) : (
<>
{saved ? (
<div className="card p-8 text-center space-y-2">
<p className="text-3xl">🎊</p>
<p className="font-semibold text-ink">Annual Review {year} salvat!</p>
</div>
) : (
<div className="space-y-4">
{[
{ key: 'highlight', label: '🌟 Cel mai mare succes al anului', val: highlights, set: setHighlights, ph: 'Ce realizare îți dă cel mai mare orgoliu?' },
{ key: 'challenge', label: '⚡ Cea mai mare provocare', val: challenges, set: setChallenges, ph: 'Ce a fost cel mai greu? Cum ai depășit-o?' },
{ key: 'lesson', label: '💡 Lecția principală', val: lessons, set: setLessons, ph: 'Ce știi acum ce nu știai la începutul anului?' },
{ key: 'word', label: '🔤 Cuvântul anului', val: wordOfYear, set: setWordOfYear, ph: 'Un singur cuvânt care descrie anul (ex: Claritate, Curaj, Construire)' },
{ key: 'next', label: '🚀 Focusul pentru anul următor', val: nextYear, set: setNextYear, ph: 'Ce vrei să fie diferit / mai bun?' },
].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)} placeholder={f.ph}
rows={f.key === 'word' ? 1 : 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 || (!highlights && !challenges && !lessons && !wordOfYear && !nextYear)}
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ă Annual Review ${year}`}
</button>
</div>
)}
</>
)}
</div>
);
}