feat(cc-049): Alerts & Deadlines — cross-module alert aggregation
This commit is contained in:
parent
7c2931b2c8
commit
43d6192428
1 changed files with 236 additions and 0 deletions
236
src/app/dashboard/alerts/page.tsx
Normal file
236
src/app/dashboard/alerts/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue