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:
@@ -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
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user