1361534c98
- groupDeadlinesByEntry skips entries with status "inchis" - closeEntry auto-resolves all pending deadlines on close (main + linked) - Fixes S-2026-00001 showing as overdue despite being closed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
341 lines
10 KiB
TypeScript
341 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import { useStorage } from "@/core/storage";
|
|
import { v4 as uuid } from "uuid";
|
|
import type {
|
|
RegistryEntry,
|
|
RegistryDirection,
|
|
RegistryStatus,
|
|
DocumentType,
|
|
TrackedDeadline,
|
|
DeadlineResolution,
|
|
} from "../types";
|
|
import {
|
|
getAllEntries,
|
|
getFullEntry,
|
|
saveEntry,
|
|
} from "../services/registry-service";
|
|
import type { RegistryAuditEvent } from "../types";
|
|
import {
|
|
createTrackedDeadline,
|
|
resolveDeadline as resolveDeadlineFn,
|
|
} from "../services/deadline-service";
|
|
import { getDeadlineType } from "../services/deadline-catalog";
|
|
|
|
export interface RegistryFilters {
|
|
search: string;
|
|
direction: RegistryDirection | "all";
|
|
status: RegistryStatus | "all";
|
|
documentType: DocumentType | "all";
|
|
company: string;
|
|
}
|
|
|
|
export function useRegistry() {
|
|
const storage = useStorage("registratura");
|
|
const blobStorage = useStorage("registratura-blobs");
|
|
const [entries, setEntries] = useState<RegistryEntry[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [filters, setFilters] = useState<RegistryFilters>({
|
|
search: "",
|
|
direction: "all",
|
|
status: "all",
|
|
documentType: "all",
|
|
company: "all",
|
|
});
|
|
const migrationRan = useRef(false);
|
|
|
|
const refresh = useCallback(async () => {
|
|
setLoading(true);
|
|
const items = await getAllEntries(storage);
|
|
setEntries(items);
|
|
setLoading(false);
|
|
}, [storage]);
|
|
|
|
// On mount: trigger server-side blob migration (fire-and-forget), then load list
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
useEffect(() => {
|
|
const init = async () => {
|
|
// Trigger server-side migration (runs inside Node.js, not browser)
|
|
if (!migrationRan.current) {
|
|
migrationRan.current = true;
|
|
fetch("/api/storage/migrate-blobs", { method: "POST" }).catch(() => {});
|
|
}
|
|
await refresh();
|
|
};
|
|
init();
|
|
}, [refresh]);
|
|
|
|
const addEntry = useCallback(
|
|
async (
|
|
data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
|
|
) => {
|
|
// Use the API for atomic server-side numbering + audit
|
|
const res = await fetch("/api/registratura", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ entry: data }),
|
|
});
|
|
const result = await res.json();
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.error ?? "Failed to create entry");
|
|
}
|
|
|
|
const entry = result.entry as RegistryEntry;
|
|
setEntries((prev) => [entry, ...prev]);
|
|
return entry;
|
|
},
|
|
[],
|
|
);
|
|
|
|
const updateEntry = useCallback(
|
|
async (id: string, updates: Partial<RegistryEntry>) => {
|
|
// Use the API for server-side diff audit
|
|
const res = await fetch("/api/registratura", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ id, updates }),
|
|
});
|
|
const result = await res.json();
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.error ?? "Failed to update entry");
|
|
}
|
|
|
|
await refresh();
|
|
},
|
|
[refresh],
|
|
);
|
|
|
|
const removeEntry = useCallback(
|
|
async (id: string) => {
|
|
// Use the API for sequence validation + audit logging
|
|
const res = await fetch(`/api/registratura?id=${encodeURIComponent(id)}`, {
|
|
method: "DELETE",
|
|
});
|
|
const result = await res.json();
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.error ?? "Failed to delete entry");
|
|
}
|
|
|
|
await refresh();
|
|
},
|
|
[refresh],
|
|
);
|
|
|
|
const closeEntry = useCallback(
|
|
async (id: string, closeLinked: boolean) => {
|
|
const entry = entries.find((e) => e.id === id);
|
|
if (!entry) return;
|
|
const now = new Date().toISOString();
|
|
|
|
// Auto-resolve all pending deadlines when closing an entry
|
|
const resolvedDeadlines = (entry.trackedDeadlines ?? []).map((dl) => {
|
|
if (dl.resolution === "pending") {
|
|
return resolveDeadlineFn(dl, "completed", "Rezolvat automat la închiderea înregistrării");
|
|
}
|
|
return dl;
|
|
});
|
|
|
|
const closedMain: RegistryEntry = {
|
|
...entry,
|
|
status: "inchis",
|
|
trackedDeadlines: resolvedDeadlines,
|
|
updatedAt: now,
|
|
};
|
|
await saveEntry(storage, blobStorage, closedMain);
|
|
const linked = entry.linkedEntryIds ?? [];
|
|
if (closeLinked && linked.length > 0) {
|
|
const saves = linked
|
|
.map((linkedId) => entries.find((e) => e.id === linkedId))
|
|
.filter((e): e is RegistryEntry => !!e && e.status !== "inchis")
|
|
.map((e) => {
|
|
const resolvedDls = (e.trackedDeadlines ?? []).map((dl) =>
|
|
dl.resolution === "pending"
|
|
? resolveDeadlineFn(dl, "completed", "Rezolvat automat la închiderea înregistrării")
|
|
: dl,
|
|
);
|
|
return saveEntry(storage, blobStorage, {
|
|
...e,
|
|
status: "inchis",
|
|
trackedDeadlines: resolvedDls,
|
|
updatedAt: now,
|
|
});
|
|
});
|
|
await Promise.all(saves);
|
|
}
|
|
await refresh();
|
|
},
|
|
[entries, storage, blobStorage, refresh],
|
|
);
|
|
|
|
const updateFilter = useCallback(
|
|
<K extends keyof RegistryFilters>(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, blobStorage, updated);
|
|
await refresh();
|
|
return tracked;
|
|
},
|
|
[entries, storage, blobStorage, refresh],
|
|
);
|
|
|
|
const resolveDeadline = useCallback(
|
|
async (
|
|
entryId: string,
|
|
deadlineId: string,
|
|
resolution: DeadlineResolution,
|
|
note?: string,
|
|
): Promise<TrackedDeadline | null> => {
|
|
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, blobStorage, updated);
|
|
|
|
const def = getDeadlineType(dl.typeId);
|
|
await refresh();
|
|
|
|
if (
|
|
def?.chainNextTypeId &&
|
|
(resolution === "completed" || resolution === "aprobat-tacit")
|
|
) {
|
|
return resolved;
|
|
}
|
|
|
|
return resolved;
|
|
},
|
|
[entries, storage, blobStorage, 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, blobStorage, updated);
|
|
await refresh();
|
|
},
|
|
[entries, storage, blobStorage, 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.search) {
|
|
const q = filters.search.toLowerCase();
|
|
return (
|
|
entry.subject.toLowerCase().includes(q) ||
|
|
entry.sender.toLowerCase().includes(q) ||
|
|
entry.recipient.toLowerCase().includes(q) ||
|
|
entry.number.toLowerCase().includes(q)
|
|
);
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const loadFullEntry = useCallback(
|
|
async (id: string): Promise<RegistryEntry | null> => {
|
|
return getFullEntry(storage, blobStorage, id);
|
|
},
|
|
[storage, blobStorage],
|
|
);
|
|
|
|
// ── Reserved slots ──
|
|
|
|
const createReservedSlots = useCallback(
|
|
async (company: string, year: number, month: number) => {
|
|
const res = await fetch("/api/registratura/reserved", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ company, year, month }),
|
|
});
|
|
const result = await res.json();
|
|
if (result.success) await refresh();
|
|
return result;
|
|
},
|
|
[refresh],
|
|
);
|
|
|
|
// ── Audit trail ──
|
|
|
|
const loadAuditHistory = useCallback(
|
|
async (entryId: string): Promise<RegistryAuditEvent[]> => {
|
|
const res = await fetch(
|
|
`/api/registratura/audit?entryId=${encodeURIComponent(entryId)}`,
|
|
);
|
|
const result = await res.json();
|
|
return result.events ?? [];
|
|
},
|
|
[],
|
|
);
|
|
|
|
return {
|
|
entries: filteredEntries,
|
|
allEntries: entries,
|
|
loading,
|
|
filters,
|
|
updateFilter,
|
|
addEntry,
|
|
updateEntry,
|
|
removeEntry,
|
|
closeEntry,
|
|
loadFullEntry,
|
|
addDeadline,
|
|
resolveDeadline,
|
|
removeDeadline,
|
|
createReservedSlots,
|
|
loadAuditHistory,
|
|
refresh,
|
|
};
|
|
}
|