diff --git a/src/modules/registratura/components/imminent-actions.tsx b/src/modules/registratura/components/imminent-actions.tsx new file mode 100644 index 0000000..8db7eda --- /dev/null +++ b/src/modules/registratura/components/imminent-actions.tsx @@ -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 ( +
+
+ + + Acțiuni iminente + + + {actions.length} + +
+
+ {actions.slice(0, 8).map((action, i) => { + const Icon = ICON_MAP[action.type]; + const colors = COLOR_MAP[action.color]; + return ( + + ); + })} + {actions.length > 8 && ( +

+ + {actions.length - 8} alte acțiuni +

+ )} +
+
+ ); +} diff --git a/src/modules/registratura/components/registratura-module.tsx b/src/modules/registratura/components/registratura-module.tsx index 81ab94e..543d6af 100644 --- a/src/modules/registratura/components/registratura-module.tsx +++ b/src/modules/registratura/components/registratura-module.tsx @@ -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() { + {viewMode === "list" && ( + + )} + {viewMode === "list" && ( <>
diff --git a/src/modules/registratura/components/registry-entry-detail.tsx b/src/modules/registratura/components/registry-entry-detail.tsx index 60577c5..1df9e6a 100644 --- a/src/modules/registratura/components/registry-entry-detail.tsx +++ b/src/modules/registratura/components/registry-entry-detail.tsx @@ -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({
+ {/* ── 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: , + label: `AC — Remindere dezactivate`, + detail: `Valabilitate ${validityMonths} luni, luna ${currentMonth}/${validityMonths}`, + color: "text-muted-foreground", + }); + } else if (daysLeft <= 30) { + alerts.push({ + icon: , + 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: , + 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: , + 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: , + 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: , + label: `Expiră în ${daysLeft} zile`, + detail: `Alertă setată la ${alertDays} zile înainte`, + color: "text-amber-600 dark:text-amber-400", + }); + } else { + alerts.push({ + icon: , + 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: , + 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: , + 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 ( + +
+ {alerts.map((alert, i) => ( +
+ {alert.icon} +
+

{alert.label}

+

{alert.detail}

+
+
+ ))} +
+
+ ); + })()} + {/* ── Parties ── */}
diff --git a/src/modules/registratura/components/registry-entry-form.tsx b/src/modules/registratura/components/registry-entry-form.tsx index 323bbb6..ac0097d 100644 --- a/src/modules/registratura/components/registry-entry-form.tsx +++ b/src/modules/registratura/components/registry-entry-form.tsx @@ -1307,6 +1307,28 @@ export function RegistryEntryForm({ onChange={(e) => setExpiryDate(e.target.value)} className="mt-1" /> + {date && ( +
+ {[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 ( + + ); + })} +
+ )}
{expiryDate && (
diff --git a/src/modules/registratura/components/thread-explorer.tsx b/src/modules/registratura/components/thread-explorer.tsx index c5a75f3..77b848b 100644 --- a/src/modules/registratura/components/thread-explorer.tsx +++ b/src/modules/registratura/components/thread-explorer.tsx @@ -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]);