fix(registratura): 5 post-3.02 fixes - QuickContact pre-fill (useEffect sync), form close on contact create (stopPropagation), Switch label 'Inchis' -> 'Status', CU moved from Avize to own category, close guard for active deadlines

This commit is contained in:
AI Assistant
2026-02-27 16:02:10 +02:00
parent 2be0462e0d
commit 80e41d4842
8 changed files with 470 additions and 131 deletions
@@ -0,0 +1,252 @@
"use client";
import { useState, useMemo } from "react";
import { AlertTriangle, Search, Link2 } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Textarea } from "@/shared/components/ui/textarea";
import { Badge } from "@/shared/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/components/ui/dialog";
import type { RegistryEntry, TrackedDeadline } from "../types";
import { getDeadlineType } from "../services/deadline-catalog";
interface CloseGuardDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The entry being closed */
entry: RegistryEntry;
/** All entries for search/linking */
allEntries: RegistryEntry[];
/** Active deadlines on this entry */
activeDeadlines: TrackedDeadline[];
/** Called when user provides justification and confirms close */
onConfirmClose: (justification: {
linkedEntryId?: string;
reason: string;
}) => 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.
*/
export function CloseGuardDialog({
open,
onOpenChange,
entry,
allEntries,
activeDeadlines,
onConfirmClose,
}: CloseGuardDialogProps) {
const [mode, setMode] = useState<"link" | "manual">("link");
const [search, setSearch] = useState("");
const [selectedEntryId, setSelectedEntryId] = useState("");
const [reason, setReason] = useState("");
// Reset on open
const handleOpenChange = (o: boolean) => {
if (o) {
setMode("link");
setSearch("");
setSelectedEntryId("");
setReason("");
}
onOpenChange(o);
};
// Searchable entries (exclude self and closed entries)
const searchResults = useMemo(() => {
if (!search || search.length < 2) return [];
const q = search.toLowerCase();
return allEntries
.filter(
(e) =>
e.id !== entry.id &&
e.status !== "inchis" &&
(e.subject.toLowerCase().includes(q) ||
e.number.toLowerCase().includes(q) ||
e.sender.toLowerCase().includes(q) ||
e.recipient.toLowerCase().includes(q)),
)
.slice(0, 8);
}, [search, allEntries, entry.id]);
const selectedEntry = allEntries.find((e) => e.id === selectedEntryId);
const canSubmit =
mode === "link"
? selectedEntryId !== ""
: reason.trim().length >= 5;
const handleSubmit = () => {
if (!canSubmit) return;
onConfirmClose({
linkedEntryId: mode === "link" ? selectedEntryId : undefined,
reason:
mode === "link"
? `Continuat în #${selectedEntry?.number ?? selectedEntryId}`
: reason.trim(),
});
};
return (
<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>
</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)}
>
<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>
)}
</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}
/>
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Anulează
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={!canSubmit}
variant="destructive"
>
Închide înregistrarea
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { UserPlus } from "lucide-react";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
@@ -35,24 +35,28 @@ export function QuickContactDialog({
const [phone, setPhone] = useState("");
const [email, setEmail] = useState("");
// Reset when dialog opens with new name
const handleOpenChange = (o: boolean) => {
if (o) {
// Sync name with initialName whenever the dialog opens
// (useState(initialName) only works on first mount; controlled open
// bypasses onOpenChange so we need useEffect)
useEffect(() => {
if (open) {
setName(initialName);
setPhone("");
setEmail("");
}
onOpenChange(o);
};
}, [open, initialName]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Stop propagation so the submit doesn't bubble through the React portal
// to the outer registry-entry-form, which would close the whole form.
e.stopPropagation();
if (!name.trim()) return;
onConfirm({ name: name.trim(), phone: phone.trim(), email: email.trim() });
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
@@ -30,6 +30,7 @@ import { RegistryFilters } from "./registry-filters";
import { RegistryTable } from "./registry-table";
import { RegistryEntryForm } from "./registry-entry-form";
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";
@@ -59,6 +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);
// ── Bidirectional Address Book integration ──
const handleCreateContact = useCallback(
@@ -143,13 +145,51 @@ export function RegistraturaModule() {
const handleCloseRequest = (id: string) => {
const entry = allEntries.find((e) => e.id === id);
if (entry && (entry.linkedEntryIds ?? []).length > 0) {
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;
}
// No active deadlines — proceed to linked-entries check
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);
@@ -202,6 +242,15 @@ export function RegistraturaModule() {
? allEntries.find((e) => e.id === closingId)
: null;
const guardingEntry = guardingId
? allEntries.find((e) => e.id === guardingId)
: null;
const guardingActiveDeadlines = guardingEntry
? (guardingEntry.trackedDeadlines ?? []).filter(
(d) => d.resolution === "pending",
)
: [];
return (
<Tabs defaultValue="registru">
<TabsList>
@@ -333,6 +382,20 @@ export function RegistraturaModule() {
</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>
@@ -609,7 +609,7 @@ export function RegistryEntryForm({
</div>
<div>
<Label className="flex items-center gap-1.5">
Închis
Status
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>