feat(registratura): structured ClosureInfo who/when/why/attachment for every close

- Added ClosureInfo type with reason, closedBy, closedAt, linkedEntry, hadActiveDeadlines, attachment
- Rewrote close-guard-dialog into universal close dialog (always shown on close)
  - Reason field (always required)
  - Optional continuation entry search+link
  - Optional closing document attachment (file upload)
  - Active deadlines shown as warning banner when present
- Created ClosureBanner component (read-only, shown at top of closed entry edit)
  - Shows who, when, why, linked entry (clickable), attached doc (downloadable)
- All closes now go through the dialog  no more silent closeEntry
- Linked-entries sub-dialog preserved as second step
This commit is contained in:
AI Assistant
2026-02-27 17:06:03 +02:00
parent 5b99ad0400
commit db6662be39
7 changed files with 539 additions and 258 deletions
@@ -1,7 +1,14 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo, useRef } from "react";
import { AlertTriangle, Search, Link2 } from "lucide-react"; import {
AlertTriangle,
Search,
Link2,
Paperclip,
X,
FileText,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input"; import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label"; import { Label } from "@/shared/components/ui/label";
@@ -14,8 +21,14 @@ import {
DialogTitle, DialogTitle,
DialogFooter, DialogFooter,
} from "@/shared/components/ui/dialog"; } from "@/shared/components/ui/dialog";
import type { RegistryEntry, TrackedDeadline } from "../types"; import type {
RegistryEntry,
TrackedDeadline,
RegistryAttachment,
ClosureInfo,
} from "../types";
import { getDeadlineType } from "../services/deadline-catalog"; import { getDeadlineType } from "../services/deadline-catalog";
import { v4 as uuid } from "uuid";
interface CloseGuardDialogProps { interface CloseGuardDialogProps {
open: boolean; open: boolean;
@@ -24,19 +37,18 @@ interface CloseGuardDialogProps {
entry: RegistryEntry; entry: RegistryEntry;
/** All entries for search/linking */ /** All entries for search/linking */
allEntries: RegistryEntry[]; allEntries: RegistryEntry[];
/** Active deadlines on this entry */ /** Active (pending) deadlines — empty if none */
activeDeadlines: TrackedDeadline[]; activeDeadlines: TrackedDeadline[];
/** Called when user provides justification and confirms close */ /** Called when user confirms close with all structured data */
onConfirmClose: (justification: { onConfirmClose: (info: ClosureInfo) => void;
linkedEntryId?: string;
reason: string;
}) => void;
} }
/** /**
* Guard dialog shown when a user tries to close an entry that has * Universal close dialog: always shown when closing any entry.
* unresolved (active) legal deadlines. Requires justification: * - If active deadlines exist → shows a warning banner
* either link to a continuation entry or provide manual reason text. * - Requires a reason (free text)
* - Optionally link a continuation entry
* - Optionally attach a closing document (e.g. scanned letter)
*/ */
export function CloseGuardDialog({ export function CloseGuardDialog({
open, open,
@@ -46,23 +58,26 @@ export function CloseGuardDialog({
activeDeadlines, activeDeadlines,
onConfirmClose, onConfirmClose,
}: CloseGuardDialogProps) { }: CloseGuardDialogProps) {
const [mode, setMode] = useState<"link" | "manual">("link");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [selectedEntryId, setSelectedEntryId] = useState(""); const [selectedEntryId, setSelectedEntryId] = useState("");
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [attachment, setAttachment] = useState<RegistryAttachment | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const hasDeadlines = activeDeadlines.length > 0;
// Reset on open // Reset on open
const handleOpenChange = (o: boolean) => { const handleOpenChange = (o: boolean) => {
if (o) { if (o) {
setMode("link");
setSearch(""); setSearch("");
setSelectedEntryId(""); setSelectedEntryId("");
setReason(""); setReason("");
setAttachment(null);
} }
onOpenChange(o); onOpenChange(o);
}; };
// Searchable entries (exclude self and closed entries) // Searchable entries (exclude self)
const searchResults = useMemo(() => { const searchResults = useMemo(() => {
if (!search || search.length < 2) return []; if (!search || search.length < 2) return [];
const q = search.toLowerCase(); const q = search.toLowerCase();
@@ -70,7 +85,6 @@ export function CloseGuardDialog({
.filter( .filter(
(e) => (e) =>
e.id !== entry.id && e.id !== entry.id &&
e.status !== "inchis" &&
(e.subject.toLowerCase().includes(q) || (e.subject.toLowerCase().includes(q) ||
e.number.toLowerCase().includes(q) || e.number.toLowerCase().includes(q) ||
e.sender.toLowerCase().includes(q) || e.sender.toLowerCase().includes(q) ||
@@ -81,17 +95,36 @@ export function CloseGuardDialog({
const selectedEntry = allEntries.find((e) => e.id === selectedEntryId); const selectedEntry = allEntries.find((e) => e.id === selectedEntryId);
const canSubmit = const canSubmit = reason.trim().length >= 3;
mode === "link" ? selectedEntryId !== "" : reason.trim().length >= 5;
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
setAttachment({
id: uuid(),
name: file.name,
data: reader.result as string,
type: file.type,
size: file.size,
addedAt: new Date().toISOString(),
});
};
reader.readAsDataURL(file);
e.target.value = "";
};
const handleSubmit = () => { const handleSubmit = () => {
if (!canSubmit) return; if (!canSubmit) return;
onConfirmClose({ onConfirmClose({
linkedEntryId: mode === "link" ? selectedEntryId : undefined, reason: reason.trim(),
reason: closedBy: "Utilizator", // TODO: replace with SSO identity
mode === "link" closedAt: new Date().toISOString(),
? `Continuat în #${selectedEntry?.number ?? selectedEntryId}` linkedEntryId: selectedEntryId || undefined,
: reason.trim(), linkedEntryNumber: selectedEntry?.number,
hadActiveDeadlines: hasDeadlines,
attachment: attachment ?? undefined,
}); });
}; };
@@ -99,127 +132,170 @@ export function CloseGuardDialog({
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-amber-600 dark:text-amber-400"> <DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5" /> {hasDeadlines ? (
Termene legale active <>
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400" />
<span className="text-amber-600 dark:text-amber-400">
Închidere cu termene active
</span>
</>
) : (
<>Închide înregistrarea {entry.number}</>
)}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{/* Warning message */} {/* Active deadlines warning */}
<p className="text-sm text-muted-foreground"> {hasDeadlines && (
Înregistrarea <strong>{entry.number}</strong> are{" "} <div className="rounded border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/30 p-3 space-y-1.5">
<strong>{activeDeadlines.length}</strong> termen <p className="text-sm text-amber-800 dark:text-amber-200">
{activeDeadlines.length > 1 ? "e" : ""} legal <strong>{activeDeadlines.length}</strong> termen
{activeDeadlines.length > 1 ? "e" : ""} nerezolvat {activeDeadlines.length > 1 ? "e" : ""} legal
{activeDeadlines.length > 1 ? "e" : ""}. Pentru a o închide, {activeDeadlines.length > 1 ? "e" : ""} nerezolvat
justifică decizia. {activeDeadlines.length > 1 ? "e" : ""}:
</p> </p>
{activeDeadlines.map((dl) => {
{/* Active deadlines list */} const def = getDeadlineType(dl.typeId);
<div className="rounded border p-2 space-y-1.5"> const isOverdue = new Date(dl.dueDate) < new Date();
{activeDeadlines.map((dl) => { return (
const def = getDeadlineType(dl.typeId); <div key={dl.id} className="flex items-center gap-2 text-sm">
const isOverdue = new Date(dl.dueDate) < new Date(); <Badge
return ( variant={isOverdue ? "destructive" : "outline"}
<div key={dl.id} className="flex items-center gap-2 text-sm"> className="text-[10px] shrink-0"
<Badge
variant={isOverdue ? "destructive" : "outline"}
className="text-[10px] shrink-0"
>
{isOverdue ? "Depășit" : "Activ"}
</Badge>
<span className="font-medium">{def?.label ?? dl.typeId}</span>
<span className="text-muted-foreground text-xs ml-auto">
scadent {dl.dueDate}
</span>
</div>
);
})}
</div>
{/* Mode toggle */}
<div className="flex gap-2">
<Button
type="button"
variant={mode === "link" ? "default" : "outline"}
size="sm"
onClick={() => setMode("link")}
className="flex-1"
>
<Link2 className="mr-1.5 h-3.5 w-3.5" />
Leagă de altă înregistrare
</Button>
<Button
type="button"
variant={mode === "manual" ? "default" : "outline"}
size="sm"
onClick={() => setMode("manual")}
className="flex-1"
>
Justificare manuală
</Button>
</div>
{/* Link mode */}
{mode === "link" && (
<div className="space-y-2">
<Label>Caută înregistrarea-continuare</Label>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value);
setSelectedEntryId("");
}}
placeholder="Caută după nr., subiect, expeditor..."
className="pl-9"
/>
</div>
{searchResults.length > 0 && (
<div className="max-h-48 overflow-y-auto rounded border divide-y">
{searchResults.map((e) => (
<button
key={e.id}
type="button"
className={`w-full px-3 py-2 text-left text-sm hover:bg-accent transition-colors ${
selectedEntryId === e.id ? "bg-accent" : ""
}`}
onClick={() => setSelectedEntryId(e.id)}
> >
<span className="font-mono font-medium text-xs"> {isOverdue ? "Depășit" : "Activ"}
{e.number} </Badge>
</span> <span className="font-medium">
<span className="ml-2">{e.subject}</span> {def?.label ?? dl.typeId}
<span className="ml-2 text-muted-foreground text-xs"> </span>
{e.sender} <span className="text-muted-foreground text-xs ml-auto">
</span> scadent {dl.dueDate}
</button> </span>
))} </div>
</div> );
)} })}
{selectedEntry && (
<p className="text-xs text-muted-foreground">
Selectat: <strong>{selectedEntry.number}</strong> {" "}
{selectedEntry.subject}
</p>
)}
</div> </div>
)} )}
{/* Manual mode */} {/* Reason — always required */}
{mode === "manual" && ( <div>
<div className="space-y-2"> <Label>Motiv închidere *</Label>
<Label>Motiv închidere (min. 5 caractere)</Label> <Textarea
<Textarea value={reason}
value={reason} onChange={(e) => setReason(e.target.value)}
onChange={(e) => setReason(e.target.value)} placeholder="Ex: Dosarul a fost finalizat, retras de beneficiar, preluat într-o altă cerere..."
placeholder="Ex: Dosarul a fost retras de beneficiar..." rows={3}
rows={3} className="mt-1"
autoFocus
/>
</div>
{/* Link to continuation entry — optional */}
<div className="space-y-2">
<Label className="flex items-center gap-1.5">
<Link2 className="h-3.5 w-3.5" />
Legătură la înregistrarea-continuare
<span className="text-xs text-muted-foreground">(opțional)</span>
</Label>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value);
setSelectedEntryId("");
}}
placeholder="Caută după nr., subiect, expeditor..."
className="pl-9"
/> />
</div> </div>
)} {searchResults.length > 0 && !selectedEntryId && (
<div className="max-h-36 overflow-y-auto rounded border divide-y">
{searchResults.map((e) => (
<button
key={e.id}
type="button"
className="w-full px-3 py-2 text-left text-sm hover:bg-accent transition-colors"
onClick={() => {
setSelectedEntryId(e.id);
setSearch(e.number);
}}
>
<span className="font-mono font-medium text-xs">
{e.number}
</span>
<span className="ml-2">{e.subject}</span>
<span className="ml-2 text-muted-foreground text-xs">
{e.sender}
</span>
</button>
))}
</div>
)}
{selectedEntry && (
<div className="flex items-center gap-2 text-sm rounded border px-2 py-1.5 bg-muted/50">
<Link2 className="h-3.5 w-3.5 text-primary shrink-0" />
<span className="font-mono text-xs font-medium">
{selectedEntry.number}
</span>
<span className="truncate">{selectedEntry.subject}</span>
<button
type="button"
className="ml-auto text-muted-foreground hover:text-destructive"
onClick={() => {
setSelectedEntryId("");
setSearch("");
}}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
{/* Attachment — optional closing document */}
<div className="space-y-2">
<Label className="flex items-center gap-1.5">
<Paperclip className="h-3.5 w-3.5" />
Document de închidere
<span className="text-xs text-muted-foreground">(opțional)</span>
</Label>
{!attachment ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileRef.current?.click()}
>
<Paperclip className="mr-1.5 h-3.5 w-3.5" />
Atașează document
</Button>
) : (
<div className="flex items-center gap-2 rounded border px-2 py-1.5 text-sm">
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="flex-1 truncate">{attachment.name}</span>
<Badge variant="outline" className="text-[10px]">
{(attachment.size / 1024).toFixed(0)} KB
</Badge>
<button
type="button"
onClick={() => setAttachment(null)}
className="text-destructive"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
<input
ref={fileRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.xls,.xlsx"
onChange={handleFileUpload}
className="hidden"
/>
</div>
</div> </div>
<DialogFooter> <DialogFooter>
@@ -234,7 +310,7 @@ export function CloseGuardDialog({
type="button" type="button"
onClick={handleSubmit} onClick={handleSubmit}
disabled={!canSubmit} disabled={!canSubmit}
variant="destructive" variant={hasDeadlines ? "destructive" : "default"}
> >
Închide înregistrarea Închide înregistrarea
</Button> </Button>
@@ -0,0 +1,128 @@
"use client";
import {
Lock,
Calendar,
User,
Link2,
FileText,
AlertTriangle,
Download,
} from "lucide-react";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import type { ClosureInfo, RegistryEntry } from "../types";
interface ClosureBannerProps {
closureInfo: ClosureInfo;
/** Navigate to the linked continuation entry */
onNavigateLinked?: (entry: RegistryEntry) => void;
/** All entries — to find the linked entry for navigation */
allEntries?: RegistryEntry[];
}
/**
* Read-only banner displayed at the top of a closed entry,
* showing who closed it, when, why, linked entry, and attachment.
*/
export function ClosureBanner({
closureInfo,
onNavigateLinked,
allEntries,
}: ClosureBannerProps) {
const linkedEntry =
closureInfo.linkedEntryId && allEntries
? allEntries.find((e) => e.id === closureInfo.linkedEntryId)
: null;
const closedDate = new Date(closureInfo.closedAt).toLocaleDateString(
"ro-RO",
{ day: "2-digit", month: "long", year: "numeric" },
);
const handleDownload = () => {
if (!closureInfo.attachment) return;
const a = document.createElement("a");
a.href = closureInfo.attachment.data;
a.download = closureInfo.attachment.name;
a.click();
};
return (
<div className="rounded-lg border border-muted bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<Lock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold">Înregistrare închisă</span>
{closureInfo.hadActiveDeadlines && (
<Badge
variant="outline"
className="text-[10px] text-amber-600 border-amber-300 dark:text-amber-400 dark:border-amber-700"
>
<AlertTriangle className="mr-1 h-3 w-3" />
Avea termene active
</Badge>
)}
</div>
{/* Who + When */}
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-muted-foreground">
<span className="flex items-center gap-1.5">
<User className="h-3.5 w-3.5" />
{closureInfo.closedBy}
</span>
<span className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5" />
{closedDate}
</span>
</div>
{/* Reason */}
<div className="text-sm">
<span className="font-medium">Motiv: </span>
{closureInfo.reason}
</div>
{/* Linked entry */}
{(linkedEntry || closureInfo.linkedEntryNumber) && (
<div className="flex items-center gap-2 text-sm">
<Link2 className="h-3.5 w-3.5 text-primary" />
<span className="text-muted-foreground">Continuat în:</span>
{linkedEntry && onNavigateLinked ? (
<button
type="button"
className="text-primary hover:underline font-medium"
onClick={() => onNavigateLinked(linkedEntry)}
>
{linkedEntry.number} {linkedEntry.subject}
</button>
) : (
<span className="font-mono text-xs font-medium">
{closureInfo.linkedEntryNumber ?? closureInfo.linkedEntryId}
</span>
)}
</div>
)}
{/* Attached document */}
{closureInfo.attachment && (
<div className="flex items-center gap-2 text-sm">
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate">{closureInfo.attachment.name}</span>
<Badge variant="outline" className="text-[10px]">
{(closureInfo.attachment.size / 1024).toFixed(0)} KB
</Badge>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={handleDownload}
>
<Download className="mr-1 h-3 w-3" />
Descarcă
</Button>
</div>
)}
</div>
);
}
@@ -1,16 +1,23 @@
'use client'; "use client";
import { useState, useMemo } from 'react'; import { useState, useMemo } from "react";
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, Dialog,
} from '@/shared/components/ui/dialog'; DialogContent,
import { Button } from '@/shared/components/ui/button'; DialogHeader,
import { Input } from '@/shared/components/ui/input'; DialogTitle,
import { Label } from '@/shared/components/ui/label'; DialogFooter,
import { Badge } from '@/shared/components/ui/badge'; } from "@/shared/components/ui/dialog";
import { DEADLINE_CATALOG, CATEGORY_LABELS } from '../services/deadline-catalog'; import { Button } from "@/shared/components/ui/button";
import { computeDueDate } from '../services/working-days'; import { Input } from "@/shared/components/ui/input";
import type { DeadlineCategory, DeadlineTypeDef } from '../types'; import { Label } from "@/shared/components/ui/label";
import { Badge } from "@/shared/components/ui/badge";
import {
DEADLINE_CATALOG,
CATEGORY_LABELS,
} from "../services/deadline-catalog";
import { computeDueDate } from "../services/working-days";
import type { DeadlineCategory, DeadlineTypeDef } from "../types";
interface DeadlineAddDialogProps { interface DeadlineAddDialogProps {
open: boolean; open: boolean;
@@ -19,14 +26,29 @@ interface DeadlineAddDialogProps {
onAdd: (typeId: string, startDate: string) => void; onAdd: (typeId: string, startDate: string) => void;
} }
type Step = 'category' | 'type' | 'date'; type Step = "category" | "type" | "date";
const CATEGORIES: DeadlineCategory[] = ['certificat', 'avize', 'completari', 'analiza', 'autorizare', 'publicitate']; const CATEGORIES: DeadlineCategory[] = [
"certificat",
"avize",
"completari",
"analiza",
"autorizare",
"publicitate",
];
export function DeadlineAddDialog({ open, onOpenChange, entryDate, onAdd }: DeadlineAddDialogProps) { export function DeadlineAddDialog({
const [step, setStep] = useState<Step>('category'); open,
const [selectedCategory, setSelectedCategory] = useState<DeadlineCategory | null>(null); onOpenChange,
const [selectedType, setSelectedType] = useState<DeadlineTypeDef | null>(null); entryDate,
onAdd,
}: DeadlineAddDialogProps) {
const [step, setStep] = useState<Step>("category");
const [selectedCategory, setSelectedCategory] =
useState<DeadlineCategory | null>(null);
const [selectedType, setSelectedType] = useState<DeadlineTypeDef | null>(
null,
);
const [startDate, setStartDate] = useState(entryDate); const [startDate, setStartDate] = useState(entryDate);
const typesForCategory = useMemo(() => { const typesForCategory = useMemo(() => {
@@ -38,12 +60,21 @@ export function DeadlineAddDialog({ open, onOpenChange, entryDate, onAdd }: Dead
if (!selectedType || !startDate) return null; if (!selectedType || !startDate) return null;
const start = new Date(startDate); const start = new Date(startDate);
start.setHours(0, 0, 0, 0); start.setHours(0, 0, 0, 0);
const due = computeDueDate(start, selectedType.days, selectedType.dayType, selectedType.isBackwardDeadline); const due = computeDueDate(
return due.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' }); start,
selectedType.days,
selectedType.dayType,
selectedType.isBackwardDeadline,
);
return due.toLocaleDateString("ro-RO", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}, [selectedType, startDate]); }, [selectedType, startDate]);
const handleClose = () => { const handleClose = () => {
setStep('category'); setStep("category");
setSelectedCategory(null); setSelectedCategory(null);
setSelectedType(null); setSelectedType(null);
setStartDate(entryDate); setStartDate(entryDate);
@@ -52,7 +83,7 @@ export function DeadlineAddDialog({ open, onOpenChange, entryDate, onAdd }: Dead
const handleCategorySelect = (cat: DeadlineCategory) => { const handleCategorySelect = (cat: DeadlineCategory) => {
setSelectedCategory(cat); setSelectedCategory(cat);
setStep('type'); setStep("type");
}; };
const handleTypeSelect = (typ: DeadlineTypeDef) => { const handleTypeSelect = (typ: DeadlineTypeDef) => {
@@ -60,15 +91,15 @@ export function DeadlineAddDialog({ open, onOpenChange, entryDate, onAdd }: Dead
if (!typ.requiresCustomStartDate) { if (!typ.requiresCustomStartDate) {
setStartDate(entryDate); setStartDate(entryDate);
} }
setStep('date'); setStep("date");
}; };
const handleBack = () => { const handleBack = () => {
if (step === 'type') { if (step === "type") {
setStep('category'); setStep("category");
setSelectedCategory(null); setSelectedCategory(null);
} else if (step === 'date') { } else if (step === "date") {
setStep('type'); setStep("type");
setSelectedType(null); setSelectedType(null);
} }
}; };
@@ -80,17 +111,24 @@ export function DeadlineAddDialog({ open, onOpenChange, entryDate, onAdd }: Dead
}; };
return ( return (
<Dialog open={open} onOpenChange={(o) => { if (!o) handleClose(); }}> <Dialog
open={open}
onOpenChange={(o) => {
if (!o) handleClose();
}}
>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{step === 'category' && 'Adaugă termen legal — Categorie'} {step === "category" && "Adaugă termen legal — Categorie"}
{step === 'type' && `Adaugă termen legal — ${selectedCategory ? CATEGORY_LABELS[selectedCategory] : ''}`} {step === "type" &&
{step === 'date' && `Adaugă termen legal — ${selectedType?.label ?? ''}`} `Adaugă termen legal — ${selectedCategory ? CATEGORY_LABELS[selectedCategory] : ""}`}
{step === "date" &&
`Adaugă termen legal — ${selectedType?.label ?? ""}`}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
{step === 'category' && ( {step === "category" && (
<div className="grid gap-2 py-2"> <div className="grid gap-2 py-2">
{CATEGORIES.map((cat) => ( {CATEGORIES.map((cat) => (
<button <button
@@ -99,7 +137,9 @@ export function DeadlineAddDialog({ open, onOpenChange, entryDate, onAdd }: Dead
className="flex items-center justify-between rounded-lg border p-3 text-left transition-colors hover:bg-accent" className="flex items-center justify-between rounded-lg border p-3 text-left transition-colors hover:bg-accent"
onClick={() => handleCategorySelect(cat)} onClick={() => handleCategorySelect(cat)}
> >
<span className="font-medium text-sm">{CATEGORY_LABELS[cat]}</span> <span className="font-medium text-sm">
{CATEGORY_LABELS[cat]}
</span>
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{DEADLINE_CATALOG.filter((d) => d.category === cat).length} {DEADLINE_CATALOG.filter((d) => d.category === cat).length}
</Badge> </Badge>
@@ -108,7 +148,7 @@ export function DeadlineAddDialog({ open, onOpenChange, entryDate, onAdd }: Dead
</div> </div>
)} )}
{step === 'type' && ( {step === "type" && (
<div className="grid gap-2 py-2"> <div className="grid gap-2 py-2">
{typesForCategory.map((typ) => ( {typesForCategory.map((typ) => (
<button <button
@@ -120,27 +160,42 @@ export function DeadlineAddDialog({ open, onOpenChange, entryDate, onAdd }: Dead
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-sm">{typ.label}</span> <span className="font-medium text-sm">{typ.label}</span>
<Badge variant="outline" className="text-[10px]"> <Badge variant="outline" className="text-[10px]">
{typ.days} {typ.dayType === 'working' ? 'zile lucr.' : 'zile cal.'} {typ.days}{" "}
{typ.dayType === "working" ? "zile lucr." : "zile cal."}
</Badge> </Badge>
{typ.tacitApprovalApplicable && ( {typ.tacitApprovalApplicable && (
<Badge variant="outline" className="text-[10px] text-blue-600">tacit</Badge> <Badge
variant="outline"
className="text-[10px] text-blue-600"
>
tacit
</Badge>
)} )}
{typ.isBackwardDeadline && ( {typ.isBackwardDeadline && (
<Badge variant="outline" className="text-[10px] text-orange-600">înapoi</Badge> <Badge
variant="outline"
className="text-[10px] text-orange-600"
>
înapoi
</Badge>
)} )}
</div> </div>
<p className="text-xs text-muted-foreground mt-1">{typ.description}</p> <p className="text-xs text-muted-foreground mt-1">
{typ.description}
</p>
</button> </button>
))} ))}
</div> </div>
)} )}
{step === 'date' && selectedType && ( {step === "date" && selectedType && (
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div> <div>
<Label>{selectedType.startDateLabel}</Label> <Label>{selectedType.startDateLabel}</Label>
{selectedType.startDateHint && ( {selectedType.startDateHint && (
<p className="text-xs text-muted-foreground mt-0.5">{selectedType.startDateHint}</p> <p className="text-xs text-muted-foreground mt-0.5">
{selectedType.startDateHint}
</p>
)} )}
<Input <Input
type="date" type="date"
@@ -153,15 +208,24 @@ export function DeadlineAddDialog({ open, onOpenChange, entryDate, onAdd }: Dead
{dueDatePreview && ( {dueDatePreview && (
<div className="rounded-lg border bg-muted/30 p-3"> <div className="rounded-lg border bg-muted/30 p-3">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{selectedType.isBackwardDeadline ? 'Termen limită depunere' : 'Termen limită calculat'} {selectedType.isBackwardDeadline
? "Termen limită depunere"
: "Termen limită calculat"}
</p> </p>
<p className="text-lg font-bold">{dueDatePreview}</p> <p className="text-lg font-bold">{dueDatePreview}</p>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{selectedType.days} {selectedType.dayType === 'working' ? 'zile lucrătoare' : 'zile calendaristice'} {selectedType.days}{" "}
{selectedType.isBackwardDeadline ? ' ÎNAINTE' : ' de la data start'} {selectedType.dayType === "working"
? "zile lucrătoare"
: "zile calendaristice"}
{selectedType.isBackwardDeadline
? " ÎNAINTE"
: " de la data start"}
</p> </p>
{selectedType.legalReference && ( {selectedType.legalReference && (
<p className="text-xs text-muted-foreground mt-1 italic">Ref: {selectedType.legalReference}</p> <p className="text-xs text-muted-foreground mt-1 italic">
Ref: {selectedType.legalReference}
</p>
)} )}
</div> </div>
)} )}
@@ -169,13 +233,17 @@ export function DeadlineAddDialog({ open, onOpenChange, entryDate, onAdd }: Dead
)} )}
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
{step !== 'category' && ( {step !== "category" && (
<Button type="button" variant="outline" onClick={handleBack}>Înapoi</Button> <Button type="button" variant="outline" onClick={handleBack}>
Înapoi
</Button>
)} )}
{step === 'category' && ( {step === "category" && (
<Button type="button" variant="outline" onClick={handleClose}>Anulează</Button> <Button type="button" variant="outline" onClick={handleClose}>
Anulează
</Button>
)} )}
{step === 'date' && ( {step === "date" && (
<Button type="button" onClick={handleConfirm} disabled={!startDate}> <Button type="button" onClick={handleConfirm} disabled={!startDate}>
Adaugă termen Adaugă termen
</Button> </Button>
@@ -33,7 +33,7 @@ import { DeadlineDashboard } from "./deadline-dashboard";
import { CloseGuardDialog } from "./close-guard-dialog"; import { CloseGuardDialog } from "./close-guard-dialog";
import { getOverdueDays } from "../services/registry-service"; import { getOverdueDays } from "../services/registry-service";
import { aggregateDeadlines } from "../services/deadline-service"; import { aggregateDeadlines } from "../services/deadline-service";
import type { RegistryEntry, DeadlineResolution } from "../types"; import type { RegistryEntry, DeadlineResolution, ClosureInfo } from "../types";
import type { AddressContact } from "@/modules/address-book/types"; import type { AddressContact } from "@/modules/address-book/types";
type ViewMode = "list" | "add" | "edit"; type ViewMode = "list" | "add" | "edit";
@@ -60,7 +60,7 @@ export function RegistraturaModule() {
const [viewMode, setViewMode] = useState<ViewMode>("list"); const [viewMode, setViewMode] = useState<ViewMode>("list");
const [editingEntry, setEditingEntry] = useState<RegistryEntry | null>(null); const [editingEntry, setEditingEntry] = useState<RegistryEntry | null>(null);
const [closingId, setClosingId] = useState<string | null>(null); const [closingId, setClosingId] = useState<string | null>(null);
const [guardingId, setGuardingId] = useState<string | null>(null); const [linkCheckId, setLinkCheckId] = useState<string | null>(null);
// ── Bidirectional Address Book integration ── // ── Bidirectional Address Book integration ──
const handleCreateContact = useCallback( const handleCreateContact = useCallback(
@@ -143,57 +143,35 @@ export function RegistraturaModule() {
await removeEntry(id); await removeEntry(id);
}; };
/** All closes go through the close dialog */
const handleCloseRequest = (id: string) => { const handleCloseRequest = (id: string) => {
const entry = allEntries.find((e) => e.id === id); setClosingId(id);
};
/** Called when the close dialog is confirmed — stores closureInfo then closes */
const handleCloseConfirm = async (info: ClosureInfo) => {
if (!closingId) return;
const entry = allEntries.find((e) => e.id === closingId);
if (!entry) return; if (!entry) return;
// Check for active (unresolved) legal deadlines // Store structured closure info on the entry
const activeDeadlines = (entry.trackedDeadlines ?? []).filter( await updateEntry(closingId, { closureInfo: info });
(d) => d.resolution === "pending",
);
if (activeDeadlines.length > 0) {
// Show guard dialog first
setGuardingId(id);
return;
}
// No active deadlines — proceed to linked-entries check // Check for linked entries
if ((entry.linkedEntryIds ?? []).length > 0) { if ((entry.linkedEntryIds ?? []).length > 0) {
setClosingId(id); setLinkCheckId(closingId);
} else {
closeEntry(id, false);
}
};
/** Called after the close-guard dialog is confirmed */
const handleGuardConfirm = (justification: {
linkedEntryId?: string;
reason: string;
}) => {
if (!guardingId) return;
const entry = allEntries.find((e) => e.id === guardingId);
// Store the justification in notes
if (entry) {
const justNote = `\n[Închis cu termene active] ${justification.reason}`;
updateEntry(guardingId, {
notes: (entry.notes ?? "") + justNote,
});
}
// Now check for linked entries
if (entry && (entry.linkedEntryIds ?? []).length > 0) {
setClosingId(guardingId);
} else {
closeEntry(guardingId, false);
}
setGuardingId(null);
};
const handleCloseConfirm = (closeLinked: boolean) => {
if (closingId) {
closeEntry(closingId, closeLinked);
setClosingId(null); setClosingId(null);
} else {
await closeEntry(closingId, false);
setClosingId(null);
}
};
/** Linked-entries sub-confirm */
const handleLinkCheckConfirm = (closeLinked: boolean) => {
if (linkCheckId) {
closeEntry(linkCheckId, closeLinked);
setLinkCheckId(null);
} }
}; };
@@ -241,16 +219,16 @@ export function RegistraturaModule() {
const closingEntry = closingId const closingEntry = closingId
? allEntries.find((e) => e.id === closingId) ? allEntries.find((e) => e.id === closingId)
: null; : null;
const closingActiveDeadlines = closingEntry
const guardingEntry = guardingId ? (closingEntry.trackedDeadlines ?? []).filter(
? allEntries.find((e) => e.id === guardingId)
: null;
const guardingActiveDeadlines = guardingEntry
? (guardingEntry.trackedDeadlines ?? []).filter(
(d) => d.resolution === "pending", (d) => d.resolution === "pending",
) )
: []; : [];
const linkCheckEntry = linkCheckId
? allEntries.find((e) => e.id === linkCheckId)
: null;
return ( return (
<Tabs defaultValue="registru"> <Tabs defaultValue="registru">
<TabsList> <TabsList>
@@ -348,54 +326,54 @@ export function RegistraturaModule() {
</Card> </Card>
)} )}
{/* Close confirmation dialog */} {/* Universal close dialog — reason, attachment, linked entry */}
{closingEntry && (
<CloseGuardDialog
open={closingId !== null}
onOpenChange={(open) => {
if (!open) setClosingId(null);
}}
entry={closingEntry}
allEntries={allEntries}
activeDeadlines={closingActiveDeadlines}
onConfirmClose={handleCloseConfirm}
/>
)}
{/* Secondary: linked-entries sub-dialog */}
<Dialog <Dialog
open={closingId !== null} open={linkCheckId !== null}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) setClosingId(null); if (!open) setLinkCheckId(null);
}} }}
> >
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Închide înregistrarea</DialogTitle> <DialogTitle>Înregistrări legate</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="py-2"> <div className="py-2">
<p className="text-sm"> <p className="text-sm">
Această înregistrare are{" "} Această înregistrare are{" "}
{closingEntry?.linkedEntryIds?.length ?? 0} înregistrări {linkCheckEntry?.linkedEntryIds?.length ?? 0} înregistrări
legate. Vrei le închizi și pe acestea? legate. Vrei le închizi și pe acestea?
</p> </p>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setClosingId(null)}> <Button variant="outline" onClick={() => setLinkCheckId(null)}>
Anulează Anulează
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
onClick={() => handleCloseConfirm(false)} onClick={() => handleLinkCheckConfirm(false)}
> >
Doar aceasta Doar aceasta
</Button> </Button>
<Button onClick={() => handleCloseConfirm(true)}> <Button onClick={() => handleLinkCheckConfirm(true)}>
Închide toate legate Închide toate legate
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Close guard for entries with active legal deadlines */}
{guardingEntry && (
<CloseGuardDialog
open={guardingId !== null}
onOpenChange={(open) => {
if (!open) setGuardingId(null);
}}
entry={guardingEntry}
allEntries={allEntries}
activeDeadlines={guardingActiveDeadlines}
onConfirmClose={handleGuardConfirm}
/>
)}
</div> </div>
</TabsContent> </TabsContent>
@@ -48,6 +48,7 @@ import { DeadlineAddDialog } from "./deadline-add-dialog";
import { DeadlineResolveDialog } from "./deadline-resolve-dialog"; import { DeadlineResolveDialog } from "./deadline-resolve-dialog";
import { QuickContactDialog } from "./quick-contact-dialog"; import { QuickContactDialog } from "./quick-contact-dialog";
import { ThreadView } from "./thread-view"; import { ThreadView } from "./thread-view";
import { ClosureBanner } from "./closure-banner";
import { import {
createTrackedDeadline, createTrackedDeadline,
resolveDeadline as resolveDeadlineFn, resolveDeadline as resolveDeadlineFn,
@@ -394,6 +395,15 @@ export function RegistryEntryForm({
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{/* Closure info banner (if editing a closed entry) */}
{initial?.closureInfo && (
<ClosureBanner
closureInfo={initial.closureInfo}
allEntries={allEntries}
onNavigateLinked={onNavigateEntry}
/>
)}
{/* Thread view (if editing an entry that's in a thread) */} {/* Thread view (if editing an entry that's in a thread) */}
{initial && {initial &&
allEntries && allEntries &&
+1
View File
@@ -4,6 +4,7 @@ export type {
RegistryEntry, RegistryEntry,
RegistryDirection, RegistryDirection,
RegistryStatus, RegistryStatus,
ClosureInfo,
DocumentType, DocumentType,
DeadlineDayType, DeadlineDayType,
DeadlineResolution, DeadlineResolution,
+20
View File
@@ -40,6 +40,24 @@ export const DEFAULT_DOC_TYPE_LABELS: Record<string, string> = {
/** Status — simplified to open/closed */ /** Status — simplified to open/closed */
export type RegistryStatus = "deschis" | "inchis"; export type RegistryStatus = "deschis" | "inchis";
/** Structured closure information — recorded every time an entry is closed */
export interface ClosureInfo {
/** Why the entry was closed */
reason: string;
/** Who closed it (name / SSO identity when available) */
closedBy: string;
/** ISO timestamp of closure */
closedAt: string;
/** If the entry continues in another entry, its ID */
linkedEntryId?: string;
/** Linked entry number for display (e.g. "B-0042/2026") */
linkedEntryNumber?: string;
/** Whether the entry had unresolved legal deadlines when closed */
hadActiveDeadlines: boolean;
/** Optional document justifying the closure */
attachment?: RegistryAttachment;
}
/** File attachment */ /** File attachment */
export interface RegistryAttachment { export interface RegistryAttachment {
id: string; id: string;
@@ -116,6 +134,8 @@ export interface RegistryEntry {
recipientContactId?: string; recipientContactId?: string;
company: CompanyId; company: CompanyId;
status: RegistryStatus; status: RegistryStatus;
/** Structured closure metadata (populated when status = 'inchis') */
closureInfo?: ClosureInfo;
/** Deadline date (YYYY-MM-DD) */ /** Deadline date (YYYY-MM-DD) */
deadline?: string; deadline?: string;
/** Assignee — person responsible (ERP-ready field) */ /** Assignee — person responsible (ERP-ready field) */