From 99bfb467c6eff09f424428e157f045786f3c7714 Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Sun, 2 Aug 2026 18:17:11 +0000 Subject: [PATCH] feat(CC-092): add Annual Review page (year stats, 5-section form, multi-year selector) --- src/app/dashboard/annual-review/page.tsx | 172 +++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 src/app/dashboard/annual-review/page.tsx diff --git a/src/app/dashboard/annual-review/page.tsx b/src/app/dashboard/annual-review/page.tsx new file mode 100644 index 0000000..f4ace77 --- /dev/null +++ b/src/app/dashboard/annual-review/page.tsx @@ -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('/v1/tasks?limit=1000', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 }); + const { data: goals = [] } = useQuery({ queryKey: ['ar-goals', tenantId], queryFn: () => apiFetch('/v1/goals?limit=500', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 }); + const { data: decisions = [] } = useQuery({ queryKey: ['ar-decisions', tenantId],queryFn: () => apiFetch('/v1/decisions', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 }); + const { data: observations = [] } = useQuery({ queryKey: ['ar-obs', tenantId], queryFn: () => apiFetch('/v1/observations', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 }); + const { data: contracts = [] } = useQuery({ queryKey: ['ar-contracts', tenantId],queryFn: () => apiFetch('/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 ( +
+
+
+

Annual Review

+

Retrospectivă de an complet

+
+
+ {YEAR_OPTIONS.map((y) => ( + + ))} +
+
+ + {/* Year stats */} +
+ {[ + { 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) => ( +
+

{s.label}

+

{s.value}

+
+ ))} +
+ + {hasReview ? ( +
+

Review {year} salvat

+ {pastReviews.map((o) => { + const labels: Record = { + 'annual-highlight':'🌟 Highlight', 'annual-challenge':'⚡ Provocare', + 'annual-lesson':'💡 Lecție', 'annual-word':'🔤 Cuvântul anului', 'annual-next':'🚀 Focusul anului următor', + }; + return ( +
+

{labels[o.metric] ?? o.metric}

+

{o.value}

+
+ ); + })} +
+ ) : ( + <> + {saved ? ( +
+

🎊

+

Annual Review {year} salvat!

+
+ ) : ( +
+ {[ + { 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) => ( +
+ +