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