feat: add deadline system guide/overview page (Ghid termene)

- New DeadlineConfigOverview component: read-only reference page showing all
  18 deadline types organized by category with chain flow diagrams
- Accessible via "Ghid termene" toggle in the "Termene legale" tab
- Shows: summary stats, color legend, collapsible category sections,
  chain flow diagrams (fan-in + sequential), notification overview,
  document expiry settings, and legal reference index
- All data derived from existing catalog (zero API calls)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-12 00:10:23 +02:00
parent c5112dbb3d
commit f6fc63a40c
2 changed files with 595 additions and 5 deletions
@@ -0,0 +1,565 @@
"use client";
import { useState, useMemo } from "react";
import {
ChevronDown,
ChevronRight,
Info,
Clock,
ShieldCheck,
Bell,
ArrowRight,
Scale,
FileWarning,
Timer,
Link2,
Zap,
} from "lucide-react";
import { Badge } from "@/shared/components/ui/badge";
import { Separator } from "@/shared/components/ui/separator";
import {
DEADLINE_CATALOG,
CATEGORY_LABELS,
getDeadlineType,
getDeadlinesByCategory,
} from "../services/deadline-catalog";
import { NOTIFICATION_TYPES } from "@/core/notifications/types";
import type { DeadlineCategory, DeadlineTypeDef } from "../types";
import { cn } from "@/shared/lib/utils";
// ── Constants ──
const ALL_CATEGORIES: DeadlineCategory[] = [
"certificat",
"avize",
"completari",
"urbanism",
"autorizare",
"litigii",
];
const CATEGORY_ICONS: Record<DeadlineCategory, string> = {
certificat: "CU",
avize: "AV",
completari: "CO",
urbanism: "UR",
autorizare: "AC",
litigii: "LI",
};
// ── Chain building ──
interface ChainGroup {
sources: DeadlineTypeDef[];
target: DeadlineTypeDef;
}
function buildChainGroups(category: DeadlineCategory): ChainGroup[] {
const types = getDeadlinesByCategory(category);
const targetMap = new Map<string, DeadlineTypeDef[]>();
for (const t of types) {
if (t.chainNextTypeId) {
const sources = targetMap.get(t.chainNextTypeId) ?? [];
sources.push(t);
targetMap.set(t.chainNextTypeId, sources);
}
}
const groups: ChainGroup[] = [];
for (const [targetId, sources] of targetMap) {
const target = getDeadlineType(targetId);
if (target) {
groups.push({ sources, target });
}
}
return groups;
}
// ── Legal reference index ──
function buildLegalIndex(): Map<string, DeadlineTypeDef[]> {
const index = new Map<string, DeadlineTypeDef[]>();
for (const t of DEADLINE_CATALOG) {
if (t.legalReference) {
const existing = index.get(t.legalReference) ?? [];
existing.push(t);
index.set(t.legalReference, existing);
}
}
return index;
}
// ── Component ──
export function DeadlineConfigOverview() {
const [expandedCategories, setExpandedCategories] = useState<Set<DeadlineCategory>>(
() => new Set(ALL_CATEGORIES),
);
const [expandedDescs, setExpandedDescs] = useState<Set<string>>(new Set());
const [showLegalIndex, setShowLegalIndex] = useState(false);
const stats = useMemo(() => {
const total = DEADLINE_CATALOG.length;
const autoTracked = DEADLINE_CATALOG.filter((d) => d.autoTrack).length;
const tacit = DEADLINE_CATALOG.filter((d) => d.tacitApprovalApplicable).length;
const chained = DEADLINE_CATALOG.filter((d) => d.chainNextTypeId).length;
return { total, autoTracked, tacit, chained };
}, []);
const legalIndex = useMemo(() => buildLegalIndex(), []);
const toggleCategory = (cat: DeadlineCategory) => {
setExpandedCategories((prev) => {
const next = new Set(prev);
if (next.has(cat)) next.delete(cat);
else next.add(cat);
return next;
});
};
const toggleDesc = (id: string) => {
setExpandedDescs((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<div className="space-y-6">
{/* ── Summary stats ── */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard icon={<Scale className="h-4 w-4" />} value={stats.total} label="Tipuri termene" />
<StatCard icon={<Zap className="h-4 w-4 text-purple-500" />} value={stats.autoTracked} label="Auto-tracked" />
<StatCard icon={<ShieldCheck className="h-4 w-4 text-blue-500" />} value={stats.tacit} label="Aprobare tacita" />
<StatCard icon={<Link2 className="h-4 w-4 text-amber-500" />} value={stats.chained} label="Termene in lant" />
</div>
{/* ── Legend ── */}
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/30 px-3 py-2">
<span className="text-[10px] font-medium text-muted-foreground">Legenda:</span>
<Badge variant="outline" className="bg-purple-100 text-purple-700 border-purple-300 text-[10px] dark:bg-purple-900/30 dark:text-purple-300 dark:border-purple-700">
Auto
</Badge>
<Badge variant="outline" className="bg-gray-100 text-gray-600 border-gray-300 text-[10px] dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600">
Manual
</Badge>
<Badge variant="outline" className="bg-blue-100 text-blue-700 border-blue-300 text-[10px] dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700">
Tacit
</Badge>
<Badge variant="outline" className="bg-green-100 text-green-700 border-green-300 text-[10px] dark:bg-green-900/30 dark:text-green-300 dark:border-green-700">
Lucratoare
</Badge>
<Badge variant="outline" className="bg-orange-100 text-orange-700 border-orange-300 text-[10px] dark:bg-orange-900/30 dark:text-orange-300 dark:border-orange-700">
Calendaristice
</Badge>
<Badge variant="outline" className="border-dashed text-muted-foreground text-[10px]">
Fundal
</Badge>
</div>
{/* ── Category sections ── */}
{ALL_CATEGORIES.map((cat) => {
const types = getDeadlinesByCategory(cat);
if (types.length === 0) return null;
const isExpanded = expandedCategories.has(cat);
const chainGroups = buildChainGroups(cat);
return (
<div key={cat} className="rounded-lg border bg-card">
{/* Category header */}
<button
onClick={() => toggleCategory(cat)}
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-[10px] font-bold text-primary">
{CATEGORY_ICONS[cat]}
</span>
<span className="flex-1 text-sm font-medium">
{CATEGORY_LABELS[cat]}
</span>
<Badge variant="outline" className="text-[10px]">
{types.length} tip{types.length !== 1 ? "uri" : ""}
</Badge>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</button>
{/* Category body */}
{isExpanded && (
<div>
<Separator />
<div className="divide-y">
{types.map((t) => (
<DeadlineTypeRow
key={t.id}
type={t}
descExpanded={expandedDescs.has(t.id)}
onToggleDesc={() => toggleDesc(t.id)}
/>
))}
</div>
{/* Chain flow diagrams */}
{chainGroups.length > 0 && (
<div className="border-t bg-muted/20 px-4 py-3">
<p className="mb-2 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
Fluxuri de termene in lant
</p>
<div className="space-y-3">
{chainGroups.map((group) => (
<ChainFlowDiagram
key={group.target.id}
group={group}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
);
})}
{/* ── Notification overview ── */}
<div className="rounded-lg border bg-card">
<div className="flex items-center gap-3 px-4 py-3">
<Bell className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">Notificari email</span>
</div>
<Separator />
<div className="px-4 py-3 space-y-3">
<p className="text-xs text-muted-foreground">
Digest zilnic trimis luni-vineri la 8:00 prin Brevo SMTP, declansat automat de N8N cron.
</p>
<div className="space-y-2">
{NOTIFICATION_TYPES.map((nt) => (
<div key={nt.type} className="flex items-start gap-2">
<span className="mt-1 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
<div>
<span className="text-xs font-medium">{nt.label}</span>
<span className="ml-2 text-xs text-muted-foreground">
{nt.description}
</span>
</div>
</div>
))}
</div>
<p className="text-[10px] text-muted-foreground italic">
Preferintele per utilizator se configureaza din iconita clopotel din bara de sus.
</p>
</div>
</div>
{/* ── Document expiry overview ── */}
<div className="rounded-lg border bg-card">
<div className="flex items-center gap-3 px-4 py-3">
<FileWarning className="h-4 w-4 text-amber-500" />
<span className="text-sm font-medium">Expirare documente</span>
</div>
<Separator />
<div className="px-4 py-3 space-y-3">
<div className="space-y-2">
<div className="flex items-start gap-2">
<span className="mt-1 h-1.5 w-1.5 shrink-0 rounded-full bg-amber-500" />
<div className="text-xs">
<span className="font-medium">Certificat de Urbanism (CU)</span>
<span className="ml-1 text-muted-foreground">
data expirare + fereastra de alerta (implicit 30 zile). Se configureaza per inregistrare.
</span>
</div>
</div>
<div className="flex items-start gap-2">
<span className="mt-1 h-1.5 w-1.5 shrink-0 rounded-full bg-amber-500" />
<div className="text-xs">
<span className="font-medium">Autorizatie de Construire (AC)</span>
<span className="ml-1 text-muted-foreground">
validitate 12 luni de la emitere, executie 6/12/24/36 luni, prelungire +24 luni.
Reminder lunar automat (snooze/dismiss disponibil).
</span>
</div>
</div>
<div className="flex items-start gap-2">
<span className="mt-1 h-1.5 w-1.5 shrink-0 rounded-full bg-blue-500" />
<div className="text-xs">
<span className="font-medium">Monitorizare externa</span>
<span className="ml-1 text-muted-foreground">
verificare automata status la autoritate (Primaria Cluj etc.). Notificare la schimbare status.
</span>
</div>
</div>
</div>
<p className="text-[10px] text-muted-foreground italic">
Transmiterea in termen (1 zi) se verifica automat in fundal cand un entry e inchis prin act administrativ.
</p>
</div>
</div>
{/* ── Legal reference index ── */}
<div className="rounded-lg border bg-card">
<button
onClick={() => setShowLegalIndex(!showLegalIndex)}
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
>
<Scale className="h-4 w-4 text-muted-foreground" />
<span className="flex-1 text-sm font-medium">Index referinte legale</span>
<Badge variant="outline" className="text-[10px]">
{legalIndex.size} ref.
</Badge>
{showLegalIndex ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</button>
{showLegalIndex && (
<>
<Separator />
<div className="px-4 py-3 space-y-2">
{Array.from(legalIndex.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([ref, types]) => (
<div key={ref} className="text-xs">
<span className="font-medium text-primary">{ref}</span>
<span className="ml-2 text-muted-foreground">
{types.map((t) => t.label).join(", ")}
</span>
</div>
))}
</div>
</>
)}
</div>
</div>
);
}
// ── Stat card ──
function StatCard({
icon,
value,
label,
}: {
icon: React.ReactNode;
value: number;
label: string;
}) {
return (
<div className="flex items-center gap-3 rounded-lg border bg-card px-3 py-2.5">
{icon}
<div>
<p className="text-lg font-semibold leading-none">{value}</p>
<p className="text-[10px] text-muted-foreground">{label}</p>
</div>
</div>
);
}
// ── Deadline type row ──
function DeadlineTypeRow({
type,
descExpanded,
onToggleDesc,
}: {
type: DeadlineTypeDef;
descExpanded: boolean;
onToggleDesc: () => void;
}) {
const chainTarget = type.chainNextTypeId
? getDeadlineType(type.chainNextTypeId)
: undefined;
return (
<div
className={cn(
"px-4 py-2.5",
type.backgroundOnly && "opacity-60",
)}
>
<div className="flex items-center gap-2 flex-wrap">
{/* Label */}
<span className="text-xs font-medium">{type.label}</span>
{/* Duration badge */}
<Badge
variant="outline"
className={cn(
"text-[10px]",
type.dayType === "working"
? "bg-green-50 text-green-700 border-green-300 dark:bg-green-900/20 dark:text-green-300 dark:border-green-700"
: "bg-orange-50 text-orange-700 border-orange-300 dark:bg-orange-900/20 dark:text-orange-300 dark:border-orange-700",
)}
>
<Timer className="mr-0.5 inline h-2.5 w-2.5" />
{type.days}z {type.dayType === "working" ? "lucr." : "cal."}
</Badge>
{/* Auto/Manual badge */}
{type.autoTrack ? (
<Badge
variant="outline"
className="bg-purple-50 text-purple-700 border-purple-300 text-[10px] dark:bg-purple-900/20 dark:text-purple-300 dark:border-purple-700"
>
Auto
</Badge>
) : (
<Badge variant="outline" className="text-[10px] text-muted-foreground">
Manual
</Badge>
)}
{/* Tacit badge */}
{type.tacitApprovalApplicable && (
<Badge
variant="outline"
className="bg-blue-50 text-blue-700 border-blue-300 text-[10px] dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-700"
>
<ShieldCheck className="mr-0.5 inline h-2.5 w-2.5" />
Tacit
</Badge>
)}
{/* Background badge */}
{type.backgroundOnly && (
<Badge variant="outline" className="border-dashed text-[10px] text-muted-foreground">
Fundal
</Badge>
)}
{/* Direction filter */}
{type.directionFilter && type.directionFilter.length === 1 && (
<Badge variant="outline" className="text-[10px] text-muted-foreground">
{type.directionFilter[0] === "iesit" ? "Iesit" : "Intrat"}
</Badge>
)}
{/* Info toggle */}
<button
onClick={onToggleDesc}
className="ml-auto shrink-0 text-muted-foreground hover:text-foreground"
title="Detalii"
>
<Info className="h-3.5 w-3.5" />
</button>
</div>
{/* Chain indicator */}
{chainTarget && (
<div className="mt-1 flex items-center gap-1.5 text-[10px] text-amber-600 dark:text-amber-400">
<ArrowRight className="h-3 w-3" />
<span>
La rezolvare lanseaza: <span className="font-medium">{chainTarget.label}</span>
{" "}({chainTarget.days}z {chainTarget.dayType === "working" ? "lucr." : "cal."})
</span>
</div>
)}
{/* Description + legal ref (expanded) */}
{descExpanded && (
<div className="mt-2 space-y-1 rounded-md bg-muted/30 px-3 py-2">
<p className="text-[11px] text-muted-foreground leading-relaxed">
{type.description}
</p>
{type.legalReference && (
<p className="text-[10px] text-primary/70">
<Scale className="mr-0.5 inline h-2.5 w-2.5" />
{type.legalReference}
</p>
)}
{type.startDateHint && (
<p className="text-[10px] text-muted-foreground italic">
<Clock className="mr-0.5 inline h-2.5 w-2.5" />
{type.startDateHint}
</p>
)}
</div>
)}
</div>
);
}
// ── Chain flow diagram ──
function ChainFlowDiagram({ group }: { group: ChainGroup }) {
const isFanIn = group.sources.length > 1;
if (isFanIn) {
return (
<div className="flex items-center gap-0">
{/* Multiple sources stacked */}
<div className="flex flex-col gap-1">
{group.sources.map((src) => (
<ChainNode key={src.id} type={src} small />
))}
</div>
{/* Converging arrows */}
<div className="flex items-center px-1">
<div className="flex flex-col items-end">
{group.sources.map((_, i) => (
<div key={i} className="h-px w-4 bg-amber-400/60" />
))}
</div>
<div className="h-px w-3 bg-amber-400" />
<div className="border-y-[4px] border-l-[6px] border-y-transparent border-l-amber-400" />
</div>
{/* Target */}
<ChainNode type={group.target} isTarget />
</div>
);
}
// Single chain: source → target
const src = group.sources[0];
if (!src) return null;
return (
<div className="flex items-center gap-0">
<ChainNode type={src} />
<div className="flex items-center px-1">
<div className="h-px w-6 bg-amber-400" />
<div className="border-y-[4px] border-l-[6px] border-y-transparent border-l-amber-400" />
</div>
<ChainNode type={group.target} isTarget />
</div>
);
}
function ChainNode({
type,
isTarget,
small,
}: {
type: DeadlineTypeDef;
isTarget?: boolean;
small?: boolean;
}) {
return (
<div
className={cn(
"rounded-md border px-2 py-1",
small ? "max-w-[160px]" : "max-w-[200px]",
isTarget
? "border-amber-400 bg-amber-50/50 dark:bg-amber-950/20 dark:border-amber-700"
: "border-border bg-background",
)}
>
<p className={cn("font-medium leading-tight", small ? "text-[9px]" : "text-[10px]")}>
{type.label}
</p>
<p className="text-[9px] text-muted-foreground">
{type.days}z {type.dayType === "working" ? "lucr." : "cal."}
{type.tacitApprovalApplicable && (
<span className="ml-1 text-blue-600 dark:text-blue-400">tacit</span>
)}
</p>
</div>
);
}
@@ -38,6 +38,7 @@ import { RegistryTable } from "./registry-table";
import { RegistryEntryForm } from "./registry-entry-form";
import { RegistryEntryDetail } from "./registry-entry-detail";
import { DeadlineDashboard } from "./deadline-dashboard";
import { DeadlineConfigOverview } from "./deadline-config-overview";
import { ThreadExplorer } from "./thread-explorer";
import { CloseGuardDialog } from "./close-guard-dialog";
import { getOverdueDays } from "../services/registry-service";
@@ -81,6 +82,7 @@ export function RegistraturaModule() {
const [conexToEntry, setConexToEntry] = useState<RegistryEntry | null>(null);
/** If set, the parent entry will be closed after saving the new reply entry */
const [closesEntryId, setClosesEntryId] = useState<string | null>(null);
const [termeneView, setTermeneView] = useState<"active" | "guide">("active");
const [closingId, setClosingId] = useState<string | null>(null);
const [linkCheckId, setLinkCheckId] = useState<string | null>(null);
@@ -630,11 +632,34 @@ export function RegistraturaModule() {
</TabsContent>
<TabsContent value="termene">
<DeadlineDashboard
entries={allEntries}
onResolveDeadline={handleDashboardResolve}
onAddChainedDeadline={handleAddChainedDeadline}
/>
{/* Sub-navigation: active deadlines vs. system guide */}
<div className="mb-4 flex items-center gap-2">
<Button
variant={termeneView === "active" ? "default" : "outline"}
size="sm"
onClick={() => setTermeneView("active")}
>
Termene active
</Button>
<Button
variant={termeneView === "guide" ? "default" : "outline"}
size="sm"
onClick={() => setTermeneView("guide")}
>
<BookOpen className="mr-1.5 h-3.5 w-3.5" />
Ghid termene
</Button>
</div>
{termeneView === "active" ? (
<DeadlineDashboard
entries={allEntries}
onResolveDeadline={handleDashboardResolve}
onAddChainedDeadline={handleAddChainedDeadline}
/>
) : (
<DeadlineConfigOverview />
)}
</TabsContent>
</Tabs>
);