feat(cc-049): Alerts & Deadlines — cross-module alert aggregation

This commit is contained in:
admin-valentin 2026-07-31 19:26:45 +00:00
parent 7c2931b2c8
commit 43d6192428

View file

@ -0,0 +1,236 @@
'use client';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { apiFetch, type Transaction, type Goal, type Decision, type Notification } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
interface Task {
id: string;
title: string;
status: string;
dueDate?: string;
}
interface Alert {
id: string;
level: 'critical' | 'warning' | 'info';
module: string;
message: string;
href: string;
detail?: string;
}
const LEVEL_CFG = {
critical: {
borderClass: 'border-l-signal-danger',
bgClass: 'bg-[color:oklch(var(--signal-danger)/0.05)]',
badgeClass: 'bg-signal-danger text-white',
label: 'CRITIC',
icon: '!',
},
warning: {
borderClass: 'border-l-signal-warn',
bgClass: 'bg-[color:oklch(var(--signal-warn)/0.05)]',
badgeClass: 'bg-signal-warn text-white',
label: 'AVERTISMENT',
icon: '⚠',
},
info: {
borderClass: 'border-l-bronze-deep',
bgClass: 'bg-bronze-wash/20',
badgeClass: 'bg-bronze-deep text-white',
label: 'INFO',
icon: 'i',
},
};
export default function AlertsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const { data: tasks = [] } = useQuery({
queryKey: ['tasks-alerts', tenantId],
queryFn: () => apiFetch<Task[]>('/v1/tasks', { tenantId }),
enabled: Boolean(tenantId),
});
const { data: transactions = [] } = useQuery({
queryKey: ['tx-alerts', tenantId],
queryFn: () => apiFetch<Transaction[]>('/v1/transactions', { tenantId }),
enabled: Boolean(tenantId),
});
const { data: goals = [] } = useQuery({
queryKey: ['goals-alerts', tenantId],
queryFn: () => apiFetch<Goal[]>('/v1/goals', { tenantId }),
enabled: Boolean(tenantId),
});
const { data: decisions = [] } = useQuery({
queryKey: ['decisions-alerts', tenantId],
queryFn: () => apiFetch<Decision[]>('/v1/decisions', { tenantId }),
enabled: Boolean(tenantId),
});
const { data: notifications = [] } = useQuery({
queryKey: ['notif-alerts', tenantId],
queryFn: () => apiFetch<Notification[]>('/v1/notifications?filter=unread', { tenantId }),
enabled: Boolean(tenantId),
});
const now = new Date();
const in7Days = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const alerts: Alert[] = [];
const overdueTasks = tasks.filter((t) => t.status !== 'done' && t.dueDate && new Date(t.dueDate) < now);
if (overdueTasks.length > 0)
alerts.push({
id: 'tasks-overdue',
level: 'critical',
module: 'Sarcini',
message: `${overdueTasks.length} sarcini restante`,
href: '/dashboard/tasks',
detail: overdueTasks.slice(0, 3).map((t) => t.title).join(', '),
});
const missingEvidence = transactions.filter((t) => t.evidenceStatus === 'missing');
if (missingEvidence.length > 0)
alerts.push({
id: 'tx-missing',
level: 'critical',
module: 'Tranzacții',
message: `${missingEvidence.length} tranzacții fără dovadă contabilă`,
href: '/dashboard/transactions',
});
const overdueDecisionReviews = decisions.filter(
(d) => d.selectedOption && d.reviewDueAt && new Date(d.reviewDueAt) < now,
);
if (overdueDecisionReviews.length > 0)
alerts.push({
id: 'decisions-reviews',
level: 'critical',
module: 'Decizii',
message: `${overdueDecisionReviews.length} revizuiri de decizii restante`,
href: '/dashboard/decisions/register',
});
const criticalNotifs = notifications.filter((n) => n.severity === 'CRITICAL');
if (criticalNotifs.length > 0)
alerts.push({
id: 'notif-critical',
level: 'critical',
module: 'Sistem',
message: `${criticalNotifs.length} notificări critice necitite`,
href: '/dashboard/settings/notifications',
});
const riskyGoals = goals.filter((g) => g.status === 'at_risk');
if (riskyGoals.length > 0)
alerts.push({
id: 'goals-risk',
level: 'warning',
module: 'Obiective',
message: `${riskyGoals.length} obiective la risc`,
href: '/dashboard/goals',
detail: riskyGoals.slice(0, 2).map((g) => g.metric).join(', '),
});
const urgentTasks = tasks.filter(
(t) =>
t.status !== 'done' &&
t.dueDate &&
new Date(t.dueDate) >= now &&
new Date(t.dueDate) <= in7Days,
);
if (urgentTasks.length > 0)
alerts.push({
id: 'tasks-urgent',
level: 'warning',
module: 'Sarcini',
message: `${urgentTasks.length} sarcini scadente în 7 zile`,
href: '/dashboard/tasks',
});
const partialTx = transactions.filter((t) => t.evidenceStatus === 'partial');
if (partialTx.length > 0)
alerts.push({
id: 'tx-partial',
level: 'warning',
module: 'Tranzacții',
message: `${partialTx.length} tranzacții documentate parțial`,
href: '/dashboard/transactions',
});
const pendingDecisions = decisions.filter((d) => !d.selectedOption);
if (pendingDecisions.length > 5)
alerts.push({
id: 'decisions-pending',
level: 'info',
module: 'Decizii',
message: `${pendingDecisions.length} decizii în așteptare`,
href: '/dashboard/decisions',
});
return (
<div className="max-w-3xl space-y-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Alerte & Scadențe</h1>
<p className="mt-1 text-sm text-ink-faint">
Monitorizare cross-modul {' '}
{alerts.length === 0
? 'Totul în regulă'
: `${alerts.length} alertă${alerts.length === 1 ? '' : 'e'} activă${alerts.length === 1 ? '' : 'e'}`}
</p>
</div>
{alerts.length === 0 ? (
<div className="card p-12 text-center">
<p className="font-display text-3xl text-signal-ok"></p>
<p className="mt-2 text-sm font-medium text-signal-ok">Nicio alertă activă</p>
<p className="mt-1 text-xs text-ink-faint">
Toate sarcinile, obiectivele, tranzacțiile și deciziile sunt în ordine.
</p>
</div>
) : (
<ul className="space-y-3">
{alerts.map((alert) => {
const cfg = LEVEL_CFG[alert.level];
return (
<li
key={alert.id}
className={`card flex items-start gap-4 border-l-4 p-4 ${cfg.borderClass}`}
>
<span
className={`mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold ${cfg.badgeClass}`}
>
{cfg.icon}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-ink-faint">
{alert.module}
</span>
<span
className={`rounded-full px-1.5 py-0.5 text-[9px] font-bold ${cfg.badgeClass}`}
>
{cfg.label}
</span>
</div>
<p className="mt-0.5 text-sm font-medium text-ink">{alert.message}</p>
{alert.detail && (
<p className="mt-0.5 truncate text-[11px] text-ink-faint">{alert.detail}</p>
)}
</div>
<Link
href={alert.href}
className="shrink-0 rounded-md border border-ink-line px-3 py-1.5 text-xs font-medium text-ink hover:bg-paper-sunken"
>
Vezi
</Link>
</li>
);
})}
</ul>
)}
</div>
);
}