diff --git a/src/app/dashboard/anomalies/page.tsx b/src/app/dashboard/anomalies/page.tsx new file mode 100644 index 0000000..9b4e15c --- /dev/null +++ b/src/app/dashboard/anomalies/page.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { apiFetch } from '../../../lib/api'; +import { useSession } from '../../../components/session-provider'; + +interface Transaction { id: string; amount: string; type: string; description: string | null; category: string | null; date: string; currency: string | null; } +interface Task { id: string; status: string; priority: string | null; dueDate: string | null; createdAt: string; } +interface Observation { id: string; metric: string; value: string; confidence: number | null; createdAt: string; observedAt: string | null; } + +interface Anomaly { + id: string; severity: 'HIGH' | 'MEDIUM' | 'LOW'; type: string; description: string; + value: string; expected: string; entity: string; date: string; +} + +function stddev(values: number[]): number { + if (values.length < 2) return 0; + const mean = values.reduce((a, b) => a + b, 0) / values.length; + const variance = values.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / values.length; + return Math.sqrt(variance); +} + +export default function AnomaliesPage() { + const { activeTenant } = useSession(); + const tenantId = activeTenant?.tenantId ?? ''; + const [severity, setSeverity] = useState<'all' | 'HIGH' | 'MEDIUM' | 'LOW'>('all'); + + const { data: transactions = [], isLoading: tL } = useQuery({ + queryKey: ['anomaly-txns', tenantId], + queryFn: () => apiFetch('/v1/transactions?limit=500', { tenantId }), + enabled: Boolean(tenantId), staleTime: 120_000, + }); + const { data: tasks = [], isLoading: tkL } = useQuery({ + queryKey: ['anomaly-tasks', tenantId], + queryFn: () => apiFetch('/v1/tasks?limit=500', { tenantId }), + enabled: Boolean(tenantId), staleTime: 120_000, + }); + const { data: observations = [] } = useQuery({ + queryKey: ['anomaly-obs', tenantId], + queryFn: () => apiFetch('/v1/observations', { tenantId }), + enabled: Boolean(tenantId), staleTime: 120_000, + }); + + const anomalies = useMemo(() => { + const list: Anomaly[] = []; + + // Financial anomalies: transactions > mean + 2σ + const expenses = transactions.filter((t) => t.type === 'expense' || parseFloat(t.amount) < 0); + const amounts = expenses.map((t) => Math.abs(parseFloat(t.amount))).filter((a) => !isNaN(a)); + if (amounts.length >= 5) { + const mean = amounts.reduce((a, b) => a + b, 0) / amounts.length; + const sd = stddev(amounts); + expenses.forEach((t) => { + const amt = Math.abs(parseFloat(t.amount)); + if (amt > mean + 2 * sd) { + list.push({ + id: `txn-${t.id}`, severity: amt > mean + 3 * sd ? 'HIGH' : 'MEDIUM', + type: 'Cheltuială neobișnuită', + description: t.description ?? t.category ?? 'Tranzacție', + value: `${amt.toFixed(2)} ${t.currency ?? 'RON'}`, + expected: `≤ ${(mean + 2 * sd).toFixed(2)} ${t.currency ?? 'RON'}`, + entity: 'Finanțe', date: t.date, + }); + } + }); + } + + // Task anomaly: many overdue urgent tasks + const overdueUrgent = tasks.filter((t) => + t.priority === 'urgent' && t.status !== 'completed' && t.dueDate && new Date(t.dueDate) < new Date() + ); + if (overdueUrgent.length >= 3) { + list.push({ + id: 'urgent-overdue', severity: 'HIGH', type: 'Acumulare taskuri urgente depășite', + description: `${overdueUrgent.length} taskuri urgente sunt depășite.`, + value: `${overdueUrgent.length} taskuri`, expected: '0', entity: 'Taskuri', + date: new Date().toISOString(), + }); + } + + // Observation anomaly: metric with low confidence repeatedly + const lowConf = observations.filter((o) => (o.confidence ?? 1) < 0.3); + if (lowConf.length > 5) { + list.push({ + id: 'low-confidence-obs', severity: 'LOW', type: 'Observații cu confidență scăzută', + description: `${lowConf.length} observații au confidență sub 30%. Date posibil inexacte.`, + value: `${lowConf.length} obs`, expected: '< 5', entity: 'Observații', + date: new Date().toISOString(), + }); + } + + // Gap in observations: last entry more than 7 days ago + if (observations.length > 0) { + const last = [...observations].sort((a, b) => + new Date(b.observedAt ?? b.createdAt).getTime() - new Date(a.observedAt ?? a.createdAt).getTime() + )[0]; + const daysSince = (Date.now() - new Date(last.observedAt ?? last.createdAt).getTime()) / 86400_000; + if (daysSince > 7) { + list.push({ + id: 'obs-gap', severity: 'MEDIUM', type: 'Lipsă date personale', + description: `Nu ai mai logat observații personale de ${Math.floor(daysSince)} zile.`, + value: `${Math.floor(daysSince)} zile`, expected: '≤ 7 zile', entity: 'Observații', + date: last.observedAt ?? last.createdAt, + }); + } + } + + return list.sort((a, b) => { + const order = { HIGH: 0, MEDIUM: 1, LOW: 2 }; + return order[a.severity] - order[b.severity]; + }); + }, [transactions, tasks, observations]); + + const filtered = anomalies.filter((a) => severity === 'all' || a.severity === severity); + const SEV_CLS = { HIGH: 'bg-signal-danger/10 text-signal-danger border-signal-danger/30', MEDIUM: 'bg-warn/10 text-warn border-warn/30', LOW: 'bg-muted text-ink-faint border-border' }; + const SEV_DOT = { HIGH: 'bg-signal-danger', MEDIUM: 'bg-warn', LOW: 'bg-border' }; + + return ( +
+
+

Anomalii & Alerte

+

+ {tL || tkL ? 'Se analizează…' : `${anomalies.length} anomalii detectate · analiză statistică (2σ)`} +

+
+ + {/* Summary */} +
+ {(['HIGH', 'MEDIUM', 'LOW'] as const).map((s) => { + const count = anomalies.filter((a) => a.severity === s).length; + return ( + + ); + })} +
+ + {tL || tkL ? ( +
Se analizează datele…
+ ) : filtered.length === 0 ? ( +
+

+

+ {anomalies.length === 0 ? 'Nicio anomalie detectată. Date în parametri normali.' : 'Nicio anomalie în severitatea selectată.'} +

+
+ ) : ( +
+ {filtered.map((anomaly) => ( +
+
+
+
+ +

{anomaly.type}

+
+

{anomaly.description}

+
+ + {anomaly.severity} + +
+
+ Entitate: {anomaly.entity} + Valoare: {anomaly.value} + Așteptat: {anomaly.expected} + {new Date(anomaly.date).toLocaleDateString('ro-RO')} +
+
+ ))} +
+ )} +
+ ); +}