feat: simplify deadline dashboard + add flow diagrams for document chains
Major UX overhaul of the "Termene legale" and thread tabs: Deadline dashboard: - Replace 6 KPI cards with simple summary bar (active/urgent/overdue) - Replace flat table with grouped list by entry (cards with progress bars) - Chain deadlines collapsed by default with expand toggle - Auto-tracked/background deadlines hidden from main list Flow diagram (new component): - CSS-only horizontal flow diagram showing document chains - Nodes with direction bar (blue=intrat, orange=iesit), number, subject, status - Solid arrows for thread links, dashed for conex/linked entries - Used in both "Dosare" tab (full) and detail panel (compact, max 5 nodes) Thread explorer → Dosare: - Renamed tab "Fire conversatie" → "Dosare" - Each thread shown as a card with flow diagram inside - Simplified stats (just active/finalized count) Background tracking: - comunicare-aviz-beneficiar marked as backgroundOnly (not shown in dashboard) - Transmission status computed and shown in detail panel (on-time/late) Auto-resolution: - When closing entry via reply, matching parent deadlines auto-resolve - Resolution note includes the reply entry number Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string> = {
|
||||
pending: "În așteptare",
|
||||
completed: "Finalizat",
|
||||
"aprobat-tacit": "Aprobat tacit",
|
||||
respins: "Respins",
|
||||
anulat: "Anulat",
|
||||
const VARIANT_DOT: Record<string, string> = {
|
||||
green: "bg-green-500",
|
||||
yellow: "bg-amber-500",
|
||||
red: "bg-red-500",
|
||||
blue: "bg-blue-500",
|
||||
gray: "bg-muted-foreground",
|
||||
};
|
||||
|
||||
const VARIANT_TEXT: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string | null>(null);
|
||||
const [resolvingDeadline, setResolvingDeadline] =
|
||||
useState<TrackedDeadline | null>(null);
|
||||
const [expandedChains, setExpandedChains] = useState<Set<string>>(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 (
|
||||
<div className="space-y-4">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<StatCard label="Active" value={stats.active} />
|
||||
<StatCard
|
||||
label="Urgente"
|
||||
value={stats.urgent}
|
||||
variant={stats.urgent > 0 ? "destructive" : undefined}
|
||||
{/* Summary bar */}
|
||||
<div className="flex items-center gap-6 rounded-lg border bg-card px-4 py-3">
|
||||
<SummaryItem
|
||||
count={stats.active}
|
||||
label="active"
|
||||
variant="green"
|
||||
showIfZero
|
||||
/>
|
||||
<StatCard
|
||||
label="Depășit termen"
|
||||
value={stats.overdue}
|
||||
variant={stats.overdue > 0 ? "destructive" : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label="Aprobat tacit"
|
||||
value={stats.tacit}
|
||||
variant={stats.tacit > 0 ? "blue" : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label="Lipsă nr. dest."
|
||||
value={stats.missingRecipientReg}
|
||||
variant={stats.missingRecipientReg > 0 ? "destructive" : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label="Expiră curând"
|
||||
value={stats.expiringSoon}
|
||||
variant={stats.expiringSoon > 0 ? "destructive" : undefined}
|
||||
<SummaryItem count={stats.urgent} label="urgente" variant="yellow" />
|
||||
<SummaryItem
|
||||
count={stats.overdue}
|
||||
label={stats.overdue === 1 ? "depasit" : "depasite"}
|
||||
variant="red"
|
||||
/>
|
||||
|
||||
<div className="ml-auto">
|
||||
<Button
|
||||
variant={urgentOnly ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setUrgentOnly(!urgentOnly)}
|
||||
>
|
||||
Doar urgente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alert banners */}
|
||||
{stats.missingRecipientReg > 0 && (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-2 text-xs text-amber-700 dark:text-amber-400">
|
||||
⚠ {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 ? (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||
{urgentOnly
|
||||
? "Niciun termen urgent sau depasit."
|
||||
: "Niciun termen legal activ."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredGroups.map((group) => (
|
||||
<EntryDeadlineCard
|
||||
key={group.entry.id}
|
||||
group={group}
|
||||
expandedChains={expandedChains}
|
||||
onToggleChain={toggleChain}
|
||||
onResolve={handleResolveClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{stats.expiringSoon > 0 && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-4 py-2 text-xs text-red-700 dark:text-red-400">
|
||||
⚠ {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.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">Categorie</Label>
|
||||
<Select
|
||||
value={filters.category}
|
||||
onValueChange={(v) =>
|
||||
updateFilter("category", v as DeadlineCategory | "all")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 w-[160px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toate</SelectItem>
|
||||
{(
|
||||
Object.entries(CATEGORY_LABELS) as [DeadlineCategory, string][]
|
||||
).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Status</Label>
|
||||
<Select
|
||||
value={filters.resolution}
|
||||
onValueChange={(v) =>
|
||||
updateFilter("resolution", v as DeadlineResolution | "all")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 w-[160px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toate</SelectItem>
|
||||
{Object.entries(RESOLUTION_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant={filters.urgentOnly ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => updateFilter("urgentOnly", !filters.urgentOnly)}
|
||||
>
|
||||
Doar urgente
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<DeadlineTable rows={filteredRows} onResolve={handleResolveClick} />
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{filteredRows.length} din {stats.all.length} termene afișate
|
||||
</p>
|
||||
|
||||
<DeadlineResolveDialog
|
||||
open={resolvingDeadline !== null}
|
||||
@@ -248,27 +195,261 @@ export function DeadlineDashboard({
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
// ── Summary item ──
|
||||
|
||||
function SummaryItem({
|
||||
count,
|
||||
label,
|
||||
value,
|
||||
variant,
|
||||
showIfZero,
|
||||
}: {
|
||||
count: number;
|
||||
label: string;
|
||||
value: number;
|
||||
variant?: "destructive" | "blue";
|
||||
variant: string;
|
||||
showIfZero?: boolean;
|
||||
}) {
|
||||
if (count === 0 && !showIfZero) return null;
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p
|
||||
className={`text-2xl font-bold ${
|
||||
variant === "destructive" && value > 0 ? "text-destructive" : ""
|
||||
}${variant === "blue" && value > 0 ? "text-blue-600" : ""}`}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn("h-2.5 w-2.5 rounded-full", VARIANT_DOT[variant])}
|
||||
/>
|
||||
<span className={cn("text-sm font-semibold", VARIANT_TEXT[variant])}>
|
||||
{count}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Entry card with its deadlines ──
|
||||
|
||||
function EntryDeadlineCard({
|
||||
group,
|
||||
expandedChains,
|
||||
onToggleChain,
|
||||
onResolve,
|
||||
}: {
|
||||
group: DeadlineEntryGroup;
|
||||
expandedChains: Set<string>;
|
||||
onToggleChain: (id: string) => void;
|
||||
onResolve: (entryId: string, deadline: TrackedDeadline) => void;
|
||||
}) {
|
||||
const { entry, mainDeadlines } = group;
|
||||
|
||||
// Worst status for card border color
|
||||
const worstVariant = mainDeadlines.reduce<string>((worst, m) => {
|
||||
if (m.deadline.resolution !== "pending") return worst;
|
||||
const order: Record<string, number> = {
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-card",
|
||||
VARIANT_BG[worstVariant] ?? "",
|
||||
)}
|
||||
>
|
||||
{/* Entry header */}
|
||||
<div className="flex items-center gap-3 border-b px-4 py-2.5">
|
||||
<span className="font-mono text-xs font-semibold">
|
||||
{entry.number}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">|</span>
|
||||
<span className="truncate text-xs">{entry.subject}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"ml-auto shrink-0 text-[10px]",
|
||||
entry.direction === "intrat"
|
||||
? "border-blue-300 text-blue-700 dark:border-blue-800 dark:text-blue-400"
|
||||
: "border-orange-300 text-orange-700 dark:border-orange-800 dark:text-orange-400",
|
||||
)}
|
||||
>
|
||||
{entry.direction === "intrat" ? "Intrat" : "Iesit"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Deadlines */}
|
||||
<div className="divide-y">
|
||||
{mainDeadlines.map((item) => {
|
||||
const isExpanded = expandedChains.has(item.deadline.id);
|
||||
const chainCount = item.chainChildren.length;
|
||||
|
||||
return (
|
||||
<div key={item.deadline.id}>
|
||||
<DeadlineRow
|
||||
deadline={item.deadline}
|
||||
status={item.status}
|
||||
entryId={entry.id}
|
||||
chainCount={chainCount}
|
||||
isExpanded={isExpanded}
|
||||
onToggleChain={() => onToggleChain(item.deadline.id)}
|
||||
onResolve={onResolve}
|
||||
/>
|
||||
{/* Chain children (expanded) */}
|
||||
{isExpanded &&
|
||||
chainCount > 0 &&
|
||||
item.chainChildren.map((child) => (
|
||||
<DeadlineRow
|
||||
key={child.deadline.id}
|
||||
deadline={child.deadline}
|
||||
status={child.status}
|
||||
entryId={entry.id}
|
||||
isChainChild
|
||||
onResolve={onResolve}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-2",
|
||||
isChainChild && "bg-muted/30 pl-8",
|
||||
)}
|
||||
>
|
||||
{/* Chain expand toggle */}
|
||||
{!isChainChild && (chainCount ?? 0) > 0 ? (
|
||||
<button
|
||||
onClick={onToggleChain}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
) : isChainChild ? (
|
||||
<span className="text-muted-foreground/40 text-xs">└</span>
|
||||
) : (
|
||||
<span className="w-3.5" />
|
||||
)}
|
||||
|
||||
{/* Type label */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className={cn("text-xs", isChainChild && "text-muted-foreground")}>
|
||||
{def?.label ?? deadline.typeId}
|
||||
</span>
|
||||
{!isChainChild && (chainCount ?? 0) > 0 && (
|
||||
<span className="ml-2 text-[10px] text-muted-foreground">
|
||||
(+{chainCount} etap{chainCount === 1 ? "a" : "e"})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar (only for pending) */}
|
||||
{isPending && (
|
||||
<div className="hidden w-24 sm:block">
|
||||
<div className="h-1.5 rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-1.5 rounded-full transition-all",
|
||||
status.variant === "red"
|
||||
? "bg-red-500"
|
||||
: status.variant === "yellow"
|
||||
? "bg-amber-500"
|
||||
: "bg-green-500",
|
||||
)}
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Countdown / status */}
|
||||
<div className="shrink-0 text-right">
|
||||
{isPending && status.daysRemaining !== null ? (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
VARIANT_TEXT[status.variant],
|
||||
)}
|
||||
>
|
||||
{status.daysRemaining < 0
|
||||
? `${Math.abs(status.daysRemaining)}z depasit`
|
||||
: status.daysRemaining === 0
|
||||
? "azi"
|
||||
: `${status.daysRemaining}z ramase`}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{status.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resolve button */}
|
||||
<div className="w-7 shrink-0">
|
||||
{isPending && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-green-600"
|
||||
onClick={() => onResolve(entryId, deadline)}
|
||||
title="Rezolva"
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, RegistryEntry[]>; // parentId -> linked entries
|
||||
}
|
||||
|
||||
function flattenTree(root: FlowNode): FlattenedChain {
|
||||
const mainChain: RegistryEntry[] = [];
|
||||
const branches = new Map<string, RegistryEntry[]>();
|
||||
|
||||
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 (
|
||||
<p className="text-xs text-muted-foreground">Niciun document in lant.</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-start gap-0 overflow-x-auto py-2", compact && "py-1")}>
|
||||
{truncated > 0 && (
|
||||
<div className="flex shrink-0 items-center self-center px-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{truncated} ...
|
||||
</span>
|
||||
<Arrow />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mainChain.map((entry, i) => {
|
||||
const branchEntries: RegistryEntry[] = branches.get(entry.id) ?? [];
|
||||
const isHighlighted = entry.id === highlightEntryId;
|
||||
const isLast = i === mainChain.length - 1;
|
||||
|
||||
return (
|
||||
<div key={entry.id} className="flex shrink-0 items-start">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{/* Main node */}
|
||||
<FlowNode
|
||||
entry={entry}
|
||||
isHighlighted={isHighlighted}
|
||||
compact={compact}
|
||||
onClick={() => onNodeClick?.(entry)}
|
||||
/>
|
||||
{/* Branch entries below */}
|
||||
{branchEntries.length > 0 && (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="h-3 w-px border-l border-dashed border-muted-foreground/40" />
|
||||
{branchEntries.map((be) => (
|
||||
<FlowNode
|
||||
key={be.id}
|
||||
entry={be}
|
||||
isHighlighted={be.id === highlightEntryId}
|
||||
compact={compact}
|
||||
isBranch
|
||||
onClick={() => onNodeClick?.(be)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Arrow to next */}
|
||||
{!isLast && (
|
||||
<div className="flex items-center self-center">
|
||||
<Arrow />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Arrow connector ──
|
||||
|
||||
function Arrow() {
|
||||
return (
|
||||
<div className="flex items-center px-1">
|
||||
<div className="h-px w-6 bg-border" />
|
||||
<div className="border-y-[4px] border-l-[6px] border-y-transparent border-l-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"group relative flex flex-col rounded-lg border text-left transition-all hover:shadow-sm",
|
||||
compact ? "w-[130px] p-2" : "w-[160px] p-2.5",
|
||||
isHighlighted
|
||||
? "border-primary ring-2 ring-primary/30"
|
||||
: "border-border hover:border-foreground/20",
|
||||
isBranch && "border-dashed",
|
||||
)}
|
||||
>
|
||||
{/* Direction bar */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 left-0 right-0 h-1 rounded-t-lg",
|
||||
isIntrat
|
||||
? "bg-blue-500"
|
||||
: "bg-orange-500",
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Number */}
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono font-semibold",
|
||||
compact ? "text-[10px]" : "text-xs",
|
||||
)}
|
||||
>
|
||||
{entry.number}
|
||||
</span>
|
||||
{hasDeadlines && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 text-muted-foreground",
|
||||
compact
|
||||
? "line-clamp-1 text-[9px]"
|
||||
: "line-clamp-2 text-[10px] leading-tight",
|
||||
)}
|
||||
>
|
||||
{entry.subject || "—"}
|
||||
</p>
|
||||
|
||||
{/* Date + status */}
|
||||
<div className="mt-1.5 flex items-center justify-between">
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{formatShortDate(entry.date)}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-4 px-1 text-[8px]",
|
||||
isOpen
|
||||
? "border-green-300 text-green-700 dark:border-green-800 dark:text-green-400"
|
||||
: "border-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isOpen ? "Deschis" : "Inchis"}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
|
||||
) => {
|
||||
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() {
|
||||
<Tabs defaultValue="registru">
|
||||
<TabsList>
|
||||
<TabsTrigger value="registru">Registru</TabsTrigger>
|
||||
<TabsTrigger value="fire">Fire conversație</TabsTrigger>
|
||||
<TabsTrigger value="fire">Dosare</TabsTrigger>
|
||||
<TabsTrigger value="termene">
|
||||
Termene legale
|
||||
{alertCount > 0 && (
|
||||
|
||||
@@ -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<string>();
|
||||
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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
@@ -387,48 +419,52 @@ export function RegistryEntryDetail({
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
{/* ── Thread links ── */}
|
||||
{(threadParent ||
|
||||
threadChildren.length > 0 ||
|
||||
{/* ── Thread links (flow diagram) ── */}
|
||||
{(threadChain.length >= 2 ||
|
||||
(entry.linkedEntryIds ?? []).length > 0) && (
|
||||
<DetailSection title="Legături">
|
||||
{threadParent && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<GitBranch className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Răspuns la:</span>
|
||||
<span className="font-mono text-xs">
|
||||
{threadParent.number}
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground">
|
||||
— {threadParent.subject}
|
||||
</span>
|
||||
</div>
|
||||
<DetailSection title="Dosar">
|
||||
{threadChain.length >= 2 && (
|
||||
<FlowDiagram
|
||||
chain={threadChain}
|
||||
allEntries={allEntries}
|
||||
highlightEntryId={entry.id}
|
||||
compact
|
||||
maxNodes={5}
|
||||
/>
|
||||
)}
|
||||
{/* Transmission status for thread children */}
|
||||
{threadChildren.length > 0 && (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Răspunsuri ({threadChildren.length}):
|
||||
</p>
|
||||
{threadChildren.map((child) => (
|
||||
<div
|
||||
key={child.id}
|
||||
className="flex items-center gap-2 text-xs ml-4"
|
||||
>
|
||||
<span className="font-mono">{child.number}</span>
|
||||
<span className="truncate text-muted-foreground">
|
||||
{child.subject}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-2 space-y-1">
|
||||
{threadChildren.map((child) => {
|
||||
const txStatus = computeTransmissionStatus(entry, child);
|
||||
if (!txStatus) return null;
|
||||
return (
|
||||
<div
|
||||
key={child.id}
|
||||
className="flex items-center gap-2 text-[10px]"
|
||||
>
|
||||
<span className="font-mono">{child.number}</span>
|
||||
{txStatus === "on-time" ? (
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
Transmis in termen
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-600 dark:text-amber-400">
|
||||
Transmis cu intarziere
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{(entry.linkedEntryIds ?? []).length > 0 && (
|
||||
<div className="mt-1">
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Link2 className="h-3 w-3" />
|
||||
{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"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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<string, RegistryEntry[]>();
|
||||
const rootIds = new Set<string>();
|
||||
|
||||
// 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<string>();
|
||||
@@ -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<Set<string>>(
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Total fire</p>
|
||||
<p className="text-2xl font-bold">{totalThreads}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Fire active</p>
|
||||
<p className="text-2xl font-bold">{activeThreads}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Finalizate</p>
|
||||
<p className="text-2xl font-bold">{totalThreads - activeThreads}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Durată medie</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{avgDuration}{" "}
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
zile
|
||||
</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/* Summary + filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{activeCount}</span>{" "}
|
||||
{activeCount === 1 ? "dosar activ" : "dosare active"},{" "}
|
||||
<span className="font-medium text-foreground">{closedCount}</span>{" "}
|
||||
finalizate
|
||||
</p>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="relative min-w-[200px] flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Caută după nr., subiect, expeditor..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Status</Label>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(v) =>
|
||||
setStatusFilter(v as "all" | "active" | "closed")
|
||||
}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Cauta..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9 w-[200px] pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant={activeOnly ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setActiveOnly(!activeOnly)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toate</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="closed">Finalizate</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
Doar active
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thread list */}
|
||||
{/* Thread list with flow diagrams */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<GitBranch className="mx-auto h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{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."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filtered.map((thread) => {
|
||||
const isExpanded = expandedThreads.has(thread.id);
|
||||
return (
|
||||
<Card key={thread.id} className="overflow-hidden">
|
||||
{/* Thread header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(thread.id)}
|
||||
className="w-full text-left"
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 shrink-0">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<GitBranch className="h-4 w-4 text-primary shrink-0" />
|
||||
<span className="font-medium truncate">
|
||||
{thread.root.subject}
|
||||
</span>
|
||||
<Badge
|
||||
variant={thread.isActive ? "default" : "secondary"}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{thread.isActive ? "Activ" : "Finalizat"}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{thread.chain.length} doc.
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{formatShortDate(thread.chain[0]!.date)} —{" "}
|
||||
{formatShortDate(
|
||||
thread.chain[thread.chain.length - 1]!.date,
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{thread.totalDays} zile total
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Building2 className="h-3 w-3" />
|
||||
{thread.daysAtUs}z la noi ·{" "}
|
||||
{thread.daysAtInstitution}z la inst.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</button>
|
||||
|
||||
{/* Expanded: Timeline */}
|
||||
{isExpanded && (
|
||||
<div className="border-t bg-muted/20 px-4 pb-4 pt-3">
|
||||
{/* Timeline */}
|
||||
<div className="relative ml-6">
|
||||
{/* Vertical line */}
|
||||
<div className="absolute left-3 top-0 bottom-0 w-0.5 bg-border" />
|
||||
|
||||
{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 (
|
||||
<div key={entry.id}>
|
||||
{/* Gap indicator */}
|
||||
{prev && gap > 0 && (
|
||||
<div className="relative flex items-center py-1.5 pl-8">
|
||||
<div className="absolute left-[9px] h-5 w-3 border-l-2 border-dashed border-muted-foreground/30" />
|
||||
<span className="text-[10px] text-muted-foreground italic">
|
||||
{gap} zile {location}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Entry node */}
|
||||
<div className="relative flex items-start gap-3 py-1.5">
|
||||
{/* Node dot */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 mt-1.5 h-6 w-6 rounded-full border-2 flex items-center justify-center shrink-0",
|
||||
isIncoming
|
||||
? "border-blue-500 bg-blue-50 dark:bg-blue-950"
|
||||
: "border-orange-500 bg-orange-50 dark:bg-orange-950",
|
||||
)}
|
||||
>
|
||||
{isIncoming ? (
|
||||
<ArrowDown className="h-3 w-3 text-blue-600 dark:text-blue-400" />
|
||||
) : (
|
||||
<ArrowUp className="h-3 w-3 text-orange-600 dark:text-orange-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Entry content */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNavigateEntry?.(entry);
|
||||
}}
|
||||
className={cn(
|
||||
"flex-1 rounded-lg border p-3 text-left transition-colors hover:bg-accent/50",
|
||||
entry.status === "inchis" && "opacity-70",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge
|
||||
variant={
|
||||
isIncoming ? "default" : "secondary"
|
||||
}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{isIncoming ? "↓ Intrat" : "↑ Ieșit"}
|
||||
</Badge>
|
||||
<span className="font-mono text-xs">
|
||||
{entry.number}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(entry.date)}
|
||||
</span>
|
||||
{entry.status === "inchis" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px]"
|
||||
>
|
||||
Închis
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm font-medium">
|
||||
{entry.subject}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Tip: {getDocTypeLabel(entry.documentType)}
|
||||
</span>
|
||||
{entry.sender && (
|
||||
<span>De la: {entry.sender}</span>
|
||||
)}
|
||||
{entry.recipient && (
|
||||
<span>Către: {entry.recipient}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Day summary + Export */}
|
||||
<div className="mt-4 flex items-center justify-between border-t pt-3">
|
||||
<div className="flex gap-4 text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-blue-500" />
|
||||
Intrate:{" "}
|
||||
{
|
||||
thread.chain.filter((e) => e.direction === "intrat")
|
||||
.length
|
||||
}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-orange-500" />
|
||||
Ieșite:{" "}
|
||||
{
|
||||
thread.chain.filter((e) => e.direction === "iesit")
|
||||
.length
|
||||
}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
La noi: {thread.daysAtUs}z · La instituție:{" "}
|
||||
{thread.daysAtInstitution}z
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadReport(thread);
|
||||
}}
|
||||
>
|
||||
<FileDown className="mr-1.5 h-3.5 w-3.5" />
|
||||
Export raport
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<div className="space-y-4">
|
||||
{filtered.map((thread) => (
|
||||
<ThreadCard
|
||||
key={thread.id}
|
||||
thread={thread}
|
||||
allEntries={entries}
|
||||
onNodeClick={onNavigateEntry}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{filtered.length} din {totalThreads} fire afișate
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Thread card with flow diagram ──
|
||||
|
||||
function ThreadCard({
|
||||
thread,
|
||||
allEntries,
|
||||
onNodeClick,
|
||||
}: {
|
||||
thread: Thread;
|
||||
allEntries: RegistryEntry[];
|
||||
onNodeClick?: (entry: RegistryEntry) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-card",
|
||||
thread.isActive
|
||||
? "border-border"
|
||||
: "border-muted bg-muted/20",
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 border-b px-4 py-3">
|
||||
<GitBranch className="h-4 w-4 shrink-0 text-primary" />
|
||||
<span className="truncate text-sm font-medium">
|
||||
{thread.root.subject}
|
||||
</span>
|
||||
<Badge
|
||||
variant={thread.isActive ? "default" : "secondary"}
|
||||
className="shrink-0 text-[10px]"
|
||||
>
|
||||
{thread.isActive ? "Activ" : "Finalizat"}
|
||||
</Badge>
|
||||
<div className="ml-auto flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{formatShortDate(thread.chain[0]!.date)} —{" "}
|
||||
{formatShortDate(thread.chain[thread.chain.length - 1]!.date)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{thread.totalDays}z
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{thread.chain.length} doc.
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flow diagram */}
|
||||
<div className="px-4 py-3">
|
||||
<FlowDiagram
|
||||
chain={thread.chain}
|
||||
allEntries={allEntries}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
|
||||
@@ -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<string, number> = {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user