fix(registratura): prevent duplicate numbers, add upload progress, submission lock, unified close/resolve, backdating support

- generateRegistryNumber: parse max existing number instead of counting entries
- addEntry: fetch fresh entries before generating number (race condition fix)
- Form: isSubmitting lock prevents double-click submission
- Form: uploadingCount tracks FileReader progress, blocks submit while uploading
- Form: submit button shows Loader2 spinner during save/upload
- CloseGuardDialog: added ClosureResolution selector (finalizat/aprobat-tacit/respins/retras/altele)
- ClosureBanner: displays resolution badge
- Types: ClosureResolution type, registrationDate field on RegistryEntry
- Date field renamed 'Data document' with tooltip explaining backdating
- Registry table shows '(înr. DATE)' when registrationDate differs from document date
This commit is contained in:
AI Assistant
2026-02-27 21:56:47 +02:00
parent db6662be39
commit 8042df481f
7 changed files with 403 additions and 185 deletions
@@ -14,6 +14,13 @@ import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label"; import { Label } from "@/shared/components/ui/label";
import { Textarea } from "@/shared/components/ui/textarea"; import { Textarea } from "@/shared/components/ui/textarea";
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -26,6 +33,7 @@ import type {
TrackedDeadline, TrackedDeadline,
RegistryAttachment, RegistryAttachment,
ClosureInfo, ClosureInfo,
ClosureResolution,
} from "../types"; } from "../types";
import { getDeadlineType } from "../services/deadline-catalog"; import { getDeadlineType } from "../services/deadline-catalog";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
@@ -60,6 +68,7 @@ export function CloseGuardDialog({
}: CloseGuardDialogProps) { }: CloseGuardDialogProps) {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [selectedEntryId, setSelectedEntryId] = useState(""); const [selectedEntryId, setSelectedEntryId] = useState("");
const [resolution, setResolution] = useState<ClosureResolution>("finalizat");
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [attachment, setAttachment] = useState<RegistryAttachment | null>(null); const [attachment, setAttachment] = useState<RegistryAttachment | null>(null);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
@@ -71,6 +80,7 @@ export function CloseGuardDialog({
if (o) { if (o) {
setSearch(""); setSearch("");
setSelectedEntryId(""); setSelectedEntryId("");
setResolution("finalizat");
setReason(""); setReason("");
setAttachment(null); setAttachment(null);
} }
@@ -118,6 +128,7 @@ export function CloseGuardDialog({
const handleSubmit = () => { const handleSubmit = () => {
if (!canSubmit) return; if (!canSubmit) return;
onConfirmClose({ onConfirmClose({
resolution,
reason: reason.trim(), reason: reason.trim(),
closedBy: "Utilizator", // TODO: replace with SSO identity closedBy: "Utilizator", // TODO: replace with SSO identity
closedAt: new Date().toISOString(), closedAt: new Date().toISOString(),
@@ -179,6 +190,26 @@ export function CloseGuardDialog({
</div> </div>
)} )}
{/* Resolution type — same concept as deadline resolution */}
<div>
<Label>Rezoluție</Label>
<Select
value={resolution}
onValueChange={(v) => setResolution(v as ClosureResolution)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="finalizat">Finalizat</SelectItem>
<SelectItem value="aprobat-tacit">Aprobat tacit</SelectItem>
<SelectItem value="respins">Respins / Negativ</SelectItem>
<SelectItem value="retras">Retras de beneficiar</SelectItem>
<SelectItem value="altele">Altele</SelectItem>
</SelectContent>
</Select>
</div>
{/* Reason — always required */} {/* Reason — always required */}
<div> <div>
<Label>Motiv închidere *</Label> <Label>Motiv închidere *</Label>
@@ -21,6 +21,14 @@ interface ClosureBannerProps {
allEntries?: RegistryEntry[]; allEntries?: RegistryEntry[];
} }
const RESOLUTION_LABELS: Record<string, string> = {
finalizat: "Finalizat",
"aprobat-tacit": "Aprobat tacit",
respins: "Respins / Negativ",
retras: "Retras",
altele: "Altele",
};
/** /**
* Read-only banner displayed at the top of a closed entry, * Read-only banner displayed at the top of a closed entry,
* showing who closed it, when, why, linked entry, and attachment. * showing who closed it, when, why, linked entry, and attachment.
@@ -53,6 +61,12 @@ export function ClosureBanner({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Lock className="h-4 w-4 text-muted-foreground" /> <Lock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold">Înregistrare închisă</span> <span className="text-sm font-semibold">Înregistrare închisă</span>
{closureInfo.resolution && (
<Badge variant="secondary" className="text-[10px]">
{RESOLUTION_LABELS[closureInfo.resolution] ??
closureInfo.resolution}
</Badge>
)}
{closureInfo.hadActiveDeadlines && ( {closureInfo.hadActiveDeadlines && (
<Badge <Badge
variant="outline" variant="outline"
@@ -9,6 +9,7 @@ import {
UserPlus, UserPlus,
Info, Info,
GitBranch, GitBranch,
Loader2,
} from "lucide-react"; } from "lucide-react";
import type { CompanyId } from "@/core/auth/types"; import type { CompanyId } from "@/core/auth/types";
import type { import type {
@@ -60,7 +61,7 @@ interface RegistryEntryFormProps {
allEntries?: RegistryEntry[]; allEntries?: RegistryEntry[];
onSubmit: ( onSubmit: (
data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">, data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
) => void; ) => void | Promise<void>;
onCancel: () => void; onCancel: () => void;
/** Callback to create a new Address Book contact */ /** Callback to create a new Address Book contact */
onCreateContact?: (data: { onCreateContact?: (data: {
@@ -148,6 +149,11 @@ export function RegistryEntryForm({
const [linkedSearch, setLinkedSearch] = useState(""); const [linkedSearch, setLinkedSearch] = useState("");
const [threadSearch, setThreadSearch] = useState(""); const [threadSearch, setThreadSearch] = useState("");
// ── Submission lock + file upload tracking ──
const [isSubmitting, setIsSubmitting] = useState(false);
const [uploadingCount, setUploadingCount] = useState(0);
const isUploading = uploadingCount > 0;
// ── Deadline dialogs ── // ── Deadline dialogs ──
const [deadlineAddOpen, setDeadlineAddOpen] = useState(false); const [deadlineAddOpen, setDeadlineAddOpen] = useState(false);
const [resolvingDeadline, setResolvingDeadline] = const [resolvingDeadline, setResolvingDeadline] =
@@ -283,7 +289,9 @@ export function RegistryEntryForm({
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files; const files = e.target.files;
if (!files) return; if (!files) return;
for (const file of Array.from(files)) { const fileArr = Array.from(files);
setUploadingCount((prev) => prev + fileArr.length);
for (const file of fileArr) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
const base64 = reader.result as string; const base64 = reader.result as string;
@@ -298,6 +306,10 @@ export function RegistryEntryForm({
addedAt: new Date().toISOString(), addedAt: new Date().toISOString(),
}, },
]); ]);
setUploadingCount((prev) => Math.max(0, prev - 1));
};
reader.onerror = () => {
setUploadingCount((prev) => Math.max(0, prev - 1));
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
@@ -308,31 +320,37 @@ export function RegistryEntryForm({
setAttachments((prev) => prev.filter((a) => a.id !== id)); setAttachments((prev) => prev.filter((a) => a.id !== id));
}; };
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
onSubmit({ if (isSubmitting || isUploading) return;
direction, setIsSubmitting(true);
documentType, try {
subject, await onSubmit({
date, direction,
sender, documentType,
senderContactId: senderContactId || undefined, subject,
recipient, date,
recipientContactId: recipientContactId || undefined, sender,
company, senderContactId: senderContactId || undefined,
status: isClosed ? "inchis" : "deschis", recipient,
deadline: deadline || undefined, recipientContactId: recipientContactId || undefined,
assignee: assignee || undefined, company,
assigneeContactId: assigneeContactId || undefined, status: isClosed ? "inchis" : "deschis",
threadParentId: threadParentId || undefined, deadline: deadline || undefined,
linkedEntryIds, assignee: assignee || undefined,
attachments, assigneeContactId: assigneeContactId || undefined,
trackedDeadlines: threadParentId: threadParentId || undefined,
trackedDeadlines.length > 0 ? trackedDeadlines : undefined, linkedEntryIds,
notes, attachments,
tags: initial?.tags ?? [], trackedDeadlines:
visibility: initial?.visibility ?? "all", trackedDeadlines.length > 0 ? trackedDeadlines : undefined,
}); notes,
tags: initial?.tags ?? [],
visibility: initial?.visibility ?? "all",
});
} finally {
setIsSubmitting(false);
}
}; };
// ── Contact autocomplete dropdown renderer ── // ── Contact autocomplete dropdown renderer ──
@@ -479,13 +497,34 @@ export function RegistryEntryForm({
</div> </div>
</div> </div>
<div> <div>
<Label>Data</Label> <Label className="flex items-center gap-1.5">
Data document
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p className="text-xs">
Data reală de pe document. Poate fi în trecut dacă
înregistrezi retroactiv.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<Input <Input
type="date" type="date"
value={date} value={date}
onChange={(e) => setDate(e.target.value)} onChange={(e) => setDate(e.target.value)}
className="mt-1" className="mt-1"
/> />
{!initial && date !== new Date().toISOString().slice(0, 10) && (
<p className="text-[10px] text-amber-600 dark:text-amber-400 mt-0.5">
Data diferă de azi înregistrarea retroactivă va primi următorul
nr. secvențial.
</p>
)}
</div> </div>
</div> </div>
@@ -876,12 +915,22 @@ export function RegistryEntryForm({
{/* Attachments */} {/* Attachments */}
<div> <div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label>Atașamente</Label> <Label className="flex items-center gap-1.5">
Atașamente
{isUploading && (
<span className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 font-normal">
<Loader2 className="h-3 w-3 animate-spin" />
Se încarcă {uploadingCount} fișier
{uploadingCount > 1 ? "e" : ""}
</span>
)}
</Label>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting}
> >
<Paperclip className="mr-1 h-3.5 w-3.5" /> Adaugă fișier <Paperclip className="mr-1 h-3.5 w-3.5" /> Adaugă fișier
</Button> </Button>
@@ -931,10 +980,31 @@ export function RegistryEntryForm({
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}> <Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
Anulează Anulează
</Button> </Button>
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button> <Button type="submit" disabled={isSubmitting || isUploading}>
{isSubmitting ? (
<>
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
Se salvează
</>
) : isUploading ? (
<>
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
Se încarcă fișiere
</>
) : initial ? (
"Actualizează"
) : (
"Adaugă"
)}
</Button>
</div> </div>
{/* Quick contact creation dialog */} {/* Quick contact creation dialog */}
@@ -71,7 +71,7 @@ export function RegistryTable({
<thead> <thead>
<tr className="border-b bg-muted/40"> <tr className="border-b bg-muted/40">
<th className="px-3 py-2 text-left font-medium">Nr.</th> <th className="px-3 py-2 text-left font-medium">Nr.</th>
<th className="px-3 py-2 text-left font-medium">Data</th> <th className="px-3 py-2 text-left font-medium">Data doc.</th>
<th className="px-3 py-2 text-left font-medium">Dir.</th> <th className="px-3 py-2 text-left font-medium">Dir.</th>
<th className="px-3 py-2 text-left font-medium">Tip</th> <th className="px-3 py-2 text-left font-medium">Tip</th>
<th className="px-3 py-2 text-left font-medium">Subiect</th> <th className="px-3 py-2 text-left font-medium">Subiect</th>
@@ -103,6 +103,15 @@ export function RegistryTable({
</td> </td>
<td className="px-3 py-2 text-xs whitespace-nowrap"> <td className="px-3 py-2 text-xs whitespace-nowrap">
{formatDate(entry.date)} {formatDate(entry.date)}
{entry.registrationDate &&
entry.registrationDate !== entry.date && (
<span
className="block text-[10px] text-muted-foreground"
title={`Înregistrat pe ${formatDate(entry.registrationDate)}`}
>
(înr. {formatDate(entry.registrationDate)})
</span>
)}
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<Badge <Badge
+197 -132
View File
@@ -1,31 +1,46 @@
'use client'; "use client";
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from "react";
import { useStorage } from '@/core/storage'; import { useStorage } from "@/core/storage";
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from "uuid";
import type { RegistryEntry, RegistryDirection, RegistryStatus, DocumentType, TrackedDeadline, DeadlineResolution } from '../types'; import type {
import { getAllEntries, saveEntry, deleteEntry, generateRegistryNumber } from '../services/registry-service'; RegistryEntry,
import { createTrackedDeadline, resolveDeadline as resolveDeadlineFn } from '../services/deadline-service'; RegistryDirection,
import { getDeadlineType } from '../services/deadline-catalog'; RegistryStatus,
DocumentType,
TrackedDeadline,
DeadlineResolution,
} from "../types";
import {
getAllEntries,
saveEntry,
deleteEntry,
generateRegistryNumber,
} from "../services/registry-service";
import {
createTrackedDeadline,
resolveDeadline as resolveDeadlineFn,
} from "../services/deadline-service";
import { getDeadlineType } from "../services/deadline-catalog";
export interface RegistryFilters { export interface RegistryFilters {
search: string; search: string;
direction: RegistryDirection | 'all'; direction: RegistryDirection | "all";
status: RegistryStatus | 'all'; status: RegistryStatus | "all";
documentType: DocumentType | 'all'; documentType: DocumentType | "all";
company: string; company: string;
} }
export function useRegistry() { export function useRegistry() {
const storage = useStorage('registratura'); const storage = useStorage("registratura");
const [entries, setEntries] = useState<RegistryEntry[]>([]); const [entries, setEntries] = useState<RegistryEntry[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<RegistryFilters>({ const [filters, setFilters] = useState<RegistryFilters>({
search: '', search: "",
direction: 'all', direction: "all",
status: 'all', status: "all",
documentType: 'all', documentType: "all",
company: 'all', company: "all",
}); });
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
@@ -36,139 +51,189 @@ export function useRegistry() {
}, [storage]); }, [storage]);
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]); useEffect(() => {
refresh();
}, [refresh]);
const addEntry = useCallback(async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => { const addEntry = useCallback(
const now = new Date().toISOString(); async (
const number = generateRegistryNumber(data.company, data.date, entries); data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
const entry: RegistryEntry = { ) => {
...data, // Fetch fresh entries to prevent duplicate numbers from stale state
id: uuid(), const freshEntries = await getAllEntries(storage);
number, const now = new Date().toISOString();
createdAt: now, const number = generateRegistryNumber(
updatedAt: now, data.company,
}; data.date,
await saveEntry(storage, entry); freshEntries,
await refresh(); );
return entry; const entry: RegistryEntry = {
}, [storage, refresh, entries]); ...data,
id: uuid(),
number,
registrationDate: new Date().toISOString().slice(0, 10),
createdAt: now,
updatedAt: now,
};
await saveEntry(storage, entry);
await refresh();
return entry;
},
[storage, refresh],
);
const updateEntry = useCallback(async (id: string, updates: Partial<RegistryEntry>) => { const updateEntry = useCallback(
const existing = entries.find((e) => e.id === id); async (id: string, updates: Partial<RegistryEntry>) => {
if (!existing) return; const existing = entries.find((e) => e.id === id);
const updated: RegistryEntry = { if (!existing) return;
...existing, const updated: RegistryEntry = {
...updates, ...existing,
id: existing.id, ...updates,
number: existing.number, id: existing.id,
createdAt: existing.createdAt, number: existing.number,
updatedAt: new Date().toISOString(), createdAt: existing.createdAt,
}; updatedAt: new Date().toISOString(),
await saveEntry(storage, updated); };
await refresh(); await saveEntry(storage, updated);
}, [storage, refresh, entries]); await refresh();
},
[storage, refresh, entries],
);
const removeEntry = useCallback(async (id: string) => { const removeEntry = useCallback(
await deleteEntry(storage, id); async (id: string) => {
await refresh(); await deleteEntry(storage, id);
}, [storage, refresh]); await refresh();
},
[storage, refresh],
);
/** Close an entry and optionally its linked entries */ /** Close an entry and optionally its linked entries */
const closeEntry = useCallback(async (id: string, closeLinked: boolean) => { const closeEntry = useCallback(
const entry = entries.find((e) => e.id === id); async (id: string, closeLinked: boolean) => {
if (!entry) return; const entry = entries.find((e) => e.id === id);
await updateEntry(id, { status: 'inchis' }); if (!entry) return;
const linked = entry.linkedEntryIds ?? []; await updateEntry(id, { status: "inchis" });
if (closeLinked && linked.length > 0) { const linked = entry.linkedEntryIds ?? [];
for (const linkedId of linked) { if (closeLinked && linked.length > 0) {
const linked = entries.find((e) => e.id === linkedId); for (const linkedId of linked) {
if (linked && linked.status !== 'inchis') { const linked = entries.find((e) => e.id === linkedId);
const updatedLinked: RegistryEntry = { if (linked && linked.status !== "inchis") {
...linked, const updatedLinked: RegistryEntry = {
status: 'inchis', ...linked,
updatedAt: new Date().toISOString(), status: "inchis",
}; updatedAt: new Date().toISOString(),
await saveEntry(storage, updatedLinked); };
await saveEntry(storage, updatedLinked);
}
} }
await refresh();
} }
await refresh(); },
} [entries, updateEntry, storage, refresh],
}, [entries, updateEntry, storage, refresh]); );
const updateFilter = useCallback(<K extends keyof RegistryFilters>(key: K, value: RegistryFilters[K]) => { const updateFilter = useCallback(
setFilters((prev) => ({ ...prev, [key]: value })); <K extends keyof RegistryFilters>(key: K, value: RegistryFilters[K]) => {
}, []); setFilters((prev) => ({ ...prev, [key]: value }));
},
[],
);
// ── Deadline operations ── // ── Deadline operations ──
const addDeadline = useCallback(async (entryId: string, typeId: string, startDate: string, chainParentId?: string) => { const addDeadline = useCallback(
const entry = entries.find((e) => e.id === entryId); async (
if (!entry) return null; entryId: string,
const tracked = createTrackedDeadline(typeId, startDate, chainParentId); typeId: string,
if (!tracked) return null; startDate: string,
const existing = entry.trackedDeadlines ?? []; chainParentId?: string,
const updated: RegistryEntry = { ) => {
...entry, const entry = entries.find((e) => e.id === entryId);
trackedDeadlines: [...existing, tracked], if (!entry) return null;
updatedAt: new Date().toISOString(), const tracked = createTrackedDeadline(typeId, startDate, chainParentId);
}; if (!tracked) return null;
await saveEntry(storage, updated); const existing = entry.trackedDeadlines ?? [];
await refresh(); const updated: RegistryEntry = {
return tracked; ...entry,
}, [entries, storage, refresh]); trackedDeadlines: [...existing, tracked],
updatedAt: new Date().toISOString(),
};
await saveEntry(storage, updated);
await refresh();
return tracked;
},
[entries, storage, refresh],
);
const resolveDeadline = useCallback(async ( const resolveDeadline = useCallback(
entryId: string, async (
deadlineId: string, entryId: string,
resolution: DeadlineResolution, deadlineId: string,
note?: string, resolution: DeadlineResolution,
): Promise<TrackedDeadline | null> => { note?: string,
const entry = entries.find((e) => e.id === entryId); ): Promise<TrackedDeadline | null> => {
if (!entry) return null; const entry = entries.find((e) => e.id === entryId);
const deadlines = entry.trackedDeadlines ?? []; if (!entry) return null;
const idx = deadlines.findIndex((d) => d.id === deadlineId); const deadlines = entry.trackedDeadlines ?? [];
if (idx === -1) return null; const idx = deadlines.findIndex((d) => d.id === deadlineId);
const dl = deadlines[idx]; if (idx === -1) return null;
if (!dl) return null; const dl = deadlines[idx];
const resolved = resolveDeadlineFn(dl, resolution, note); if (!dl) return null;
const updatedDeadlines = [...deadlines]; const resolved = resolveDeadlineFn(dl, resolution, note);
updatedDeadlines[idx] = resolved; const updatedDeadlines = [...deadlines];
const updated: RegistryEntry = { updatedDeadlines[idx] = resolved;
...entry, const updated: RegistryEntry = {
trackedDeadlines: updatedDeadlines, ...entry,
updatedAt: new Date().toISOString(), trackedDeadlines: updatedDeadlines,
}; updatedAt: new Date().toISOString(),
await saveEntry(storage, updated); };
await saveEntry(storage, updated);
// If the resolved deadline has a chain, automatically check for the next type // If the resolved deadline has a chain, automatically check for the next type
const def = getDeadlineType(dl.typeId); const def = getDeadlineType(dl.typeId);
await refresh(); await refresh();
if (
def?.chainNextTypeId &&
(resolution === "completed" || resolution === "aprobat-tacit")
) {
return resolved;
}
if (def?.chainNextTypeId && (resolution === 'completed' || resolution === 'aprobat-tacit')) {
return resolved; return resolved;
} },
[entries, storage, refresh],
);
return resolved; const removeDeadline = useCallback(
}, [entries, storage, refresh]); async (entryId: string, deadlineId: string) => {
const entry = entries.find((e) => e.id === entryId);
const removeDeadline = useCallback(async (entryId: string, deadlineId: string) => { if (!entry) return;
const entry = entries.find((e) => e.id === entryId); const deadlines = entry.trackedDeadlines ?? [];
if (!entry) return; const updated: RegistryEntry = {
const deadlines = entry.trackedDeadlines ?? []; ...entry,
const updated: RegistryEntry = { trackedDeadlines: deadlines.filter((d) => d.id !== deadlineId),
...entry, updatedAt: new Date().toISOString(),
trackedDeadlines: deadlines.filter((d) => d.id !== deadlineId), };
updatedAt: new Date().toISOString(), await saveEntry(storage, updated);
}; await refresh();
await saveEntry(storage, updated); },
await refresh(); [entries, storage, refresh],
}, [entries, storage, refresh]); );
const filteredEntries = entries.filter((entry) => { const filteredEntries = entries.filter((entry) => {
if (filters.direction !== 'all' && entry.direction !== filters.direction) return false; if (filters.direction !== "all" && entry.direction !== filters.direction)
if (filters.status !== 'all' && entry.status !== filters.status) return false; return false;
if (filters.documentType !== 'all' && entry.documentType !== filters.documentType) return false; if (filters.status !== "all" && entry.status !== filters.status)
if (filters.company !== 'all' && entry.company !== filters.company) return false; return false;
if (
filters.documentType !== "all" &&
entry.documentType !== filters.documentType
)
return false;
if (filters.company !== "all" && entry.company !== filters.company)
return false;
if (filters.search) { if (filters.search) {
const q = filters.search.toLowerCase(); const q = filters.search.toLowerCase();
return ( return (
@@ -1,7 +1,7 @@
import type { CompanyId } from '@/core/auth/types'; import type { CompanyId } from "@/core/auth/types";
import type { RegistryEntry } from '../types'; import type { RegistryEntry } from "../types";
const STORAGE_PREFIX = 'entry:'; const STORAGE_PREFIX = "entry:";
export interface RegistryStorage { export interface RegistryStorage {
get<T>(key: string): Promise<T | null>; get<T>(key: string): Promise<T | null>;
@@ -10,7 +10,9 @@ export interface RegistryStorage {
list(): Promise<string[]>; list(): Promise<string[]>;
} }
export async function getAllEntries(storage: RegistryStorage): Promise<RegistryEntry[]> { export async function getAllEntries(
storage: RegistryStorage,
): Promise<RegistryEntry[]> {
const keys = await storage.list(); const keys = await storage.list();
const entries: RegistryEntry[] = []; const entries: RegistryEntry[] = [];
for (const key of keys) { for (const key of keys) {
@@ -23,42 +25,56 @@ export async function getAllEntries(storage: RegistryStorage): Promise<RegistryE
return entries; return entries;
} }
export async function saveEntry(storage: RegistryStorage, entry: RegistryEntry): Promise<void> { export async function saveEntry(
storage: RegistryStorage,
entry: RegistryEntry,
): Promise<void> {
await storage.set(`${STORAGE_PREFIX}${entry.id}`, entry); await storage.set(`${STORAGE_PREFIX}${entry.id}`, entry);
} }
export async function deleteEntry(storage: RegistryStorage, id: string): Promise<void> { export async function deleteEntry(
storage: RegistryStorage,
id: string,
): Promise<void> {
await storage.delete(`${STORAGE_PREFIX}${id}`); await storage.delete(`${STORAGE_PREFIX}${id}`);
} }
const COMPANY_PREFIXES: Record<CompanyId, string> = { const COMPANY_PREFIXES: Record<CompanyId, string> = {
beletage: 'B', beletage: "B",
'urban-switch': 'US', "urban-switch": "US",
'studii-de-teren': 'SDT', "studii-de-teren": "SDT",
group: 'G', group: "G",
}; };
/** /**
* Generate company-specific registry number: B-0001/2026 * Generate company-specific registry number: B-0001/2026
* Uses the next sequential number for that company in that year. * Uses the highest existing number + 1 for that company in that year.
* Parses actual numbers from entries to prevent duplicates.
*/ */
export function generateRegistryNumber( export function generateRegistryNumber(
company: CompanyId, company: CompanyId,
date: string, _date: string,
existingEntries: RegistryEntry[] existingEntries: RegistryEntry[],
): string { ): string {
const d = new Date(date); const now = new Date();
const year = d.getFullYear(); const year = now.getFullYear();
const prefix = COMPANY_PREFIXES[company]; const prefix = COMPANY_PREFIXES[company];
// Count existing entries for this company in this year // Parse the numeric part from existing numbers for this company+year
const sameCompanyYear = existingEntries.filter((e) => { // Pattern: PREFIX-NNNN/YYYY
const entryYear = new Date(e.date).getFullYear(); const regex = new RegExp(`^${prefix}-(\\d+)/${year}$`);
return e.company === company && entryYear === year; let maxNum = 0;
});
const nextIndex = sameCompanyYear.length + 1; for (const e of existingEntries) {
const padded = String(nextIndex).padStart(4, '0'); const match = e.number.match(regex);
if (match?.[1]) {
const num = parseInt(match[1], 10);
if (num > maxNum) maxNum = num;
}
}
const nextIndex = maxNum + 1;
const padded = String(nextIndex).padStart(4, "0");
return `${prefix}-${padded}/${year}`; return `${prefix}-${padded}/${year}`;
} }
+13
View File
@@ -40,8 +40,18 @@ 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";
/** Closure resolution — why the entry was closed */
export type ClosureResolution =
| "finalizat"
| "aprobat-tacit"
| "respins"
| "retras"
| "altele";
/** Structured closure information — recorded every time an entry is closed */ /** Structured closure information — recorded every time an entry is closed */
export interface ClosureInfo { export interface ClosureInfo {
/** Resolution type */
resolution: ClosureResolution;
/** Why the entry was closed */ /** Why the entry was closed */
reason: string; reason: string;
/** Who closed it (name / SSO identity when available) */ /** Who closed it (name / SSO identity when available) */
@@ -122,7 +132,10 @@ export interface RegistryEntry {
id: string; id: string;
/** Company-specific number: B-0001/2026, US-0001/2026, SDT-0001/2026 */ /** Company-specific number: B-0001/2026, US-0001/2026, SDT-0001/2026 */
number: string; number: string;
/** Data documentului (poate fi în trecut pentru backdating) */
date: string; date: string;
/** Data înregistrării efective în sistem (auto = azi) */
registrationDate?: string;
direction: RegistryDirection; direction: RegistryDirection;
documentType: DocumentType; documentType: DocumentType;
subject: string; subject: string;