diff --git a/src/modules/registratura/components/close-guard-dialog.tsx b/src/modules/registratura/components/close-guard-dialog.tsx index 43db31f..297af22 100644 --- a/src/modules/registratura/components/close-guard-dialog.tsx +++ b/src/modules/registratura/components/close-guard-dialog.tsx @@ -14,6 +14,13 @@ 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select"; import { Dialog, DialogContent, @@ -26,6 +33,7 @@ import type { TrackedDeadline, RegistryAttachment, ClosureInfo, + ClosureResolution, } from "../types"; import { getDeadlineType } from "../services/deadline-catalog"; import { v4 as uuid } from "uuid"; @@ -60,6 +68,7 @@ export function CloseGuardDialog({ }: CloseGuardDialogProps) { const [search, setSearch] = useState(""); const [selectedEntryId, setSelectedEntryId] = useState(""); + const [resolution, setResolution] = useState("finalizat"); const [reason, setReason] = useState(""); const [attachment, setAttachment] = useState(null); const fileRef = useRef(null); @@ -71,6 +80,7 @@ export function CloseGuardDialog({ if (o) { setSearch(""); setSelectedEntryId(""); + setResolution("finalizat"); setReason(""); setAttachment(null); } @@ -118,6 +128,7 @@ export function CloseGuardDialog({ const handleSubmit = () => { if (!canSubmit) return; onConfirmClose({ + resolution, reason: reason.trim(), closedBy: "Utilizator", // TODO: replace with SSO identity closedAt: new Date().toISOString(), @@ -179,6 +190,26 @@ export function CloseGuardDialog({ )} + {/* Resolution type — same concept as deadline resolution */} +
+ + +
+ {/* Reason — always required */}
diff --git a/src/modules/registratura/components/closure-banner.tsx b/src/modules/registratura/components/closure-banner.tsx index dcdff45..b37357e 100644 --- a/src/modules/registratura/components/closure-banner.tsx +++ b/src/modules/registratura/components/closure-banner.tsx @@ -21,6 +21,14 @@ interface ClosureBannerProps { allEntries?: RegistryEntry[]; } +const RESOLUTION_LABELS: Record = { + finalizat: "Finalizat", + "aprobat-tacit": "Aprobat tacit", + respins: "Respins / Negativ", + retras: "Retras", + altele: "Altele", +}; + /** * Read-only banner displayed at the top of a closed entry, * showing who closed it, when, why, linked entry, and attachment. @@ -53,6 +61,12 @@ export function ClosureBanner({
Înregistrare închisă + {closureInfo.resolution && ( + + {RESOLUTION_LABELS[closureInfo.resolution] ?? + closureInfo.resolution} + + )} {closureInfo.hadActiveDeadlines && ( , - ) => void; + ) => void | Promise; onCancel: () => void; /** Callback to create a new Address Book contact */ onCreateContact?: (data: { @@ -148,6 +149,11 @@ export function RegistryEntryForm({ const [linkedSearch, setLinkedSearch] = 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 ── const [deadlineAddOpen, setDeadlineAddOpen] = useState(false); const [resolvingDeadline, setResolvingDeadline] = @@ -283,7 +289,9 @@ export function RegistryEntryForm({ const handleFileUpload = (e: React.ChangeEvent) => { const files = e.target.files; 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(); reader.onload = () => { const base64 = reader.result as string; @@ -298,6 +306,10 @@ export function RegistryEntryForm({ addedAt: new Date().toISOString(), }, ]); + setUploadingCount((prev) => Math.max(0, prev - 1)); + }; + reader.onerror = () => { + setUploadingCount((prev) => Math.max(0, prev - 1)); }; reader.readAsDataURL(file); } @@ -308,31 +320,37 @@ export function RegistryEntryForm({ setAttachments((prev) => prev.filter((a) => a.id !== id)); }; - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - onSubmit({ - direction, - documentType, - subject, - date, - sender, - senderContactId: senderContactId || undefined, - recipient, - recipientContactId: recipientContactId || undefined, - company, - status: isClosed ? "inchis" : "deschis", - deadline: deadline || undefined, - assignee: assignee || undefined, - assigneeContactId: assigneeContactId || undefined, - threadParentId: threadParentId || undefined, - linkedEntryIds, - attachments, - trackedDeadlines: - trackedDeadlines.length > 0 ? trackedDeadlines : undefined, - notes, - tags: initial?.tags ?? [], - visibility: initial?.visibility ?? "all", - }); + if (isSubmitting || isUploading) return; + setIsSubmitting(true); + try { + await onSubmit({ + direction, + documentType, + subject, + date, + sender, + senderContactId: senderContactId || undefined, + recipient, + recipientContactId: recipientContactId || undefined, + company, + status: isClosed ? "inchis" : "deschis", + deadline: deadline || undefined, + assignee: assignee || undefined, + assigneeContactId: assigneeContactId || undefined, + threadParentId: threadParentId || undefined, + linkedEntryIds, + attachments, + trackedDeadlines: + trackedDeadlines.length > 0 ? trackedDeadlines : undefined, + notes, + tags: initial?.tags ?? [], + visibility: initial?.visibility ?? "all", + }); + } finally { + setIsSubmitting(false); + } }; // ── Contact autocomplete dropdown renderer ── @@ -479,13 +497,34 @@ export function RegistryEntryForm({
- + setDate(e.target.value)} className="mt-1" /> + {!initial && date !== new Date().toISOString().slice(0, 10) && ( +

+ Data diferă de azi — înregistrarea retroactivă va primi următorul + nr. secvențial. +

+ )}
@@ -876,12 +915,22 @@ export function RegistryEntryForm({ {/* Attachments */}
- + @@ -931,10 +980,31 @@ export function RegistryEntryForm({
- - +
{/* Quick contact creation dialog */} diff --git a/src/modules/registratura/components/registry-table.tsx b/src/modules/registratura/components/registry-table.tsx index 997c57b..544ded7 100644 --- a/src/modules/registratura/components/registry-table.tsx +++ b/src/modules/registratura/components/registry-table.tsx @@ -71,7 +71,7 @@ export function RegistryTable({ Nr. - Data + Data doc. Dir. Tip Subiect @@ -103,6 +103,15 @@ export function RegistryTable({ {formatDate(entry.date)} + {entry.registrationDate && + entry.registrationDate !== entry.date && ( + + (înr. {formatDate(entry.registrationDate)}) + + )} ([]); const [loading, setLoading] = useState(true); const [filters, setFilters] = useState({ - search: '', - direction: 'all', - status: 'all', - documentType: 'all', - company: 'all', + search: "", + direction: "all", + status: "all", + documentType: "all", + company: "all", }); const refresh = useCallback(async () => { @@ -36,139 +51,189 @@ export function useRegistry() { }, [storage]); // eslint-disable-next-line react-hooks/set-state-in-effect - useEffect(() => { refresh(); }, [refresh]); + useEffect(() => { + refresh(); + }, [refresh]); - const addEntry = useCallback(async (data: Omit) => { - const now = new Date().toISOString(); - const number = generateRegistryNumber(data.company, data.date, entries); - const entry: RegistryEntry = { - ...data, - id: uuid(), - number, - createdAt: now, - updatedAt: now, - }; - await saveEntry(storage, entry); - await refresh(); - return entry; - }, [storage, refresh, entries]); + const addEntry = useCallback( + async ( + data: Omit, + ) => { + // Fetch fresh entries to prevent duplicate numbers from stale state + const freshEntries = await getAllEntries(storage); + const now = new Date().toISOString(); + const number = generateRegistryNumber( + data.company, + data.date, + freshEntries, + ); + const entry: RegistryEntry = { + ...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) => { - const existing = entries.find((e) => e.id === id); - if (!existing) return; - const updated: RegistryEntry = { - ...existing, - ...updates, - id: existing.id, - number: existing.number, - createdAt: existing.createdAt, - updatedAt: new Date().toISOString(), - }; - await saveEntry(storage, updated); - await refresh(); - }, [storage, refresh, entries]); + const updateEntry = useCallback( + async (id: string, updates: Partial) => { + const existing = entries.find((e) => e.id === id); + if (!existing) return; + const updated: RegistryEntry = { + ...existing, + ...updates, + id: existing.id, + number: existing.number, + createdAt: existing.createdAt, + updatedAt: new Date().toISOString(), + }; + await saveEntry(storage, updated); + await refresh(); + }, + [storage, refresh, entries], + ); - const removeEntry = useCallback(async (id: string) => { - await deleteEntry(storage, id); - await refresh(); - }, [storage, refresh]); + const removeEntry = useCallback( + async (id: string) => { + await deleteEntry(storage, id); + await refresh(); + }, + [storage, refresh], + ); /** Close an entry and optionally its linked entries */ - const closeEntry = useCallback(async (id: string, closeLinked: boolean) => { - const entry = entries.find((e) => e.id === id); - if (!entry) return; - await updateEntry(id, { status: 'inchis' }); - const linked = entry.linkedEntryIds ?? []; - if (closeLinked && linked.length > 0) { - for (const linkedId of linked) { - const linked = entries.find((e) => e.id === linkedId); - if (linked && linked.status !== 'inchis') { - const updatedLinked: RegistryEntry = { - ...linked, - status: 'inchis', - updatedAt: new Date().toISOString(), - }; - await saveEntry(storage, updatedLinked); + const closeEntry = useCallback( + async (id: string, closeLinked: boolean) => { + const entry = entries.find((e) => e.id === id); + if (!entry) return; + await updateEntry(id, { status: "inchis" }); + const linked = entry.linkedEntryIds ?? []; + if (closeLinked && linked.length > 0) { + for (const linkedId of linked) { + const linked = entries.find((e) => e.id === linkedId); + if (linked && linked.status !== "inchis") { + const updatedLinked: RegistryEntry = { + ...linked, + status: "inchis", + updatedAt: new Date().toISOString(), + }; + await saveEntry(storage, updatedLinked); + } } + await refresh(); } - await refresh(); - } - }, [entries, updateEntry, storage, refresh]); + }, + [entries, updateEntry, storage, refresh], + ); - const updateFilter = useCallback((key: K, value: RegistryFilters[K]) => { - setFilters((prev) => ({ ...prev, [key]: value })); - }, []); + const updateFilter = useCallback( + (key: K, value: RegistryFilters[K]) => { + setFilters((prev) => ({ ...prev, [key]: value })); + }, + [], + ); // ── Deadline operations ── - const addDeadline = useCallback(async (entryId: string, typeId: string, startDate: string, chainParentId?: string) => { - const entry = entries.find((e) => e.id === entryId); - if (!entry) return null; - const tracked = createTrackedDeadline(typeId, startDate, chainParentId); - if (!tracked) return null; - const existing = entry.trackedDeadlines ?? []; - const updated: RegistryEntry = { - ...entry, - trackedDeadlines: [...existing, tracked], - updatedAt: new Date().toISOString(), - }; - await saveEntry(storage, updated); - await refresh(); - return tracked; - }, [entries, storage, refresh]); + const addDeadline = useCallback( + async ( + entryId: string, + typeId: string, + startDate: string, + chainParentId?: string, + ) => { + const entry = entries.find((e) => e.id === entryId); + if (!entry) return null; + const tracked = createTrackedDeadline(typeId, startDate, chainParentId); + if (!tracked) return null; + const existing = entry.trackedDeadlines ?? []; + const updated: RegistryEntry = { + ...entry, + trackedDeadlines: [...existing, tracked], + updatedAt: new Date().toISOString(), + }; + await saveEntry(storage, updated); + await refresh(); + return tracked; + }, + [entries, storage, refresh], + ); - const resolveDeadline = useCallback(async ( - entryId: string, - deadlineId: string, - resolution: DeadlineResolution, - note?: string, - ): Promise => { - const entry = entries.find((e) => e.id === entryId); - if (!entry) return null; - const deadlines = entry.trackedDeadlines ?? []; - const idx = deadlines.findIndex((d) => d.id === deadlineId); - if (idx === -1) return null; - const dl = deadlines[idx]; - if (!dl) return null; - const resolved = resolveDeadlineFn(dl, resolution, note); - const updatedDeadlines = [...deadlines]; - updatedDeadlines[idx] = resolved; - const updated: RegistryEntry = { - ...entry, - trackedDeadlines: updatedDeadlines, - updatedAt: new Date().toISOString(), - }; - await saveEntry(storage, updated); + const resolveDeadline = useCallback( + async ( + entryId: string, + deadlineId: string, + resolution: DeadlineResolution, + note?: string, + ): Promise => { + const entry = entries.find((e) => e.id === entryId); + if (!entry) return null; + const deadlines = entry.trackedDeadlines ?? []; + const idx = deadlines.findIndex((d) => d.id === deadlineId); + if (idx === -1) return null; + const dl = deadlines[idx]; + if (!dl) return null; + const resolved = resolveDeadlineFn(dl, resolution, note); + const updatedDeadlines = [...deadlines]; + updatedDeadlines[idx] = resolved; + const updated: RegistryEntry = { + ...entry, + trackedDeadlines: updatedDeadlines, + updatedAt: new Date().toISOString(), + }; + await saveEntry(storage, updated); - // If the resolved deadline has a chain, automatically check for the next type - const def = getDeadlineType(dl.typeId); - await refresh(); + // If the resolved deadline has a chain, automatically check for the next type + const def = getDeadlineType(dl.typeId); + await refresh(); + + if ( + def?.chainNextTypeId && + (resolution === "completed" || resolution === "aprobat-tacit") + ) { + return resolved; + } - if (def?.chainNextTypeId && (resolution === 'completed' || resolution === 'aprobat-tacit')) { return resolved; - } + }, + [entries, storage, refresh], + ); - return resolved; - }, [entries, storage, refresh]); - - const removeDeadline = useCallback(async (entryId: string, deadlineId: string) => { - const entry = entries.find((e) => e.id === entryId); - if (!entry) return; - const deadlines = entry.trackedDeadlines ?? []; - const updated: RegistryEntry = { - ...entry, - trackedDeadlines: deadlines.filter((d) => d.id !== deadlineId), - updatedAt: new Date().toISOString(), - }; - await saveEntry(storage, updated); - await refresh(); - }, [entries, storage, refresh]); + const removeDeadline = useCallback( + async (entryId: string, deadlineId: string) => { + const entry = entries.find((e) => e.id === entryId); + if (!entry) return; + const deadlines = entry.trackedDeadlines ?? []; + const updated: RegistryEntry = { + ...entry, + trackedDeadlines: deadlines.filter((d) => d.id !== deadlineId), + updatedAt: new Date().toISOString(), + }; + await saveEntry(storage, updated); + await refresh(); + }, + [entries, storage, refresh], + ); const filteredEntries = entries.filter((entry) => { - if (filters.direction !== 'all' && entry.direction !== filters.direction) return false; - if (filters.status !== 'all' && entry.status !== filters.status) return false; - if (filters.documentType !== 'all' && entry.documentType !== filters.documentType) return false; - if (filters.company !== 'all' && entry.company !== filters.company) return false; + if (filters.direction !== "all" && entry.direction !== filters.direction) + return false; + if (filters.status !== "all" && entry.status !== filters.status) + 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) { const q = filters.search.toLowerCase(); return ( diff --git a/src/modules/registratura/services/registry-service.ts b/src/modules/registratura/services/registry-service.ts index 61a0166..54dc621 100644 --- a/src/modules/registratura/services/registry-service.ts +++ b/src/modules/registratura/services/registry-service.ts @@ -1,7 +1,7 @@ -import type { CompanyId } from '@/core/auth/types'; -import type { RegistryEntry } from '../types'; +import type { CompanyId } from "@/core/auth/types"; +import type { RegistryEntry } from "../types"; -const STORAGE_PREFIX = 'entry:'; +const STORAGE_PREFIX = "entry:"; export interface RegistryStorage { get(key: string): Promise; @@ -10,7 +10,9 @@ export interface RegistryStorage { list(): Promise; } -export async function getAllEntries(storage: RegistryStorage): Promise { +export async function getAllEntries( + storage: RegistryStorage, +): Promise { const keys = await storage.list(); const entries: RegistryEntry[] = []; for (const key of keys) { @@ -23,42 +25,56 @@ export async function getAllEntries(storage: RegistryStorage): Promise { +export async function saveEntry( + storage: RegistryStorage, + entry: RegistryEntry, +): Promise { await storage.set(`${STORAGE_PREFIX}${entry.id}`, entry); } -export async function deleteEntry(storage: RegistryStorage, id: string): Promise { +export async function deleteEntry( + storage: RegistryStorage, + id: string, +): Promise { await storage.delete(`${STORAGE_PREFIX}${id}`); } const COMPANY_PREFIXES: Record = { - beletage: 'B', - 'urban-switch': 'US', - 'studii-de-teren': 'SDT', - group: 'G', + beletage: "B", + "urban-switch": "US", + "studii-de-teren": "SDT", + group: "G", }; /** * 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( company: CompanyId, - date: string, - existingEntries: RegistryEntry[] + _date: string, + existingEntries: RegistryEntry[], ): string { - const d = new Date(date); - const year = d.getFullYear(); + const now = new Date(); + const year = now.getFullYear(); const prefix = COMPANY_PREFIXES[company]; - // Count existing entries for this company in this year - const sameCompanyYear = existingEntries.filter((e) => { - const entryYear = new Date(e.date).getFullYear(); - return e.company === company && entryYear === year; - }); + // Parse the numeric part from existing numbers for this company+year + // Pattern: PREFIX-NNNN/YYYY + const regex = new RegExp(`^${prefix}-(\\d+)/${year}$`); + let maxNum = 0; - const nextIndex = sameCompanyYear.length + 1; - const padded = String(nextIndex).padStart(4, '0'); + for (const e of existingEntries) { + 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}`; } diff --git a/src/modules/registratura/types.ts b/src/modules/registratura/types.ts index 7b64cfb..6fb0ac9 100644 --- a/src/modules/registratura/types.ts +++ b/src/modules/registratura/types.ts @@ -40,8 +40,18 @@ export const DEFAULT_DOC_TYPE_LABELS: Record = { /** Status — simplified to open/closed */ 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 */ export interface ClosureInfo { + /** Resolution type */ + resolution: ClosureResolution; /** Why the entry was closed */ reason: string; /** Who closed it (name / SSO identity when available) */ @@ -122,7 +132,10 @@ export interface RegistryEntry { id: string; /** Company-specific number: B-0001/2026, US-0001/2026, SDT-0001/2026 */ number: string; + /** Data documentului (poate fi în trecut pentru backdating) */ date: string; + /** Data înregistrării efective în sistem (auto = azi) */ + registrationDate?: string; direction: RegistryDirection; documentType: DocumentType; subject: string;