feat(registratura): add legal deadline tracking system (Termene Legale)

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>
This commit is contained in:
Marius Tarau
2026-02-18 11:27:34 +02:00
parent f0b878cf00
commit bb01268bcb
16 changed files with 1818 additions and 96 deletions

View File

@@ -0,0 +1,160 @@
'use client';
import { useState, useMemo } from 'react';
import { Card, CardContent } from '@/shared/components/ui/card';
import { Badge } from '@/shared/components/ui/badge';
import { Label } from '@/shared/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
import { Button } from '@/shared/components/ui/button';
import type { RegistryEntry, TrackedDeadline, DeadlineResolution, DeadlineCategory } from '../types';
import { aggregateDeadlines } from '../services/deadline-service';
import { CATEGORY_LABELS, getDeadlineType } from '../services/deadline-catalog';
import { useDeadlineFilters } from '../hooks/use-deadline-filters';
import { DeadlineTable } from './deadline-table';
import { DeadlineResolveDialog } from './deadline-resolve-dialog';
interface DeadlineDashboardProps {
entries: RegistryEntry[];
onResolveDeadline: (entryId: string, deadlineId: string, resolution: DeadlineResolution, note: string, chainNext: boolean) => void;
onAddChainedDeadline: (entryId: string, typeId: string, startDate: string, parentId: string) => void;
}
const RESOLUTION_LABELS: Record<string, string> = {
pending: 'În așteptare',
completed: 'Finalizat',
'aprobat-tacit': 'Aprobat tacit',
respins: 'Respins',
anulat: 'Anulat',
};
export function DeadlineDashboard({ entries, onResolveDeadline, onAddChainedDeadline }: DeadlineDashboardProps) {
const { filters, updateFilter } = useDeadlineFilters();
const [resolvingEntry, setResolvingEntry] = useState<string | null>(null);
const [resolvingDeadline, setResolvingDeadline] = useState<TrackedDeadline | null>(null);
const stats = useMemo(() => aggregateDeadlines(entries), [entries]);
const filteredRows = useMemo(() => {
return stats.all.filter((row) => {
if (filters.category !== 'all') {
const def = getDeadlineType(row.deadline.typeId);
if (def && def.category !== filters.category) return false;
}
if (filters.resolution !== 'all') {
// Map tacit display status to actual resolution filter
if (filters.resolution === 'pending') {
if (row.deadline.resolution !== 'pending') return false;
} else if (row.deadline.resolution !== filters.resolution) {
return false;
}
}
if (filters.urgentOnly) {
if (row.status.variant !== 'yellow' && row.status.variant !== 'red') return false;
}
return true;
});
}, [stats.all, filters]);
const handleResolveClick = (entryId: string, deadline: TrackedDeadline) => {
setResolvingEntry(entryId);
setResolvingDeadline(deadline);
};
const handleResolve = (resolution: DeadlineResolution, note: string, chainNext: boolean) => {
if (!resolvingEntry || !resolvingDeadline) return;
onResolveDeadline(resolvingEntry, resolvingDeadline.id, resolution, note, chainNext);
// Handle chain creation
if (chainNext) {
const def = getDeadlineType(resolvingDeadline.typeId);
if (def?.chainNextTypeId) {
const resolvedDate = new Date().toISOString().slice(0, 10);
onAddChainedDeadline(resolvingEntry, def.chainNextTypeId, resolvedDate, resolvingDeadline.id);
}
}
setResolvingEntry(null);
setResolvingDeadline(null);
};
return (
<div className="space-y-4">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Active" value={stats.active} />
<StatCard label="Urgente" value={stats.urgent} variant={stats.urgent > 0 ? 'destructive' : undefined} />
<StatCard label="Depășit termen" value={stats.overdue} variant={stats.overdue > 0 ? 'destructive' : undefined} />
<StatCard label="Aprobat tacit" value={stats.tacit} variant={stats.tacit > 0 ? 'blue' : undefined} />
</div>
{/* Filters */}
<div className="flex flex-wrap items-end gap-3">
<div>
<Label className="text-xs">Categorie</Label>
<Select value={filters.category} onValueChange={(v) => updateFilter('category', v as DeadlineCategory | 'all')}>
<SelectTrigger className="mt-1 w-[160px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate</SelectItem>
{(Object.entries(CATEGORY_LABELS) as [DeadlineCategory, string][]).map(([key, label]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Status</Label>
<Select value={filters.resolution} onValueChange={(v) => updateFilter('resolution', v as DeadlineResolution | 'all')}>
<SelectTrigger className="mt-1 w-[160px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate</SelectItem>
{Object.entries(RESOLUTION_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant={filters.urgentOnly ? 'default' : 'outline'}
size="sm"
onClick={() => updateFilter('urgentOnly', !filters.urgentOnly)}
>
Doar urgente
</Button>
</div>
{/* Table */}
<DeadlineTable rows={filteredRows} onResolve={handleResolveClick} />
<p className="text-xs text-muted-foreground">
{filteredRows.length} din {stats.all.length} termene afișate
</p>
<DeadlineResolveDialog
open={resolvingDeadline !== null}
deadline={resolvingDeadline}
onOpenChange={(open) => {
if (!open) {
setResolvingEntry(null);
setResolvingDeadline(null);
}
}}
onResolve={handleResolve}
/>
</div>
);
}
function StatCard({ label, value, variant }: { label: string; value: number; variant?: 'destructive' | 'blue' }) {
return (
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">{label}</p>
<p className={`text-2xl font-bold ${
variant === 'destructive' && value > 0 ? 'text-destructive' : ''
}${variant === 'blue' && value > 0 ? 'text-blue-600' : ''}`}>
{value}
</p>
</CardContent>
</Card>
);
}