diff --git a/src/app/dashboard/personal-kpi/page.tsx b/src/app/dashboard/personal-kpi/page.tsx new file mode 100644 index 0000000..4b9f23a --- /dev/null +++ b/src/app/dashboard/personal-kpi/page.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { apiFetch } from '../../../lib/api'; +import { useSession } from '../../../components/session-provider'; + +interface Observation { + id: string; subjectType: string; subjectId: string; metric: string; + value: string; unit: string | null; source: string; + confidence: number | null; observedAt: string | null; createdAt: string; +} + +const PERIODS = [ + { label: '7 zile', days: 7 }, + { label: '30 zile', days: 30 }, + { label: '90 zile', days: 90 }, + { label: 'Toate', days: 0 }, +]; + +const PERSONAL_SUBJECT_TYPES = ['person', 'self', 'health', 'energy', 'productivity', 'custom']; + +export default function PersonalKPIPage() { + const { activeTenant } = useSession(); + const tenantId = activeTenant?.tenantId ?? ''; + const qc = useQueryClient(); + + const [period, setPeriod] = useState(30); + const [showCreate, setShowCreate] = useState(false); + const [form, setForm] = useState({ + subjectType: 'self', subjectId: 'me', metric: '', value: '', unit: '', source: 'self', confidence: '0.9', + }); + + const { data: rawObs = [], isLoading } = useQuery({ + queryKey: ['personal-kpi', tenantId], + queryFn: () => apiFetch('/v1/observations', { tenantId }), + enabled: Boolean(tenantId), staleTime: 30_000, + }); + + // Filter to personal subject types only + const observations = useMemo(() => { + const cutoff = period > 0 ? Date.now() - period * 86400_000 : 0; + return rawObs.filter((o) => + PERSONAL_SUBJECT_TYPES.includes(o.subjectType) && + (cutoff === 0 || new Date(o.observedAt ?? o.createdAt).getTime() >= cutoff) + ); + }, [rawObs, period]); + + // Group by metric + const byMetric = useMemo(() => { + const m: Record = {}; + for (const o of observations) { + m[o.metric] = [...(m[o.metric] ?? []), o]; + } + // Sort each group by date + for (const k of Object.keys(m)) { + m[k].sort((a, b) => new Date(a.observedAt ?? a.createdAt).getTime() - new Date(b.observedAt ?? b.createdAt).getTime()); + } + return m; + }, [observations]); + + const createMut = useMutation({ + mutationFn: (body: Record) => + apiFetch('/v1/observations', { tenantId, method: 'POST', body }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['personal-kpi', tenantId] }); + setShowCreate(false); + setForm({ subjectType: 'self', subjectId: 'me', metric: '', value: '', unit: '', source: 'self', confidence: '0.9' }); + }, + }); + + // Trend: last vs first value (numeric) + function trend(obs: Observation[]): { dir: 'up' | 'down' | 'flat'; pct: number } { + if (obs.length < 2) return { dir: 'flat', pct: 0 }; + const first = parseFloat(obs[0].value); + const last = parseFloat(obs[obs.length - 1].value); + if (isNaN(first) || isNaN(last) || first === 0) return { dir: 'flat', pct: 0 }; + const pct = Math.round(((last - first) / Math.abs(first)) * 100); + return { dir: pct > 0 ? 'up' : pct < 0 ? 'down' : 'flat', pct: Math.abs(pct) }; + } + + const TREND_CLS = { up: 'text-signal-ok', down: 'text-signal-danger', flat: 'text-ink-faint' }; + const TREND_ICON = { up: '↑', down: '↓', flat: '→' }; + + return ( +
+
+
+

Personal KPI

+

+ Metrici personale cu trend, perioadă și sursă. + {observations.length > 0 && ` · ${observations.length} observații`} +

+
+ +
+ + {/* Period selector */} +
+ {PERIODS.map((p) => ( + + ))} +
+ + {/* Create form */} + {showCreate && ( +
+

Observație personală

+
+ + setForm({ ...form, subjectId: 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" /> + setForm({ ...form, metric: 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" /> + setForm({ ...form, value: 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" /> + setForm({ ...form, unit: 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" /> +
+ + setForm({ ...form, confidence: e.target.value })} + className="flex-1" /> + {Math.round(parseFloat(form.confidence) * 100)}% +
+
+
+ + +
+
+ )} + + {/* KPI cards */} + {isLoading ? ( +
Se încarcă…
+ ) : Object.keys(byMetric).length === 0 ? ( +
+

📈

+

Niciun KPI personal înregistrat

+

+ Adaugă prima observație personală: energie, somn, focus, activitate fizică, sau orice metrică proprie. +

+ +
+ ) : ( +
+ {Object.entries(byMetric).map(([metric, obs]) => { + const latest = obs[obs.length - 1]; + const t = trend(obs); + return ( +
+
+

{metric}

+ + {TREND_ICON[t.dir]}{t.pct > 0 ? ` ${t.pct}%` : ''} + +
+
+

+ {latest.value}{latest.unit ? {latest.unit} : ''} +

+

+ {new Date(latest.observedAt ?? latest.createdAt).toLocaleDateString('ro-RO')} · {latest.source} +

+
+ + {/* Sparkline as text dots */} + {obs.length > 1 && ( +
+ {obs.map((o, i) => { + const v = parseFloat(o.value); + const allNums = obs.map((x) => parseFloat(x.value)).filter((x) => !isNaN(x)); + const min = Math.min(...allNums); + const max = Math.max(...allNums); + const range = max - min || 1; + const h = isNaN(v) ? 4 : Math.round(4 + ((v - min) / range) * 28); + return ( +
+ ); + })} +
+ )} + +

+ {obs.length} puncte · confidence {Math.round((parseFloat(latest.confidence?.toString() ?? '1')) * 100)}% +

+
+ ); + })} +
+ )} +
+ ); +}