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:
@@ -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 să 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 &&
|
||||
|
||||
@@ -4,6 +4,7 @@ export type {
|
||||
RegistryEntry,
|
||||
RegistryDirection,
|
||||
RegistryStatus,
|
||||
ClosureInfo,
|
||||
DocumentType,
|
||||
DeadlineDayType,
|
||||
DeadlineResolution,
|
||||
|
||||
@@ -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) */
|
||||
|
||||
Reference in New Issue
Block a user