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";
|
"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 să le închizi și pe acestea?
|
legate. Vrei să 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 &&
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type {
|
|||||||
RegistryEntry,
|
RegistryEntry,
|
||||||
RegistryDirection,
|
RegistryDirection,
|
||||||
RegistryStatus,
|
RegistryStatus,
|
||||||
|
ClosureInfo,
|
||||||
DocumentType,
|
DocumentType,
|
||||||
DeadlineDayType,
|
DeadlineDayType,
|
||||||
DeadlineResolution,
|
DeadlineResolution,
|
||||||
|
|||||||
@@ -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) */
|
||||||
|
|||||||
Reference in New Issue
Block a user