c22848b471
ROOT CAUSE: RegistryEntry stores file attachments as base64 strings in JSON.
A single 5MB PDF becomes ~6.7MB of base64. With 6 entries, the exportAll()
endpoint was sending 30-60MB of JSON on every page load taking 2+ minutes.
Fix: Added ?lightweight=true parameter to /api/storage GET endpoint.
When enabled, stripHeavyFields() recursively removes large 'data' and
'fileData' string fields (>1KB) from JSON values, replacing with '__stripped__'.
Changes:
- /api/storage route.ts: stripHeavyFields() + lightweight query param
- StorageService.export(): accepts { lightweight?: boolean } option
- DatabaseStorageAdapter.export(): passes lightweight flag to API
- LocalStorageAdapter.export(): accepts option (no-op, localStorage is fast)
- useStorage.exportAll(): passes options through
- registry-service.ts: getAllEntries() uses lightweight=true by default
- registry-service.ts: new getFullEntry() loads single entry with full data
- use-registry.ts: exports loadFullEntry() for on-demand full loading
- registratura-module.tsx: handleEdit/handleNavigateEntry load full entry
Result: List loading transfers ~100KB instead of 30-60MB. Editing loads
full data for a single entry on demand (~5-10MB for one entry vs all).
287 lines
8.3 KiB
TypeScript
287 lines
8.3 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useCallback } 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,
|
||
deleteEntry,
|
||
generateRegistryNumber,
|
||
} from "../services/registry-service";
|
||
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 [entries, setEntries] = useState<RegistryEntry[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [filters, setFilters] = useState<RegistryFilters>({
|
||
search: "",
|
||
direction: "all",
|
||
status: "all",
|
||
documentType: "all",
|
||
company: "all",
|
||
});
|
||
|
||
const refresh = useCallback(async () => {
|
||
setLoading(true);
|
||
const items = await getAllEntries(storage);
|
||
setEntries(items);
|
||
setLoading(false);
|
||
}, [storage]);
|
||
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
useEffect(() => {
|
||
refresh();
|
||
}, [refresh]);
|
||
|
||
const addEntry = useCallback(
|
||
async (
|
||
data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
|
||
) => {
|
||
// Fetch fresh entries to prevent duplicate numbers from stale state.
|
||
// This single call replaces what was previously list() + N×get().
|
||
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);
|
||
// Update local state directly to avoid a second full fetch
|
||
setEntries((prev) => [entry, ...prev]);
|
||
return entry;
|
||
},
|
||
[storage],
|
||
);
|
||
|
||
const updateEntry = useCallback(
|
||
async (id: string, updates: Partial<RegistryEntry>) => {
|
||
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],
|
||
);
|
||
|
||
/** Close an entry and optionally its linked entries.
|
||
* Batches all saves, then does a single refresh at the end.
|
||
*/
|
||
const closeEntry = useCallback(
|
||
async (id: string, closeLinked: boolean) => {
|
||
const entry = entries.find((e) => e.id === id);
|
||
if (!entry) return;
|
||
// Save main entry as closed
|
||
const now = new Date().toISOString();
|
||
const closedMain: RegistryEntry = {
|
||
...entry,
|
||
status: "inchis",
|
||
updatedAt: now,
|
||
};
|
||
await saveEntry(storage, closedMain);
|
||
// Close linked entries in parallel
|
||
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) =>
|
||
saveEntry(storage, { ...e, status: "inchis", updatedAt: now }),
|
||
);
|
||
await Promise.all(saves);
|
||
}
|
||
// Single refresh at the end
|
||
await refresh();
|
||
},
|
||
[entries, storage, 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, updated);
|
||
await refresh();
|
||
return tracked;
|
||
},
|
||
[entries, storage, 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, updated);
|
||
|
||
// 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;
|
||
}
|
||
|
||
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 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;
|
||
});
|
||
|
||
/**
|
||
* Load a single entry WITH full attachment data (for editing).
|
||
* The list uses lightweight mode that strips base64 data.
|
||
*/
|
||
const loadFullEntry = useCallback(
|
||
async (id: string): Promise<RegistryEntry | null> => {
|
||
return getFullEntry(storage, id);
|
||
},
|
||
[storage],
|
||
);
|
||
|
||
return {
|
||
entries: filteredEntries,
|
||
allEntries: entries,
|
||
loading,
|
||
filters,
|
||
updateFilter,
|
||
addEntry,
|
||
updateEntry,
|
||
removeEntry,
|
||
closeEntry,
|
||
loadFullEntry,
|
||
addDeadline,
|
||
resolveDeadline,
|
||
removeDeadline,
|
||
refresh,
|
||
};
|
||
}
|