feat(CC-080): add Anomalies page (2σ financial detection + task/observation pattern alerts)
This commit is contained in:
parent
e773d973ea
commit
4d7a3c60ec
1 changed files with 181 additions and 0 deletions
181
src/app/dashboard/anomalies/page.tsx
Normal file
181
src/app/dashboard/anomalies/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue