diff --git a/src/modules/registratura/components/deadline-dashboard.tsx b/src/modules/registratura/components/deadline-dashboard.tsx index 7416769..bf4ac82 100644 --- a/src/modules/registratura/components/deadline-dashboard.tsx +++ b/src/modules/registratura/components/deadline-dashboard.tsx @@ -1,28 +1,23 @@ "use client"; import { useState, useMemo } from "react"; -import { Card, CardContent } from "@/shared/components/ui/card"; import { Badge } from "@/shared/components/ui/badge"; -import { Label } from "@/shared/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/components/ui/select"; import { Button } from "@/shared/components/ui/button"; +import { CheckCircle2, ChevronDown, ChevronRight } from "lucide-react"; import type { RegistryEntry, TrackedDeadline, DeadlineResolution, - DeadlineCategory, } from "../types"; -import { aggregateDeadlines } from "../services/deadline-service"; -import { CATEGORY_LABELS, getDeadlineType } from "../services/deadline-catalog"; -import { useDeadlineFilters } from "../hooks/use-deadline-filters"; -import { DeadlineTable } from "./deadline-table"; +import { + aggregateDeadlines, + groupDeadlinesByEntry, + type DeadlineEntryGroup, + type DeadlineDisplayStatus, +} from "../services/deadline-service"; +import { getDeadlineType } from "../services/deadline-catalog"; import { DeadlineResolveDialog } from "./deadline-resolve-dialog"; +import { cn } from "@/shared/lib/utils"; interface DeadlineDashboardProps { entries: RegistryEntry[]; @@ -41,12 +36,26 @@ interface DeadlineDashboardProps { ) => void; } -const RESOLUTION_LABELS: Record = { - pending: "În așteptare", - completed: "Finalizat", - "aprobat-tacit": "Aprobat tacit", - respins: "Respins", - anulat: "Anulat", +const VARIANT_DOT: Record = { + green: "bg-green-500", + yellow: "bg-amber-500", + red: "bg-red-500", + blue: "bg-blue-500", + gray: "bg-muted-foreground", +}; + +const VARIANT_TEXT: Record = { + green: "text-green-700 dark:text-green-400", + yellow: "text-amber-700 dark:text-amber-400", + red: "text-red-700 dark:text-red-400", + blue: "text-blue-700 dark:text-blue-400", + gray: "text-muted-foreground", +}; + +const VARIANT_BG: Record = { + red: "border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20", + yellow: + "border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20", }; export function DeadlineDashboard({ @@ -54,34 +63,37 @@ export function DeadlineDashboard({ onResolveDeadline, onAddChainedDeadline, }: DeadlineDashboardProps) { - const { filters, updateFilter } = useDeadlineFilters(); + const [urgentOnly, setUrgentOnly] = useState(false); const [resolvingEntry, setResolvingEntry] = useState(null); const [resolvingDeadline, setResolvingDeadline] = useState(null); + const [expandedChains, setExpandedChains] = useState>(new Set()); const stats = useMemo(() => aggregateDeadlines(entries), [entries]); + const groups = useMemo(() => groupDeadlinesByEntry(entries), [entries]); - const filteredRows = useMemo(() => { - return stats.all.filter((row) => { - if (filters.category !== "all") { - const def = getDeadlineType(row.deadline.typeId); - if (def && def.category !== filters.category) return false; - } - if (filters.resolution !== "all") { - // Map tacit display status to actual resolution filter - if (filters.resolution === "pending") { - if (row.deadline.resolution !== "pending") return false; - } else if (row.deadline.resolution !== filters.resolution) { - return false; - } - } - if (filters.urgentOnly) { - if (row.status.variant !== "yellow" && row.status.variant !== "red") - return false; - } - return true; + const filteredGroups = useMemo(() => { + if (!urgentOnly) return groups; + return groups + .map((g) => ({ + ...g, + mainDeadlines: g.mainDeadlines.filter( + (m) => + m.deadline.resolution === "pending" && + (m.status.variant === "yellow" || m.status.variant === "red"), + ), + })) + .filter((g) => g.mainDeadlines.length > 0); + }, [groups, urgentOnly]); + + const toggleChain = (deadlineId: string) => { + setExpandedChains((prev) => { + const next = new Set(prev); + if (next.has(deadlineId)) next.delete(deadlineId); + else next.add(deadlineId); + return next; }); - }, [stats.all, filters]); + }; const handleResolveClick = (entryId: string, deadline: TrackedDeadline) => { setResolvingEntry(entryId); @@ -102,7 +114,6 @@ export function DeadlineDashboard({ chainNext, ); - // Handle chain creation if (chainNext) { const def = getDeadlineType(resolvingDeadline.typeId); if (def?.chainNextTypeId) { @@ -122,116 +133,52 @@ export function DeadlineDashboard({ return (
- {/* Stats */} -
- - 0 ? "destructive" : undefined} + {/* Summary bar */} +
+ - 0 ? "destructive" : undefined} - /> - 0 ? "blue" : undefined} - /> - 0 ? "destructive" : undefined} - /> - 0 ? "destructive" : undefined} + + + +
+ +
- {/* Alert banners */} - {stats.missingRecipientReg > 0 && ( -
- ⚠ {stats.missingRecipientReg} ieșir - {stats.missingRecipientReg === 1 ? "e" : "i"} cu termene legale nu{" "} - {stats.missingRecipientReg === 1 ? "are" : "au"} completat - numărul/data de înregistrare la destinatar. Termenele legale nu - pornesc fără aceste date. + {/* Grouped deadline list */} + {filteredGroups.length === 0 ? ( +

+ {urgentOnly + ? "Niciun termen urgent sau depasit." + : "Niciun termen legal activ."} +

+ ) : ( +
+ {filteredGroups.map((group) => ( + + ))}
)} - {stats.expiringSoon > 0 && ( -
- ⚠ {stats.expiringSoon} document{stats.expiringSoon === 1 ? "" : "e"}{" "} - cu termen de valabilitate se apropie de expirare sau{" "} - {stats.expiringSoon === 1 ? "a" : "au"} expirat. Inițiați procedurile - de prelungire. -
- )} - - {/* Filters */} -
-
- - -
-
- - -
- -
- - {/* Table */} - - -

- {filteredRows.length} din {stats.all.length} termene afișate -

- -

{label}

-

0 ? "text-destructive" : "" - }${variant === "blue" && value > 0 ? "text-blue-600" : ""}`} - > - {value} -

-
- +
+ + + {count} + + {label} +
+ ); +} + +// ── Entry card with its deadlines ── + +function EntryDeadlineCard({ + group, + expandedChains, + onToggleChain, + onResolve, +}: { + group: DeadlineEntryGroup; + expandedChains: Set; + onToggleChain: (id: string) => void; + onResolve: (entryId: string, deadline: TrackedDeadline) => void; +}) { + const { entry, mainDeadlines } = group; + + // Worst status for card border color + const worstVariant = mainDeadlines.reduce((worst, m) => { + if (m.deadline.resolution !== "pending") return worst; + const order: Record = { + red: 0, + yellow: 1, + blue: 2, + green: 3, + gray: 4, + }; + const cur = order[m.status.variant] ?? 4; + const w = order[worst] ?? 4; + return cur < w ? m.status.variant : worst; + }, "green"); + + return ( +
+ {/* Entry header */} +
+ + {entry.number} + + | + {entry.subject} + + {entry.direction === "intrat" ? "Intrat" : "Iesit"} + +
+ + {/* Deadlines */} +
+ {mainDeadlines.map((item) => { + const isExpanded = expandedChains.has(item.deadline.id); + const chainCount = item.chainChildren.length; + + return ( +
+ onToggleChain(item.deadline.id)} + onResolve={onResolve} + /> + {/* Chain children (expanded) */} + {isExpanded && + chainCount > 0 && + item.chainChildren.map((child) => ( + + ))} +
+ ); + })} +
+
+ ); +} + +// ── Single deadline row ── + +function DeadlineRow({ + deadline, + status, + entryId, + chainCount, + isExpanded, + isChainChild, + onToggleChain, + onResolve, +}: { + deadline: TrackedDeadline; + status: DeadlineDisplayStatus; + entryId: string; + chainCount?: number; + isExpanded?: boolean; + isChainChild?: boolean; + onToggleChain?: () => void; + onResolve: (entryId: string, deadline: TrackedDeadline) => void; +}) { + const def = getDeadlineType(deadline.typeId); + const isPending = deadline.resolution === "pending"; + + // Progress calculation + const totalDays = (() => { + const start = new Date(deadline.startDate).getTime(); + const due = new Date(deadline.dueDate).getTime(); + return Math.max(1, Math.ceil((due - start) / (1000 * 60 * 60 * 24))); + })(); + + const elapsedDays = (() => { + const start = new Date(deadline.startDate).getTime(); + const now = Date.now(); + return Math.ceil((now - start) / (1000 * 60 * 60 * 24)); + })(); + + const progressPct = isPending + ? Math.min(100, Math.max(0, (elapsedDays / totalDays) * 100)) + : 100; + + return ( +
+ {/* Chain expand toggle */} + {!isChainChild && (chainCount ?? 0) > 0 ? ( + + ) : isChainChild ? ( + + ) : ( + + )} + + {/* Type label */} +
+ + {def?.label ?? deadline.typeId} + + {!isChainChild && (chainCount ?? 0) > 0 && ( + + (+{chainCount} etap{chainCount === 1 ? "a" : "e"}) + + )} +
+ + {/* Progress bar (only for pending) */} + {isPending && ( +
+
+
+
+
+ )} + + {/* Countdown / status */} +
+ {isPending && status.daysRemaining !== null ? ( + + {status.daysRemaining < 0 + ? `${Math.abs(status.daysRemaining)}z depasit` + : status.daysRemaining === 0 + ? "azi" + : `${status.daysRemaining}z ramase`} + + ) : ( + + {status.label} + + )} +
+ + {/* Resolve button */} +
+ {isPending && ( + + )} +
+
); } diff --git a/src/modules/registratura/components/flow-diagram.tsx b/src/modules/registratura/components/flow-diagram.tsx new file mode 100644 index 0000000..3ce686c --- /dev/null +++ b/src/modules/registratura/components/flow-diagram.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { useMemo } from "react"; +import { Badge } from "@/shared/components/ui/badge"; +import type { RegistryEntry } from "../types"; +import { cn } from "@/shared/lib/utils"; + +// ── Types ── + +interface FlowNode { + entry: RegistryEntry; + /** Direct thread children (replied via "Inchide") */ + threadChildren: FlowNode[]; + /** Linked/conex entries (not part of thread chain) */ + linkedEntries: RegistryEntry[]; +} + +export interface FlowDiagramProps { + /** The chain of entries (thread root + descendants), chronologically ordered */ + chain: RegistryEntry[]; + /** All entries in the system (for resolving linkedEntryIds) */ + allEntries: RegistryEntry[]; + /** Highlight this entry (current selection in detail panel) */ + highlightEntryId?: string; + /** Compact mode for detail panel (smaller nodes) */ + compact?: boolean; + /** Click handler for a node */ + onNodeClick?: (entry: RegistryEntry) => void; + /** Max nodes to show (for compact mode) */ + maxNodes?: number; +} + +// ── Build tree from flat chain ── + +function buildFlowTree( + chain: RegistryEntry[], + allEntries: RegistryEntry[], +): FlowNode | null { + if (chain.length === 0) return null; + + // Find root (entry without threadParentId in the chain, or first entry) + const chainIds = new Set(chain.map((e) => e.id)); + const root = + chain.find((e) => !e.threadParentId || !chainIds.has(e.threadParentId)) ?? + chain[0]; + if (!root) return null; + + const entryMap = new Map(chain.map((e) => [e.id, e])); + + function buildNode(entry: RegistryEntry): FlowNode { + // Thread children: entries whose threadParentId is this entry + const threadChildren = chain + .filter((e) => e.threadParentId === entry.id) + .sort((a, b) => a.date.localeCompare(b.date)) + .map((child) => buildNode(child)); + + // Linked entries: resolve linkedEntryIds that aren't in the thread chain + const linkedEntries = (entry.linkedEntryIds ?? []) + .filter((id) => !entryMap.has(id)) // exclude entries already in the chain + .map((id) => allEntries.find((e) => e.id === id)) + .filter((e): e is RegistryEntry => !!e); + + return { entry, threadChildren, linkedEntries }; + } + + return buildNode(root); +} + +// ── Flatten tree to a linear main chain + branches ── + +interface FlattenedChain { + mainChain: RegistryEntry[]; + branches: Map; // parentId -> linked entries +} + +function flattenTree(root: FlowNode): FlattenedChain { + const mainChain: RegistryEntry[] = []; + const branches = new Map(); + + function walkMain(node: FlowNode) { + mainChain.push(node.entry); + + if (node.linkedEntries.length > 0) { + branches.set(node.entry.id, node.linkedEntries); + } + + // Follow the first thread child as "main" chain, others as branches + if (node.threadChildren.length > 0) { + walkMain(node.threadChildren[0]!); + // Additional thread children treated as branches + for (let i = 1; i < node.threadChildren.length; i++) { + const child = node.threadChildren[i]!; + const branchEntries = branches.get(node.entry.id) ?? []; + branchEntries.push(child.entry); + branches.set(node.entry.id, branchEntries); + } + } + } + + walkMain(root); + return { mainChain, branches }; +} + +// ── Component ── + +export function FlowDiagram({ + chain, + allEntries, + highlightEntryId, + compact = false, + onNodeClick, + maxNodes, +}: FlowDiagramProps) { + const { mainChain, branches, truncated } = useMemo(() => { + const tree = buildFlowTree(chain, allEntries); + if (!tree) return { mainChain: [], branches: new Map(), truncated: 0 }; + + const flat = flattenTree(tree); + let mc = flat.mainChain; + let trunc = 0; + + if (maxNodes && mc.length > maxNodes) { + // Show entries around the highlighted one, or the last N + if (highlightEntryId) { + const idx = mc.findIndex((e) => e.id === highlightEntryId); + if (idx >= 0) { + const start = Math.max(0, idx - Math.floor(maxNodes / 2)); + const end = Math.min(mc.length, start + maxNodes); + trunc = mc.length - (end - start); + mc = mc.slice(start, end); + } else { + trunc = mc.length - maxNodes; + mc = mc.slice(-maxNodes); + } + } else { + trunc = mc.length - maxNodes; + mc = mc.slice(-maxNodes); + } + } + + return { mainChain: mc, branches: flat.branches, truncated: trunc }; + }, [chain, allEntries, highlightEntryId, maxNodes]); + + if (mainChain.length === 0) { + return ( +

Niciun document in lant.

+ ); + } + + return ( +
+ {truncated > 0 && ( +
+ + +{truncated} ... + + +
+ )} + + {mainChain.map((entry, i) => { + const branchEntries: RegistryEntry[] = branches.get(entry.id) ?? []; + const isHighlighted = entry.id === highlightEntryId; + const isLast = i === mainChain.length - 1; + + return ( +
+
+ {/* Main node */} + onNodeClick?.(entry)} + /> + {/* Branch entries below */} + {branchEntries.length > 0 && ( +
+
+ {branchEntries.map((be) => ( + onNodeClick?.(be)} + /> + ))} +
+ )} +
+ {/* Arrow to next */} + {!isLast && ( +
+ +
+ )} +
+ ); + })} +
+ ); +} + +// ── Arrow connector ── + +function Arrow() { + return ( +
+
+
+
+ ); +} + +// ── Single flow node ── + +function FlowNode({ + entry, + isHighlighted, + compact, + isBranch, + onClick, +}: { + entry: RegistryEntry; + isHighlighted?: boolean; + compact?: boolean; + isBranch?: boolean; + onClick?: () => void; +}) { + const isIntrat = entry.direction === "intrat"; + const isOpen = entry.status === "deschis"; + const hasDeadlines = (entry.trackedDeadlines ?? []).some( + (dl) => dl.resolution === "pending", + ); + + return ( + + ); +} + +function formatShortDate(iso: string): string { + try { + const d = new Date(iso); + return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}.${d.getFullYear()}`; + } catch { + return iso; + } +} diff --git a/src/modules/registratura/components/registratura-module.tsx b/src/modules/registratura/components/registratura-module.tsx index f68acd2..50a2328 100644 --- a/src/modules/registratura/components/registratura-module.tsx +++ b/src/modules/registratura/components/registratura-module.tsx @@ -41,7 +41,11 @@ import { DeadlineDashboard } from "./deadline-dashboard"; import { ThreadExplorer } from "./thread-explorer"; import { CloseGuardDialog } from "./close-guard-dialog"; import { getOverdueDays } from "../services/registry-service"; -import { aggregateDeadlines } from "../services/deadline-service"; +import { + aggregateDeadlines, + findAutoResolvableDeadlines, + resolveDeadline as resolveDeadlinePure, +} from "../services/deadline-service"; import type { RegistryEntry, DeadlineResolution, ClosureInfo } from "../types"; import type { AddressContact } from "@/modules/address-book/types"; @@ -142,22 +146,39 @@ export function RegistraturaModule() { const handleAdd = async ( data: Omit, ) => { - await addEntry(data); + const newEntry = await addEntry(data); // If this new entry closes a parent, close the parent automatically if (closesEntryId) { const parentEntry = allEntries.find((e) => e.id === closesEntryId); if (parentEntry && parentEntry.status === "deschis") { + // Auto-resolve matching deadlines on the parent + const replyProxy = { ...data, id: "", number: "", createdAt: "", updatedAt: "" } as RegistryEntry; + const resolvable = findAutoResolvableDeadlines(parentEntry, replyProxy); + let updatedDeadlines = parentEntry.trackedDeadlines ?? []; + if (resolvable.length > 0) { + const resolveIds = new Set(resolvable.map((d) => d.id)); + updatedDeadlines = updatedDeadlines.map((dl) => + resolveIds.has(dl.id) + ? resolveDeadlinePure( + dl, + "completed", + `Rezolvat automat — primit raspuns ${newEntry?.number ?? "conex"}`, + ) + : dl, + ); + } + const closureInfo: ClosureInfo = { resolution: "finalizat", reason: "Inchis prin inregistrare conex", closedBy: "", closedAt: new Date().toISOString(), - hadActiveDeadlines: - (parentEntry.trackedDeadlines ?? []).some( - (d) => d.resolution === "pending", - ), + hadActiveDeadlines: resolvable.length > 0, }; - await updateEntry(closesEntryId, { closureInfo }); + await updateEntry(closesEntryId, { + closureInfo, + trackedDeadlines: updatedDeadlines, + }); await closeEntry(closesEntryId, false); } setClosesEntryId(null); @@ -320,7 +341,7 @@ export function RegistraturaModule() { Registru - Fire conversație + Dosare Termene legale {alertCount > 0 && ( diff --git a/src/modules/registratura/components/registry-entry-detail.tsx b/src/modules/registratura/components/registry-entry-detail.tsx index 2c71c7d..348b86c 100644 --- a/src/modules/registratura/components/registry-entry-detail.tsx +++ b/src/modules/registratura/components/registry-entry-detail.tsx @@ -49,7 +49,9 @@ import { getPreviewableAttachments, } from "./attachment-preview"; import { findAuthorityForContact } from "../services/authority-catalog"; +import { computeTransmissionStatus } from "../services/deadline-service"; import { StatusMonitorConfig } from "./status-monitor-config"; +import { FlowDiagram } from "./flow-diagram"; interface RegistryEntryDetailProps { entry: RegistryEntry | null; @@ -191,6 +193,36 @@ export function RegistryEntryDetail({ (e) => e.threadParentId === entry.id, ); + // Build full chain for mini flow diagram + const threadChain = useMemo(() => { + if (!threadParent && threadChildren.length === 0) return []; + const byId = new Map(allEntries.map((e) => [e.id, e])); + // Walk up to root + let root = entry; + while (root.threadParentId) { + const parent = byId.get(root.threadParentId); + if (!parent) break; + root = parent; + } + // BFS down from root + const chain: RegistryEntry[] = []; + const queue = [root.id]; + const visited = new Set(); + while (queue.length > 0) { + const id = queue.shift()!; + if (visited.has(id)) continue; + visited.add(id); + const e = byId.get(id); + if (!e) continue; + chain.push(e); + for (const child of allEntries.filter((c) => c.threadParentId === id)) { + queue.push(child.id); + } + } + chain.sort((a, b) => a.date.localeCompare(b.date)); + return chain; + }, [entry, threadParent, threadChildren, allEntries]); + return ( - {/* ── Thread links ── */} - {(threadParent || - threadChildren.length > 0 || + {/* ── Thread links (flow diagram) ── */} + {(threadChain.length >= 2 || (entry.linkedEntryIds ?? []).length > 0) && ( - - {threadParent && ( -
- - Răspuns la: - - {threadParent.number} - - - — {threadParent.subject} - -
+ + {threadChain.length >= 2 && ( + )} + {/* Transmission status for thread children */} {threadChildren.length > 0 && ( -
-

- Răspunsuri ({threadChildren.length}): -

- {threadChildren.map((child) => ( -
- {child.number} - - {child.subject} - -
- ))} +
+ {threadChildren.map((child) => { + const txStatus = computeTransmissionStatus(entry, child); + if (!txStatus) return null; + return ( +
+ {child.number} + {txStatus === "on-time" ? ( + + Transmis in termen + + ) : ( + + Transmis cu intarziere + + )} +
+ ); + })}
)} {(entry.linkedEntryIds ?? []).length > 0 && ( -
+

- {entry.linkedEntryIds.length} înregistrăr + {entry.linkedEntryIds.length} inregistrar {entry.linkedEntryIds.length === 1 ? "e" : "i"} legat - {entry.linkedEntryIds.length === 1 ? "ă" : "e"} + {entry.linkedEntryIds.length === 1 ? "a" : "e"}

)} diff --git a/src/modules/registratura/components/thread-explorer.tsx b/src/modules/registratura/components/thread-explorer.tsx index ab66333..c5a75f3 100644 --- a/src/modules/registratura/components/thread-explorer.tsx +++ b/src/modules/registratura/components/thread-explorer.tsx @@ -1,32 +1,12 @@ "use client"; import { useState, useMemo } from "react"; -import { - GitBranch, - Search, - ArrowDown, - ArrowUp, - Calendar, - Clock, - Building2, - FileDown, - ChevronDown, - ChevronRight, -} from "lucide-react"; -import { Card, CardContent } from "@/shared/components/ui/card"; +import { GitBranch, Search, Calendar, Clock } from "lucide-react"; import { Badge } from "@/shared/components/ui/badge"; import { Button } from "@/shared/components/ui/button"; import { Input } from "@/shared/components/ui/input"; -import { Label } from "@/shared/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/components/ui/select"; import type { RegistryEntry } from "../types"; -import { DEFAULT_DOC_TYPE_LABELS } from "../types"; +import { FlowDiagram } from "./flow-diagram"; import { cn } from "@/shared/lib/utils"; interface ThreadExplorerProps { @@ -37,23 +17,12 @@ interface ThreadExplorerProps { /** A thread is a chain of entries linked by threadParentId */ interface Thread { id: string; - /** Root entry (no parent) */ root: RegistryEntry; - /** All entries in the thread, ordered chronologically */ chain: RegistryEntry[]; - /** Total calendar days from first to last entry */ totalDays: number; - /** Days spent "at us" vs "at institution" */ - daysAtUs: number; - daysAtInstitution: number; - /** Whether thread is still open (has open entries) */ isActive: boolean; } -function getDocTypeLabel(type: string): string { - return DEFAULT_DOC_TYPE_LABELS[type] ?? type; -} - function daysBetween(d1: string, d2: string): number { const a = new Date(d1); const b = new Date(d2); @@ -65,18 +34,6 @@ function daysBetween(d1: string, d2: string): number { ); } -function formatDate(iso: string): string { - try { - return new Date(iso).toLocaleDateString("ro-RO", { - day: "2-digit", - month: "2-digit", - year: "numeric", - }); - } catch { - return iso; - } -} - function formatShortDate(iso: string): string { try { return new Date(iso).toLocaleDateString("ro-RO", { @@ -94,7 +51,6 @@ function buildThreads(entries: RegistryEntry[]): Thread[] { const childrenMap = new Map(); const rootIds = new Set(); - // Build parent->children map for (const entry of entries) { if (entry.threadParentId) { const existing = childrenMap.get(entry.threadParentId) ?? []; @@ -103,12 +59,10 @@ function buildThreads(entries: RegistryEntry[]): Thread[] { } } - // Find roots: entries that are parents or have children, but no parent themselves for (const entry of entries) { if (!entry.threadParentId && childrenMap.has(entry.id)) { rootIds.add(entry.id); } - // Also find the root of entries that have parents if (entry.threadParentId) { let current = entry; while (current.threadParentId) { @@ -126,7 +80,6 @@ function buildThreads(entries: RegistryEntry[]): Thread[] { const root = byId.get(rootId); if (!root) continue; - // Collect all entries in this thread (BFS) const chain: RegistryEntry[] = []; const queue = [rootId]; const visited = new Set(); @@ -144,49 +97,20 @@ function buildThreads(entries: RegistryEntry[]): Thread[] { } } - // Sort chronologically chain.sort( (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), ); - if (chain.length < 2) continue; // Only show actual threads (2+ entries) + if (chain.length < 2) continue; - // Calculate days const firstDate = chain[0]!.date; const lastDate = chain[chain.length - 1]!.date; const totalDays = daysBetween(firstDate, lastDate); - - // Calculate days at us vs institution - let daysAtUs = 0; - let daysAtInstitution = 0; - for (let i = 0; i < chain.length - 1; i++) { - const current = chain[i]!; - const next = chain[i + 1]!; - const gap = daysBetween(current.date, next.date); - - // If current is outgoing (iesit), the document is at the institution - // If current is incoming (intrat), the document is at us - if (current.direction === "iesit") { - daysAtInstitution += gap; - } else { - daysAtUs += gap; - } - } - const isActive = chain.some((e) => e.status === "deschis"); - threads.push({ - id: rootId, - root, - chain, - totalDays, - daysAtUs, - daysAtInstitution, - isActive, - }); + threads.push({ id: rootId, root, chain, totalDays, isActive }); } - // Sort threads: active first, then by most recent activity threads.sort((a, b) => { if (a.isActive !== b.isActive) return a.isActive ? -1 : 1; const aLast = a.chain[a.chain.length - 1]!.date; @@ -197,92 +121,18 @@ function buildThreads(entries: RegistryEntry[]): Thread[] { return threads; } -/** Generate a text report for a thread */ -function generateThreadReport(thread: Thread): string { - const lines: string[] = []; - lines.push("═══════════════════════════════════════════════════════"); - lines.push(`RAPORT FIR CONVERSAȚIE — ${thread.root.subject}`); - lines.push("═══════════════════════════════════════════════════════"); - lines.push(""); - lines.push(`Nr. înregistrare inițial: ${thread.root.number}`); - lines.push( - `Perioada: ${formatDate(thread.chain[0]!.date)} — ${formatDate(thread.chain[thread.chain.length - 1]!.date)}`, - ); - lines.push(`Durată totală: ${thread.totalDays} zile`); - lines.push( - `Zile la noi: ${thread.daysAtUs} | Zile la instituție: ${thread.daysAtInstitution}`, - ); - lines.push(`Documente în fir: ${thread.chain.length}`); - lines.push(`Status: ${thread.isActive ? "Activ" : "Finalizat"}`); - lines.push(""); - lines.push("───────────────────────────────────────────────────────"); - lines.push("TIMELINE"); - lines.push("───────────────────────────────────────────────────────"); - - for (let i = 0; i < thread.chain.length; i++) { - const entry = thread.chain[i]!; - const prev = i > 0 ? thread.chain[i - 1] : null; - const gap = prev ? daysBetween(prev.date, entry.date) : 0; - const location = prev - ? prev.direction === "iesit" - ? "la instituție" - : "la noi" - : ""; - - if (prev && gap > 0) { - lines.push(` │ ${gap} zile ${location}`); - } - - const arrow = entry.direction === "intrat" ? "↓ INTRAT" : "↑ IEȘIT"; - const status = entry.status === "inchis" ? "[ÎNCHIS]" : "[DESCHIS]"; - lines.push(""); - lines.push(` ${arrow} — ${formatDate(entry.date)} ${status}`); - lines.push(` Nr: ${entry.number}`); - lines.push(` Tip: ${getDocTypeLabel(entry.documentType)}`); - lines.push(` Subiect: ${entry.subject}`); - if (entry.sender) lines.push(` Expeditor: ${entry.sender}`); - if (entry.recipient) lines.push(` Destinatar: ${entry.recipient}`); - if (entry.assignee) lines.push(` Responsabil: ${entry.assignee}`); - if (entry.notes) lines.push(` Note: ${entry.notes}`); - } - - lines.push(""); - lines.push("═══════════════════════════════════════════════════════"); - lines.push(`Generat la: ${new Date().toLocaleString("ro-RO")}`); - lines.push("ArchiTools — Registratură"); - - return lines.join("\n"); -} - -function downloadReport(thread: Thread) { - const text = generateThreadReport(thread); - const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `thread-${thread.root.number.replace(/[/\\]/g, "-")}.txt`; - a.click(); - URL.revokeObjectURL(url); -} - export function ThreadExplorer({ entries, onNavigateEntry, }: ThreadExplorerProps) { const [search, setSearch] = useState(""); - const [statusFilter, setStatusFilter] = useState<"all" | "active" | "closed">( - "all", - ); - const [expandedThreads, setExpandedThreads] = useState>( - new Set(), - ); + const [activeOnly, setActiveOnly] = useState(false); const threads = useMemo(() => buildThreads(entries), [entries]); const filtered = useMemo(() => { return threads.filter((t) => { - if (statusFilter === "active" && !t.isActive) return false; - if (statusFilter === "closed" && t.isActive) return false; + if (activeOnly && !t.isActive) return false; if (search.trim()) { const q = search.toLowerCase(); return t.chain.some( @@ -295,317 +145,124 @@ export function ThreadExplorer({ } return true; }); - }, [threads, search, statusFilter]); + }, [threads, search, activeOnly]); - const toggleExpand = (id: string) => { - setExpandedThreads((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - - // Stats - const totalThreads = threads.length; - const activeThreads = threads.filter((t) => t.isActive).length; - const avgDuration = - totalThreads > 0 - ? Math.round( - threads.reduce((sum, t) => sum + t.totalDays, 0) / totalThreads, - ) - : 0; + const activeCount = threads.filter((t) => t.isActive).length; + const closedCount = threads.length - activeCount; return (
- {/* Stats */} -
- - -

Total fire

-

{totalThreads}

-
-
- - -

Fire active

-

{activeThreads}

-
-
- - -

Finalizate

-

{totalThreads - activeThreads}

-
-
- - -

Durată medie

-

- {avgDuration}{" "} - - zile - -

-
-
-
+ {/* Summary + filters */} +
+

+ {activeCount}{" "} + {activeCount === 1 ? "dosar activ" : "dosare active"},{" "} + {closedCount}{" "} + finalizate +

- {/* Filters */} -
-
- - setSearch(e.target.value)} - className="pl-9" - /> -
-
- - setSearch(e.target.value)} + className="h-9 w-[200px] pl-9" + /> +
+
- {/* Thread list */} + {/* Thread list with flow diagrams */} {filtered.length === 0 ? (

- {totalThreads === 0 - ? "Niciun fir de conversație. Creați legături între înregistrări (Răspuns la) pentru a forma fire." - : "Niciun fir corespunde filtrelor."} + {threads.length === 0 + ? "Niciun dosar. Inchideti o inregistrare cu raspuns (Inchide) pentru a forma un dosar." + : "Niciun dosar corespunde filtrelor."}

) : ( -
- {filtered.map((thread) => { - const isExpanded = expandedThreads.has(thread.id); - return ( - - {/* Thread header */} - - - {/* Expanded: Timeline */} - {isExpanded && ( -
- {/* Timeline */} -
- {/* Vertical line */} -
- - {thread.chain.map((entry, i) => { - const prev = i > 0 ? thread.chain[i - 1] : null; - const gap = prev - ? daysBetween(prev.date, entry.date) - : 0; - const isIncoming = entry.direction === "intrat"; - const location = prev - ? prev.direction === "iesit" - ? "la instituție" - : "la noi" - : ""; - - return ( -
- {/* Gap indicator */} - {prev && gap > 0 && ( -
-
- - {gap} zile {location} - -
- )} - - {/* Entry node */} -
- {/* Node dot */} -
- {isIncoming ? ( - - ) : ( - - )} -
- - {/* Entry content */} - -
-
- ); - })} -
- - {/* Day summary + Export */} -
-
- -
- Intrate:{" "} - { - thread.chain.filter((e) => e.direction === "intrat") - .length - } - - -
- Ieșite:{" "} - { - thread.chain.filter((e) => e.direction === "iesit") - .length - } - - - La noi: {thread.daysAtUs}z · La instituție:{" "} - {thread.daysAtInstitution}z - -
- -
-
- )} - - ); - })} +
+ {filtered.map((thread) => ( + + ))}
)} - -

- {filtered.length} din {totalThreads} fire afișate -

+
+ ); +} + +// ── Thread card with flow diagram ── + +function ThreadCard({ + thread, + allEntries, + onNodeClick, +}: { + thread: Thread; + allEntries: RegistryEntry[]; + onNodeClick?: (entry: RegistryEntry) => void; +}) { + return ( +
+ {/* Header */} +
+ + + {thread.root.subject} + + + {thread.isActive ? "Activ" : "Finalizat"} + +
+ + + {formatShortDate(thread.chain[0]!.date)} —{" "} + {formatShortDate(thread.chain[thread.chain.length - 1]!.date)} + + + + {thread.totalDays}z + + + {thread.chain.length} doc. + +
+
+ + {/* Flow diagram */} +
+ +
); } diff --git a/src/modules/registratura/services/deadline-catalog.ts b/src/modules/registratura/services/deadline-catalog.ts index ad5e7a2..ca6656f 100644 --- a/src/modules/registratura/services/deadline-catalog.ts +++ b/src/modules/registratura/services/deadline-catalog.ts @@ -94,6 +94,7 @@ export const DEADLINE_CATALOG: DeadlineTypeDef[] = [ category: "certificat", legalReference: "Legea 350/2001, art. 44 alin. (4)", autoTrack: true, + backgroundOnly: true, directionFilter: ["iesit"], }, diff --git a/src/modules/registratura/services/deadline-service.ts b/src/modules/registratura/services/deadline-service.ts index 7e6d56e..a73e699 100644 --- a/src/modules/registratura/services/deadline-service.ts +++ b/src/modules/registratura/services/deadline-service.ts @@ -6,7 +6,7 @@ import type { DeadlineAuditEntry, } from "../types"; import { getDeadlineType } from "./deadline-catalog"; -import { computeDueDate } from "./working-days"; +import { computeDueDate, addWorkingDays } from "./working-days"; export interface DeadlineDisplayStatus { label: string; @@ -219,3 +219,163 @@ function formatDate(d: Date): string { const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } + +// ── Grouped deadlines for simplified dashboard ── + +export interface DeadlineEntryGroup { + entry: RegistryEntry; + /** Main deadlines (user-created, non-background) */ + mainDeadlines: Array<{ + deadline: TrackedDeadline; + status: DeadlineDisplayStatus; + chainChildren: Array<{ + deadline: TrackedDeadline; + status: DeadlineDisplayStatus; + }>; + }>; +} + +/** + * Group deadlines by entry for the simplified dashboard. + * Only includes entries with pending main deadlines. + * Auto-tracked / background deadlines are nested under their chain parent. + */ +export function groupDeadlinesByEntry( + entries: RegistryEntry[], +): DeadlineEntryGroup[] { + const groups: DeadlineEntryGroup[] = []; + + for (const entry of entries) { + const deadlines = entry.trackedDeadlines ?? []; + if (deadlines.length === 0) continue; + + // Separate: main deadlines vs chain children vs background + const chainChildMap = new Map< + string, + Array<{ deadline: TrackedDeadline; status: DeadlineDisplayStatus }> + >(); + const mainItems: Array<{ + deadline: TrackedDeadline; + status: DeadlineDisplayStatus; + chainChildren: Array<{ + deadline: TrackedDeadline; + status: DeadlineDisplayStatus; + }>; + }> = []; + + // First pass: identify chain children + for (const dl of deadlines) { + const def = getDeadlineType(dl.typeId); + if (def?.backgroundOnly) continue; // skip background-only + + if (dl.chainParentId) { + const children = chainChildMap.get(dl.chainParentId) ?? []; + children.push({ deadline: dl, status: getDeadlineDisplayStatus(dl) }); + chainChildMap.set(dl.chainParentId, children); + } + } + + // Second pass: build main deadline items + for (const dl of deadlines) { + const def = getDeadlineType(dl.typeId); + if (def?.backgroundOnly) continue; + if (dl.chainParentId) continue; // chain children are nested + + // Auto-tracked without chain parent: treat as main if pending, skip if resolved + if (def?.autoTrack && dl.resolution !== "pending") continue; + + mainItems.push({ + deadline: dl, + status: getDeadlineDisplayStatus(dl), + chainChildren: chainChildMap.get(dl.id) ?? [], + }); + } + + // Only include entries that have pending main deadlines + const hasPending = mainItems.some((m) => m.deadline.resolution === "pending"); + if (!hasPending) continue; + + groups.push({ entry, mainDeadlines: mainItems }); + } + + // Sort: entries with overdue/urgent deadlines first + groups.sort((a, b) => { + const worstA = worstVariant(a.mainDeadlines.map((m) => m.status.variant)); + const worstB = worstVariant(b.mainDeadlines.map((m) => m.status.variant)); + return worstA - worstB; + }); + + return groups; +} + +function worstVariant(variants: string[]): number { + const order: Record = { + red: 0, + yellow: 1, + blue: 2, + green: 3, + gray: 4, + }; + let worst = 4; + for (const v of variants) { + const o = order[v] ?? 4; + if (o < worst) worst = o; + } + return worst; +} + +// ── Transmission status (background check) ── + +export type TransmissionStatus = "on-time" | "late" | null; + +/** + * Compute whether a reply entry was transmitted within the legal 1-day window. + * Returns null if recipient registration date is not available. + */ +export function computeTransmissionStatus( + parentEntry: RegistryEntry, + replyEntry: RegistryEntry, +): TransmissionStatus { + if (!parentEntry.recipientRegDate) return null; + + const regDate = new Date(parentEntry.recipientRegDate); + regDate.setHours(0, 0, 0, 0); + + // 1 working day after recipient registration date + const deadline = addWorkingDays(regDate, 1); + + const replyDate = new Date(replyEntry.date); + replyDate.setHours(0, 0, 0, 0); + + return replyDate.getTime() <= deadline.getTime() ? "on-time" : "late"; +} + +// ── Auto-resolution of deadlines ── + +/** + * Find deadlines on a parent entry that should be auto-resolved when a reply arrives. + * Only returns pending deadlines that match the reply context. + */ +export function findAutoResolvableDeadlines( + parentEntry: RegistryEntry, + replyEntry: RegistryEntry, +): TrackedDeadline[] { + const pending = (parentEntry.trackedDeadlines ?? []).filter( + (dl) => dl.resolution === "pending", + ); + if (pending.length === 0) return []; + + // Only auto-resolve when reply is incoming (response to our outgoing request) + if (replyEntry.direction !== "intrat") return []; + if (parentEntry.direction !== "iesit") return []; + + // Filter: skip background-only and auto-tracked chain children + return pending.filter((dl) => { + const def = getDeadlineType(dl.typeId); + if (!def) return false; + if (def.backgroundOnly) return false; + // Include main deadlines (no chain parent) — these are the ones the user cares about + if (!dl.chainParentId) return true; + return false; + }); +} diff --git a/src/modules/registratura/types.ts b/src/modules/registratura/types.ts index 8cb9a1a..b12f684 100644 --- a/src/modules/registratura/types.ts +++ b/src/modules/registratura/types.ts @@ -157,6 +157,8 @@ export interface DeadlineTypeDef { autoTrack?: boolean; /** Which directions this category/type applies to (undefined = both) */ directionFilter?: RegistryDirection[]; + /** If true, this deadline is purely background — never shown in dashboard */ + backgroundOnly?: boolean; } /** Audit log entry for deadline changes */