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";
import { useState, useMemo } from "react";
import { AlertTriangle, Search, Link2 } from "lucide-react";
import { useState, useMemo, useRef } from "react";
import {
AlertTriangle,
Search,
Link2,
Paperclip,
X,
FileText,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
@@ -14,8 +21,14 @@ import {
DialogTitle,
DialogFooter,
} 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 { v4 as uuid } from "uuid";
interface CloseGuardDialogProps {
open: boolean;
@@ -24,19 +37,18 @@ interface CloseGuardDialogProps {
entry: RegistryEntry;
/** All entries for search/linking */
allEntries: RegistryEntry[];
/** Active deadlines on this entry */
/** Active (pending) deadlines — empty if none */
activeDeadlines: TrackedDeadline[];
/** Called when user provides justification and confirms close */
onConfirmClose: (justification: {
linkedEntryId?: string;
reason: string;
}) => void;
/** Called when user confirms close with all structured data */
onConfirmClose: (info: ClosureInfo) => void;
}
/**
* Guard dialog shown when a user tries to close an entry that has
* unresolved (active) legal deadlines. Requires justification:
* either link to a continuation entry or provide manual reason text.
* Universal close dialog: always shown when closing any entry.
* - If active deadlines exist → shows a warning banner
* - Requires a reason (free text)
* - Optionally link a continuation entry
* - Optionally attach a closing document (e.g. scanned letter)
*/
export function CloseGuardDialog({
open,
@@ -46,23 +58,26 @@ export function CloseGuardDialog({
activeDeadlines,
onConfirmClose,
}: CloseGuardDialogProps) {
const [mode, setMode] = useState<"link" | "manual">("link");
const [search, setSearch] = useState("");
const [selectedEntryId, setSelectedEntryId] = 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
const handleOpenChange = (o: boolean) => {
if (o) {
setMode("link");
setSearch("");
setSelectedEntryId("");
setReason("");
setAttachment(null);
}
onOpenChange(o);
};
// Searchable entries (exclude self and closed entries)
// Searchable entries (exclude self)
const searchResults = useMemo(() => {
if (!search || search.length < 2) return [];
const q = search.toLowerCase();
@@ -70,7 +85,6 @@ export function CloseGuardDialog({
.filter(
(e) =>
e.id !== entry.id &&
e.status !== "inchis" &&
(e.subject.toLowerCase().includes(q) ||
e.number.toLowerCase().includes(q) ||
e.sender.toLowerCase().includes(q) ||
@@ -81,17 +95,36 @@ export function CloseGuardDialog({
const selectedEntry = allEntries.find((e) => e.id === selectedEntryId);
const canSubmit =
mode === "link" ? selectedEntryId !== "" : reason.trim().length >= 5;
const canSubmit = reason.trim().length >= 3;
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 = () => {
if (!canSubmit) return;
onConfirmClose({
linkedEntryId: mode === "link" ? selectedEntryId : undefined,
reason:
mode === "link"
? `Continuat în #${selectedEntry?.number ?? selectedEntryId}`
: reason.trim(),
reason: reason.trim(),
closedBy: "Utilizator", // TODO: replace with SSO identity
closedAt: new Date().toISOString(),
linkedEntryId: selectedEntryId || undefined,
linkedEntryNumber: selectedEntry?.number,
hadActiveDeadlines: hasDeadlines,
attachment: attachment ?? undefined,
});
};
@@ -99,127 +132,170 @@ export function CloseGuardDialog({
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<AlertTriangle className="h-5 w-5" />
Termene legale active
<DialogTitle className="flex items-center gap-2">
{hasDeadlines ? (
<>
<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>
</DialogHeader>
<div className="space-y-4">
{/* Warning message */}
<p className="text-sm text-muted-foreground">
Înregistrarea <strong>{entry.number}</strong> are{" "}
<strong>{activeDeadlines.length}</strong> termen
{activeDeadlines.length > 1 ? "e" : ""} legal
{activeDeadlines.length > 1 ? "e" : ""} nerezolvat
{activeDeadlines.length > 1 ? "e" : ""}. Pentru a o închide,
justifică decizia.
</p>
{/* Active deadlines list */}
<div className="rounded border p-2 space-y-1.5">
{activeDeadlines.map((dl) => {
const def = getDeadlineType(dl.typeId);
const isOverdue = new Date(dl.dueDate) < new Date();
return (
<div key={dl.id} className="flex items-center gap-2 text-sm">
<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)}
{/* Active deadlines warning */}
{hasDeadlines && (
<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">
<p className="text-sm text-amber-800 dark:text-amber-200">
<strong>{activeDeadlines.length}</strong> termen
{activeDeadlines.length > 1 ? "e" : ""} legal
{activeDeadlines.length > 1 ? "e" : ""} nerezolvat
{activeDeadlines.length > 1 ? "e" : ""}:
</p>
{activeDeadlines.map((dl) => {
const def = getDeadlineType(dl.typeId);
const isOverdue = new Date(dl.dueDate) < new Date();
return (
<div key={dl.id} className="flex items-center gap-2 text-sm">
<Badge
variant={isOverdue ? "destructive" : "outline"}
className="text-[10px] shrink-0"
>
<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 && (
<p className="text-xs text-muted-foreground">
Selectat: <strong>{selectedEntry.number}</strong> {" "}
{selectedEntry.subject}
</p>
)}
{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>
)}
{/* Manual mode */}
{mode === "manual" && (
<div className="space-y-2">
<Label>Motiv închidere (min. 5 caractere)</Label>
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Ex: Dosarul a fost retras de beneficiar..."
rows={3}
{/* Reason — always required */}
<div>
<Label>Motiv închidere *</Label>
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Ex: Dosarul a fost finalizat, retras de beneficiar, preluat într-o altă cerere..."
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>
)}
{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>
<DialogFooter>
@@ -234,7 +310,7 @@ export function CloseGuardDialog({
type="button"
onClick={handleSubmit}
disabled={!canSubmit}
variant="destructive"
variant={hasDeadlines ? "destructive" : "default"}
>
Închide înregistrarea
</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 {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/shared/components/ui/dialog';
import { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input';
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';
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/components/ui/dialog";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
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 {
open: boolean;
@@ -19,14 +26,29 @@ interface DeadlineAddDialogProps {
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) {
const [step, setStep] = useState<Step>('category');
const [selectedCategory, setSelectedCategory] = useState<DeadlineCategory | null>(null);
const [selectedType, setSelectedType] = useState<DeadlineTypeDef | null>(null);
export function DeadlineAddDialog({
open,
onOpenChange,
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 typesForCategory = useMemo(() => {
@@ -38,12 +60,21 @@ export function DeadlineAddDialog({ open, onOpenChange, entryDate, onAdd }: Dead
if (!selectedType || !startDate) return null;
const start = new Date(startDate);
start.setHours(0, 0, 0, 0);
const due = computeDueDate(start, selectedType.days, selectedType.dayType, selectedType.isBackwardDeadline);
return due.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
const due = computeDueDate(
start,
selectedType.days,
selectedType.dayType,
selectedType.isBackwardDeadline,
);
return due.toLocaleDateString("ro-RO", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}, [selectedType, startDate]);
const handleClose = () => {
setStep('category');
setStep("category");
setSelectedCategory(null);
setSelectedType(null);
setStartDate(entryDate);
@@ -52,7 +83,7 @@ export function DeadlineAddDialog({ open, onOpenChange, entryDate, onAdd }: Dead
const handleCategorySelect = (cat: DeadlineCategory) => {
setSelectedCategory(cat);
setStep('type');
setStep("type");
};
const handleTypeSelect = (typ: DeadlineTypeDef) => {
@@ -60,15 +91,15 @@ export function DeadlineAddDialog({ open, onOpenChange, entryDate, onAdd }: Dead
if (!typ.requiresCustomStartDate) {
setStartDate(entryDate);
}
setStep('date');
setStep("date");
};
const handleBack = () => {
if (step === 'type') {
setStep('category');
if (step === "type") {
setStep("category");
setSelectedCategory(null);
} else if (step === 'date') {
setStep('type');
} else if (step === "date") {
setStep("type");
setSelectedType(null);
}
};
@@ -80,17 +111,24 @@ export function DeadlineAddDialog({ open, onOpenChange, entryDate, onAdd }: Dead
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) handleClose(); }}>
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) handleClose();
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{step === 'category' && 'Adaugă termen legal — Categorie'}
{step === 'type' && `Adaugă termen legal — ${selectedCategory ? CATEGORY_LABELS[selectedCategory] : ''}`}
{step === 'date' && `Adaugă termen legal — ${selectedType?.label ?? ''}`}
{step === "category" && "Adaugă termen legal — Categorie"}
{step === "type" &&
`Adaugă termen legal — ${selectedCategory ? CATEGORY_LABELS[selectedCategory] : ""}`}
{step === "date" &&
`Adaugă termen legal — ${selectedType?.label ?? ""}`}
</DialogTitle>
</DialogHeader>
{step === 'category' && (
{step === "category" && (
<div className="grid gap-2 py-2">
{CATEGORIES.map((cat) => (
<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"
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">
{DEADLINE_CATALOG.filter((d) => d.category === cat).length}
</Badge>
@@ -108,7 +148,7 @@ export function DeadlineAddDialog({ open, onOpenChange, entryDate, onAdd }: Dead
</div>
)}
{step === 'type' && (
{step === "type" && (
<div className="grid gap-2 py-2">
{typesForCategory.map((typ) => (
<button
@@ -120,27 +160,42 @@ export function DeadlineAddDialog({ open, onOpenChange, entryDate, onAdd }: Dead
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{typ.label}</span>
<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>
{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 && (
<Badge variant="outline" className="text-[10px] text-orange-600">înapoi</Badge>
<Badge
variant="outline"
className="text-[10px] text-orange-600"
>
înapoi
</Badge>
)}
</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>
))}
</div>
)}
{step === 'date' && selectedType && (
{step === "date" && selectedType && (
<div className="space-y-4 py-2">
<div>
<Label>{selectedType.startDateLabel}</Label>
{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
type="date"
@@ -153,15 +208,24 @@ export function DeadlineAddDialog({ open, onOpenChange, entryDate, onAdd }: Dead
{dueDatePreview && (
<div className="rounded-lg border bg-muted/30 p-3">
<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 className="text-lg font-bold">{dueDatePreview}</p>
<p className="text-xs text-muted-foreground mt-1">
{selectedType.days} {selectedType.dayType === 'working' ? 'zile lucrătoare' : 'zile calendaristice'}
{selectedType.isBackwardDeadline ? ' ÎNAINTE' : ' de la data start'}
{selectedType.days}{" "}
{selectedType.dayType === "working"
? "zile lucrătoare"
: "zile calendaristice"}
{selectedType.isBackwardDeadline
? " ÎNAINTE"
: " de la data start"}
</p>
{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>
)}
@@ -169,13 +233,17 @@ export function DeadlineAddDialog({ open, onOpenChange, entryDate, onAdd }: Dead
)}
<DialogFooter className="gap-2 sm:gap-0">
{step !== 'category' && (
<Button type="button" variant="outline" onClick={handleBack}>Înapoi</Button>
{step !== "category" && (
<Button type="button" variant="outline" onClick={handleBack}>
Înapoi
</Button>
)}
{step === 'category' && (
<Button type="button" variant="outline" onClick={handleClose}>Anulează</Button>
{step === "category" && (
<Button type="button" variant="outline" onClick={handleClose}>
Anulează
</Button>
)}
{step === 'date' && (
{step === "date" && (
<Button type="button" onClick={handleConfirm} disabled={!startDate}>
Adaugă termen
</Button>
@@ -33,7 +33,7 @@ import { DeadlineDashboard } from "./deadline-dashboard";
import { CloseGuardDialog } from "./close-guard-dialog";
import { getOverdueDays } from "../services/registry-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";
type ViewMode = "list" | "add" | "edit";
@@ -60,7 +60,7 @@ export function RegistraturaModule() {
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [editingEntry, setEditingEntry] = useState<RegistryEntry | 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 ──
const handleCreateContact = useCallback(
@@ -143,57 +143,35 @@ export function RegistraturaModule() {
await removeEntry(id);
};
/** All closes go through the close dialog */
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;
// Check for active (unresolved) legal deadlines
const activeDeadlines = (entry.trackedDeadlines ?? []).filter(
(d) => d.resolution === "pending",
);
if (activeDeadlines.length > 0) {
// Show guard dialog first
setGuardingId(id);
return;
}
// Store structured closure info on the entry
await updateEntry(closingId, { closureInfo: info });
// No active deadlines — proceed to linked-entries check
// Check for linked entries
if ((entry.linkedEntryIds ?? []).length > 0) {
setClosingId(id);
} 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);
setLinkCheckId(closingId);
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
? allEntries.find((e) => e.id === closingId)
: null;
const guardingEntry = guardingId
? allEntries.find((e) => e.id === guardingId)
: null;
const guardingActiveDeadlines = guardingEntry
? (guardingEntry.trackedDeadlines ?? []).filter(
const closingActiveDeadlines = closingEntry
? (closingEntry.trackedDeadlines ?? []).filter(
(d) => d.resolution === "pending",
)
: [];
const linkCheckEntry = linkCheckId
? allEntries.find((e) => e.id === linkCheckId)
: null;
return (
<Tabs defaultValue="registru">
<TabsList>
@@ -348,54 +326,54 @@ export function RegistraturaModule() {
</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
open={closingId !== null}
open={linkCheckId !== null}
onOpenChange={(open) => {
if (!open) setClosingId(null);
if (!open) setLinkCheckId(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Închide înregistrarea</DialogTitle>
<DialogTitle>Înregistrări legate</DialogTitle>
</DialogHeader>
<div className="py-2">
<p className="text-sm">
Această înregistrare are{" "}
{closingEntry?.linkedEntryIds?.length ?? 0} înregistrări
{linkCheckEntry?.linkedEntryIds?.length ?? 0} înregistrări
legate. Vrei le închizi și pe acestea?
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setClosingId(null)}>
<Button variant="outline" onClick={() => setLinkCheckId(null)}>
Anulează
</Button>
<Button
variant="secondary"
onClick={() => handleCloseConfirm(false)}
onClick={() => handleLinkCheckConfirm(false)}
>
Doar aceasta
</Button>
<Button onClick={() => handleCloseConfirm(true)}>
<Button onClick={() => handleLinkCheckConfirm(true)}>
Închide toate legate
</Button>
</DialogFooter>
</DialogContent>
</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>
</TabsContent>
@@ -48,6 +48,7 @@ import { DeadlineAddDialog } from "./deadline-add-dialog";
import { DeadlineResolveDialog } from "./deadline-resolve-dialog";
import { QuickContactDialog } from "./quick-contact-dialog";
import { ThreadView } from "./thread-view";
import { ClosureBanner } from "./closure-banner";
import {
createTrackedDeadline,
resolveDeadline as resolveDeadlineFn,
@@ -394,6 +395,15 @@ export function RegistryEntryForm({
return (
<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) */}
{initial &&
allEntries &&
+1
View File
@@ -4,6 +4,7 @@ export type {
RegistryEntry,
RegistryDirection,
RegistryStatus,
ClosureInfo,
DocumentType,
DeadlineDayType,
DeadlineResolution,
+20
View File
@@ -40,6 +40,24 @@ export const DEFAULT_DOC_TYPE_LABELS: Record<string, string> = {
/** Status — simplified to open/closed */
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 */
export interface RegistryAttachment {
id: string;
@@ -116,6 +134,8 @@ export interface RegistryEntry {
recipientContactId?: string;
company: CompanyId;
status: RegistryStatus;
/** Structured closure metadata (populated when status = 'inchis') */
closureInfo?: ClosureInfo;
/** Deadline date (YYYY-MM-DD) */
deadline?: string;
/** Assignee — person responsible (ERP-ready field) */