feat(registratura): add reminders section, expiry helpers, imminent actions dashboard
- Add "Remindere & Alerte" section in entry detail panel showing AC validity, expiry date, and tracked deadline status with color-coded indicators - Add quick expiry date buttons (6/12/24 months from document date) in entry form - Default dosare view to show only active threads - Add ImminentActions dashboard component showing urgent items (expired docs, AC validity warnings, overdue/imminent deadlines) sorted by urgency Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { AlertTriangle, Bell, Clock, Timer, ChevronRight } from "lucide-react";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import type { RegistryEntry } from "../types";
|
||||
import { getDeadlineType } from "../services/deadline-catalog";
|
||||
|
||||
interface ImminentActionsProps {
|
||||
entries: RegistryEntry[];
|
||||
onNavigate?: (entry: RegistryEntry) => void;
|
||||
}
|
||||
|
||||
interface ActionItem {
|
||||
entry: RegistryEntry;
|
||||
type: "expiry" | "ac-validity" | "deadline-overdue" | "deadline-soon";
|
||||
label: string;
|
||||
detail: string;
|
||||
daysLeft: number;
|
||||
color: "red" | "amber" | "blue";
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("ro-RO", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export function ImminentActions({ entries, onNavigate }: ImminentActionsProps) {
|
||||
const actions = useMemo(() => {
|
||||
const items: ActionItem[] = [];
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
const nowMs = now.getTime();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.status !== "deschis") continue;
|
||||
|
||||
// Expiry date alerts
|
||||
if (entry.expiryDate) {
|
||||
const expiry = new Date(entry.expiryDate);
|
||||
expiry.setHours(0, 0, 0, 0);
|
||||
const daysLeft = Math.ceil((expiry.getTime() - nowMs) / (1000 * 60 * 60 * 24));
|
||||
const alertDays = entry.expiryAlertDays ?? 30;
|
||||
|
||||
if (daysLeft < 0) {
|
||||
items.push({
|
||||
entry,
|
||||
type: "expiry",
|
||||
label: `Expirat de ${Math.abs(daysLeft)} zile`,
|
||||
detail: `Valabilitate: ${formatDate(entry.expiryDate)}`,
|
||||
daysLeft,
|
||||
color: "red",
|
||||
});
|
||||
} else if (daysLeft <= alertDays) {
|
||||
items.push({
|
||||
entry,
|
||||
type: "expiry",
|
||||
label: `Expiră în ${daysLeft} zile`,
|
||||
detail: `Valabilitate: ${formatDate(entry.expiryDate)}`,
|
||||
daysLeft,
|
||||
color: daysLeft <= 7 ? "red" : "amber",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// AC validity alerts
|
||||
if (entry.acValidity?.enabled && !entry.acValidity.reminder.dismissed) {
|
||||
const ac = entry.acValidity;
|
||||
const validityMonths = ac.validityMonths ?? 12;
|
||||
const issuance = new Date(ac.issuanceDate);
|
||||
const validityEnd = new Date(issuance);
|
||||
validityEnd.setMonth(validityEnd.getMonth() + validityMonths);
|
||||
validityEnd.setHours(0, 0, 0, 0);
|
||||
const daysLeft = Math.ceil((validityEnd.getTime() - nowMs) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysLeft <= 90) {
|
||||
items.push({
|
||||
entry,
|
||||
type: "ac-validity",
|
||||
label: daysLeft < 0
|
||||
? `AC expirat de ${Math.abs(daysLeft)} zile`
|
||||
: `AC expiră în ${daysLeft} zile`,
|
||||
detail: `Valabilitate ${validityMonths} luni de la ${formatDate(ac.issuanceDate)}`,
|
||||
daysLeft,
|
||||
color: daysLeft <= 30 ? "red" : "amber",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Tracked deadlines
|
||||
const pending = (entry.trackedDeadlines ?? []).filter(d => d.resolution === "pending");
|
||||
for (const dl of pending) {
|
||||
const due = new Date(dl.dueDate);
|
||||
due.setHours(0, 0, 0, 0);
|
||||
const daysLeft = Math.ceil((due.getTime() - nowMs) / (1000 * 60 * 60 * 24));
|
||||
const typeDef = getDeadlineType(dl.typeId);
|
||||
const typeLabel = typeDef?.label ?? dl.typeId;
|
||||
|
||||
if (daysLeft < 0) {
|
||||
items.push({
|
||||
entry,
|
||||
type: "deadline-overdue",
|
||||
label: `Termen depășit: ${typeLabel}`,
|
||||
detail: `Scadent: ${formatDate(dl.dueDate)} (${Math.abs(daysLeft)} zile depășit)`,
|
||||
daysLeft,
|
||||
color: "red",
|
||||
});
|
||||
} else if (daysLeft <= 5) {
|
||||
items.push({
|
||||
entry,
|
||||
type: "deadline-soon",
|
||||
label: `Termen iminent: ${typeLabel}`,
|
||||
detail: `Scadent: ${formatDate(dl.dueDate)} (${daysLeft === 0 ? "azi" : `${daysLeft} zile`})`,
|
||||
daysLeft,
|
||||
color: daysLeft <= 1 ? "red" : "amber",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: most urgent first (most negative daysLeft)
|
||||
items.sort((a, b) => a.daysLeft - b.daysLeft);
|
||||
|
||||
return items;
|
||||
}, [entries]);
|
||||
|
||||
if (actions.length === 0) return null;
|
||||
|
||||
const ICON_MAP = {
|
||||
expiry: Timer,
|
||||
"ac-validity": Bell,
|
||||
"deadline-overdue": AlertTriangle,
|
||||
"deadline-soon": Clock,
|
||||
} as const;
|
||||
|
||||
const COLOR_MAP = {
|
||||
red: {
|
||||
border: "border-red-200 dark:border-red-900",
|
||||
bg: "bg-red-50/60 dark:bg-red-950/20",
|
||||
text: "text-red-700 dark:text-red-400",
|
||||
badge: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400",
|
||||
},
|
||||
amber: {
|
||||
border: "border-amber-200 dark:border-amber-900",
|
||||
bg: "bg-amber-50/60 dark:bg-amber-950/20",
|
||||
text: "text-amber-700 dark:text-amber-400",
|
||||
badge: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400",
|
||||
},
|
||||
blue: {
|
||||
border: "border-blue-200 dark:border-blue-900",
|
||||
bg: "bg-blue-50/60 dark:bg-blue-950/20",
|
||||
text: "text-blue-700 dark:text-blue-400",
|
||||
badge: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400",
|
||||
},
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-200 dark:border-amber-900 bg-amber-50/30 dark:bg-amber-950/10 p-3">
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
<span className="text-sm font-semibold text-amber-800 dark:text-amber-300">
|
||||
Acțiuni iminente
|
||||
</span>
|
||||
<Badge className="text-[10px] px-1.5 py-0 bg-amber-200 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300">
|
||||
{actions.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{actions.slice(0, 8).map((action, i) => {
|
||||
const Icon = ICON_MAP[action.type];
|
||||
const colors = COLOR_MAP[action.color];
|
||||
return (
|
||||
<button
|
||||
key={`${action.entry.id}-${action.type}-${i}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 rounded-md border px-3 py-1.5 text-left transition-colors hover:opacity-80",
|
||||
colors.border,
|
||||
colors.bg,
|
||||
)}
|
||||
onClick={() => onNavigate?.(action.entry)}
|
||||
>
|
||||
<Icon className={cn("h-3.5 w-3.5 shrink-0", colors.text)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[10px] text-muted-foreground shrink-0">
|
||||
{action.entry.number}
|
||||
</span>
|
||||
<span className={cn("text-xs font-medium truncate", colors.text)}>
|
||||
{action.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground truncate">
|
||||
{action.entry.subject}
|
||||
{action.detail ? ` — ${action.detail}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{actions.length > 8 && (
|
||||
<p className="text-[10px] text-muted-foreground text-center pt-1">
|
||||
+ {actions.length - 8} alte acțiuni
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import { DeadlineDashboard } from "./deadline-dashboard";
|
||||
import { DeadlineConfigOverview } from "./deadline-config-overview";
|
||||
import { ThreadExplorer } from "./thread-explorer";
|
||||
import { CloseGuardDialog } from "./close-guard-dialog";
|
||||
import { ImminentActions } from "./imminent-actions";
|
||||
import { getOverdueDays } from "../services/registry-service";
|
||||
import {
|
||||
aggregateDeadlines,
|
||||
@@ -452,6 +453,13 @@ export function RegistraturaModule() {
|
||||
<StatCard label="Intrate" value={intrat} />
|
||||
</div>
|
||||
|
||||
{viewMode === "list" && (
|
||||
<ImminentActions
|
||||
entries={allEntries}
|
||||
onNavigate={handleView}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === "list" && (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
Bell,
|
||||
BellOff,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
@@ -24,6 +26,9 @@ import {
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
AlertTriangle,
|
||||
ShieldAlert,
|
||||
Timer,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
@@ -50,6 +55,7 @@ import {
|
||||
} from "./attachment-preview";
|
||||
import { findAuthorityForContact } from "../services/authority-catalog";
|
||||
import { computeTransmissionStatus } from "../services/deadline-service";
|
||||
import { getDeadlineType } from "../services/deadline-catalog";
|
||||
import { StatusMonitorConfig } from "./status-monitor-config";
|
||||
import { FlowDiagram } from "./flow-diagram";
|
||||
import { DeadlineTimeline } from "./deadline-timeline";
|
||||
@@ -399,6 +405,149 @@ export function RegistryEntryDetail({
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
{/* ── Reminders / Alerts summary ── */}
|
||||
{(() => {
|
||||
const alerts: { icon: React.ReactNode; label: string; detail: string; color: string }[] = [];
|
||||
|
||||
// AC validity reminder
|
||||
if (entry.acValidity?.enabled) {
|
||||
const ac = entry.acValidity;
|
||||
const validityMonths = ac.validityMonths ?? 12;
|
||||
const issuance = new Date(ac.issuanceDate);
|
||||
const validityEnd = new Date(issuance);
|
||||
validityEnd.setMonth(validityEnd.getMonth() + validityMonths);
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
validityEnd.setHours(0, 0, 0, 0);
|
||||
const daysLeft = Math.ceil((validityEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const monthsLeft = Math.ceil(daysLeft / 30);
|
||||
const currentMonth = Math.max(1, validityMonths - monthsLeft + 1);
|
||||
|
||||
if (ac.reminder.dismissed) {
|
||||
alerts.push({
|
||||
icon: <BellOff className="h-3.5 w-3.5" />,
|
||||
label: `AC — Remindere dezactivate`,
|
||||
detail: `Valabilitate ${validityMonths} luni, luna ${currentMonth}/${validityMonths}`,
|
||||
color: "text-muted-foreground",
|
||||
});
|
||||
} else if (daysLeft <= 30) {
|
||||
alerts.push({
|
||||
icon: <ShieldAlert className="h-3.5 w-3.5" />,
|
||||
label: `AC — Expiră în ${daysLeft} zile!`,
|
||||
detail: `Luna ${currentMonth}/${validityMonths} — acțiune imediată`,
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
});
|
||||
} else if (daysLeft <= 90) {
|
||||
alerts.push({
|
||||
icon: <Bell className="h-3.5 w-3.5" />,
|
||||
label: `AC — Reminder activ (luna ${currentMonth}/${validityMonths})`,
|
||||
detail: `Mai sunt ${daysLeft} zile (${monthsLeft} luni)`,
|
||||
color: "text-amber-600 dark:text-amber-400",
|
||||
});
|
||||
} else {
|
||||
alerts.push({
|
||||
icon: <Bell className="h-3.5 w-3.5" />,
|
||||
label: `AC — Reminder activ (luna ${currentMonth}/${validityMonths})`,
|
||||
detail: `Mai sunt ${daysLeft} zile`,
|
||||
color: "text-emerald-600 dark:text-emerald-400",
|
||||
});
|
||||
}
|
||||
if (ac.reminder.snoozeCount > 0) {
|
||||
const lastAlert = alerts[alerts.length - 1];
|
||||
if (lastAlert) {
|
||||
lastAlert.detail += ` · Amânat de ${ac.reminder.snoozeCount}×`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expiry date alert
|
||||
if (entry.expiryDate && entry.status === "deschis") {
|
||||
const expiry = new Date(entry.expiryDate);
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
expiry.setHours(0, 0, 0, 0);
|
||||
const daysLeft = Math.ceil((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const alertDays = entry.expiryAlertDays ?? 30;
|
||||
|
||||
if (daysLeft < 0) {
|
||||
alerts.push({
|
||||
icon: <AlertTriangle className="h-3.5 w-3.5" />,
|
||||
label: `Document expirat de ${Math.abs(daysLeft)} zile`,
|
||||
detail: `Expirat: ${formatDate(entry.expiryDate)}`,
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
});
|
||||
} else if (daysLeft <= alertDays) {
|
||||
alerts.push({
|
||||
icon: <Timer className="h-3.5 w-3.5" />,
|
||||
label: `Expiră în ${daysLeft} zile`,
|
||||
detail: `Alertă setată la ${alertDays} zile înainte`,
|
||||
color: "text-amber-600 dark:text-amber-400",
|
||||
});
|
||||
} else {
|
||||
alerts.push({
|
||||
icon: <Timer className="h-3.5 w-3.5" />,
|
||||
label: `Valabil — expiră ${formatDate(entry.expiryDate)}`,
|
||||
detail: `Mai sunt ${daysLeft} zile (alertă la ${alertDays} zile)`,
|
||||
color: "text-emerald-600 dark:text-emerald-400",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Tracked deadlines summary
|
||||
const pendingDeadlines = (entry.trackedDeadlines ?? []).filter(d => d.resolution === "pending");
|
||||
if (pendingDeadlines.length > 0) {
|
||||
const overdueCount = pendingDeadlines.filter(d => {
|
||||
const due = new Date(d.dueDate);
|
||||
due.setHours(0, 0, 0, 0);
|
||||
return due.getTime() < new Date().setHours(0, 0, 0, 0);
|
||||
}).length;
|
||||
|
||||
if (overdueCount > 0) {
|
||||
alerts.push({
|
||||
icon: <AlertTriangle className="h-3.5 w-3.5" />,
|
||||
label: `${overdueCount} ${overdueCount === 1 ? "termen depășit" : "termene depășite"}`,
|
||||
detail: `${pendingDeadlines.length} ${pendingDeadlines.length === 1 ? "termen activ" : "termene active"} total`,
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
});
|
||||
} else {
|
||||
alerts.push({
|
||||
icon: <Clock className="h-3.5 w-3.5" />,
|
||||
label: `${pendingDeadlines.length} ${pendingDeadlines.length === 1 ? "termen activ" : "termene active"}`,
|
||||
detail: pendingDeadlines.map(d => `${getDeadlineType(d.typeId)?.label ?? d.typeId} (${formatDate(d.dueDate)})`).join(", "),
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (alerts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<DetailSection title="Remindere & Alerte">
|
||||
<div className="space-y-2">
|
||||
{alerts.map((alert, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex items-start gap-2.5 rounded-md border px-3 py-2",
|
||||
alert.color.includes("red") && "border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20",
|
||||
alert.color.includes("amber") && "border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20",
|
||||
alert.color.includes("emerald") && "border-emerald-200 bg-emerald-50/50 dark:border-emerald-900 dark:bg-emerald-950/20",
|
||||
alert.color.includes("blue") && "border-blue-200 bg-blue-50/50 dark:border-blue-900 dark:bg-blue-950/20",
|
||||
alert.color.includes("muted") && "border-muted",
|
||||
)}
|
||||
>
|
||||
<span className={cn("mt-0.5 shrink-0", alert.color)}>{alert.icon}</span>
|
||||
<div className="min-w-0">
|
||||
<p className={cn("text-sm font-medium", alert.color)}>{alert.label}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{alert.detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DetailSection>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* ── Parties ── */}
|
||||
<DetailSection title="Părți">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
|
||||
@@ -1307,6 +1307,28 @@ export function RegistryEntryForm({
|
||||
onChange={(e) => setExpiryDate(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
{date && (
|
||||
<div className="flex gap-1.5 mt-1.5">
|
||||
{[6, 12, 24].map((months) => {
|
||||
const base = new Date(date);
|
||||
base.setMonth(base.getMonth() + months);
|
||||
const iso = base.toISOString().slice(0, 10);
|
||||
const isSelected = expiryDate === iso;
|
||||
return (
|
||||
<Button
|
||||
key={months}
|
||||
type="button"
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[10px]"
|
||||
onClick={() => setExpiryDate(iso)}
|
||||
>
|
||||
{months} luni
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{expiryDate && (
|
||||
<div>
|
||||
|
||||
@@ -126,7 +126,7 @@ export function ThreadExplorer({
|
||||
onNavigateEntry,
|
||||
}: ThreadExplorerProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeOnly, setActiveOnly] = useState(false);
|
||||
const [activeOnly, setActiveOnly] = useState(true);
|
||||
|
||||
const threads = useMemo(() => buildThreads(entries), [entries]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user