feat(CC-080): add Anomalies page (2σ financial detection + task/observation pattern alerts)

This commit is contained in:
admin-valentin 2026-08-02 13:02:12 +00:00
parent e773d973ea
commit 4d7a3c60ec

View file

@ -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<Transaction[]>('/v1/transactions?limit=500', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const { data: tasks = [], isLoading: tkL } = useQuery({
queryKey: ['anomaly-tasks', tenantId],
queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=500', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const { data: observations = [] } = useQuery({
queryKey: ['anomaly-obs', tenantId],
queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const anomalies = useMemo<Anomaly[]>(() => {
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 (
<div className="max-w-4xl space-y-6 p-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Anomalii & Alerte</h1>
<p className="text-sm text-ink-faint mt-1">
{tL || tkL ? 'Se analizează…' : `${anomalies.length} anomalii detectate · analiză statistică (2σ)`}
</p>
</div>
{/* Summary */}
<div className="grid grid-cols-3 gap-3">
{(['HIGH', 'MEDIUM', 'LOW'] as const).map((s) => {
const count = anomalies.filter((a) => a.severity === s).length;
return (
<button key={s} onClick={() => setSeverity(severity === s ? 'all' : s)}
className={`card p-4 text-center cursor-pointer hover:border-primary/40 transition-colors ${severity === s ? 'border-primary/50' : ''}`}>
<p className={`text-2xl font-bold ${count > 0 ? (s === 'HIGH' ? 'text-signal-danger' : s === 'MEDIUM' ? 'text-warn' : 'text-ink-faint') : 'text-ink'}`}>
{count}
</p>
<p className="text-[10px] text-ink-faint">{s}</p>
</button>
);
})}
</div>
{tL || tkL ? (
<div className="text-center text-sm text-ink-faint py-8">Se analizează datele</div>
) : filtered.length === 0 ? (
<div className="card p-8 text-center space-y-2">
<p className="text-2xl"></p>
<p className="text-sm text-ink-faint">
{anomalies.length === 0 ? 'Nicio anomalie detectată. Date în parametri normali.' : 'Nicio anomalie în severitatea selectată.'}
</p>
</div>
) : (
<div className="space-y-3">
{filtered.map((anomaly) => (
<div key={anomaly.id} className={`card p-4 border-l-4 space-y-2 ${anomaly.severity === 'HIGH' ? 'border-l-signal-danger' : anomaly.severity === 'MEDIUM' ? 'border-l-warn' : 'border-l-border'}`}>
<div className="flex items-start justify-between gap-3 flex-wrap">
<div>
<div className="flex items-center gap-2">
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${SEV_DOT[anomaly.severity]}`} />
<p className="text-sm font-semibold text-ink">{anomaly.type}</p>
</div>
<p className="text-xs text-ink-faint mt-0.5">{anomaly.description}</p>
</div>
<span className={`rounded-full px-2 py-0.5 text-[9px] font-bold shrink-0 ${SEV_CLS[anomaly.severity]}`}>
{anomaly.severity}
</span>
</div>
<div className="flex flex-wrap gap-4 text-[10px]">
<span className="text-ink-faint">Entitate: <strong className="text-ink">{anomaly.entity}</strong></span>
<span className="text-ink-faint">Valoare: <strong className="text-signal-danger">{anomaly.value}</strong></span>
<span className="text-ink-faint">Așteptat: <strong className="text-ink">{anomaly.expected}</strong></span>
<span className="text-ink-faint">{new Date(anomaly.date).toLocaleDateString('ro-RO')}</span>
</div>
</div>
))}
</div>
)}
</div>
);
}