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:
160
src/modules/registratura/components/deadline-dashboard.tsx
Normal file
160
src/modules/registratura/components/deadline-dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user