Full deadline tracking engine for Romanian construction permitting: - 16 deadline types across 5 categories (Avize, Completări, Analiză, Autorizare, Publicitate) - Working days vs calendar days with Romanian public holidays (Orthodox Easter via Meeus) - Backward deadlines (AC extension: 45 working days BEFORE expiry) - Chain deadlines (resolving one prompts adding the next) - Tacit approval auto-detection (overdue + applicable type) - Tabbed UI: Registru + Termene legale dashboard with stats/filters/table - Inline deadline cards in entry form with add/resolve/remove - Clock icon + count badge on registry table for entries with deadlines Also adds CLAUDE.md with full project context for AI assistant handoff. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
200 lines
6.8 KiB
TypeScript
200 lines
6.8 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, 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'>) => {
|
|
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 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 */
|
|
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();
|
|
}
|
|
}, [entries, updateEntry, 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;
|
|
});
|
|
|
|
return {
|
|
entries: filteredEntries,
|
|
allEntries: entries,
|
|
loading,
|
|
filters,
|
|
updateFilter,
|
|
addEntry,
|
|
updateEntry,
|
|
removeEntry,
|
|
closeEntry,
|
|
addDeadline,
|
|
resolveDeadline,
|
|
removeDeadline,
|
|
refresh,
|
|
};
|
|
}
|