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:
@@ -0,0 +1,220 @@
|
||||
import type { DeadlineTypeDef, DeadlineCategory } from '../types';
|
||||
|
||||
export const DEADLINE_CATALOG: DeadlineTypeDef[] = [
|
||||
// ── Avize ──
|
||||
{
|
||||
id: 'cerere-cu',
|
||||
label: 'Cerere CU',
|
||||
description: 'Termen de emitere a Certificatului de Urbanism de la data depunerii cererii.',
|
||||
days: 15,
|
||||
dayType: 'calendar',
|
||||
startDateLabel: 'Data depunerii',
|
||||
requiresCustomStartDate: false,
|
||||
tacitApprovalApplicable: true,
|
||||
category: 'avize',
|
||||
legalReference: 'Legea 50/1991, art. 6¹',
|
||||
},
|
||||
{
|
||||
id: 'avize-normale',
|
||||
label: 'Cerere Avize normale',
|
||||
description: 'Termen de emitere a avizelor de la data depunerii cererii.',
|
||||
days: 15,
|
||||
dayType: 'calendar',
|
||||
startDateLabel: 'Data depunerii',
|
||||
requiresCustomStartDate: false,
|
||||
tacitApprovalApplicable: true,
|
||||
category: 'avize',
|
||||
},
|
||||
{
|
||||
id: 'aviz-cultura',
|
||||
label: 'Aviz Cultură',
|
||||
description: 'Termen de emitere a avizului Ministerului Culturii de la data comisiei.',
|
||||
days: 30,
|
||||
dayType: 'calendar',
|
||||
startDateLabel: 'Data comisie',
|
||||
requiresCustomStartDate: true,
|
||||
startDateHint: 'Data ședinței comisiei de specialitate',
|
||||
tacitApprovalApplicable: true,
|
||||
category: 'avize',
|
||||
},
|
||||
{
|
||||
id: 'aviz-mediu',
|
||||
label: 'Aviz Mediu',
|
||||
description: 'Termen de emitere a avizului de mediu de la finalizarea procedurilor.',
|
||||
days: 15,
|
||||
dayType: 'calendar',
|
||||
startDateLabel: 'Data finalizare proceduri',
|
||||
requiresCustomStartDate: true,
|
||||
startDateHint: 'Data finalizării procedurii de evaluare de mediu',
|
||||
tacitApprovalApplicable: true,
|
||||
category: 'avize',
|
||||
},
|
||||
{
|
||||
id: 'aviz-aeronautica',
|
||||
label: 'Aviz Aeronautică',
|
||||
description: 'Termen de emitere a avizului de la Autoritatea Aeronautică.',
|
||||
days: 30,
|
||||
dayType: 'calendar',
|
||||
startDateLabel: 'Data depunerii',
|
||||
requiresCustomStartDate: false,
|
||||
tacitApprovalApplicable: true,
|
||||
category: 'avize',
|
||||
},
|
||||
|
||||
// ── Completări ──
|
||||
{
|
||||
id: 'completare-beneficiar',
|
||||
label: 'Completare — termen beneficiar',
|
||||
description: 'Termen acordat beneficiarului pentru completarea documentației.',
|
||||
days: 60,
|
||||
dayType: 'calendar',
|
||||
startDateLabel: 'Data notificării',
|
||||
requiresCustomStartDate: false,
|
||||
tacitApprovalApplicable: false,
|
||||
chainNextTypeId: 'completare-emitere',
|
||||
chainNextActionLabel: 'Adaugă termen emitere (15 zile)',
|
||||
category: 'completari',
|
||||
},
|
||||
{
|
||||
id: 'completare-emitere',
|
||||
label: 'Completare — termen emitere',
|
||||
description: 'Termen de emitere după depunerea completărilor.',
|
||||
days: 15,
|
||||
dayType: 'calendar',
|
||||
startDateLabel: 'Data depunere completări',
|
||||
requiresCustomStartDate: true,
|
||||
startDateHint: 'Data la care beneficiarul a depus completările',
|
||||
tacitApprovalApplicable: true,
|
||||
category: 'completari',
|
||||
},
|
||||
|
||||
// ── Analiză ──
|
||||
{
|
||||
id: 'ctatu-analiza',
|
||||
label: 'Analiză CTATU',
|
||||
description: 'Termen de analiză în Comisia Tehnică de Amenajare a Teritoriului și Urbanism.',
|
||||
days: 30,
|
||||
dayType: 'calendar',
|
||||
startDateLabel: 'Data depunerii',
|
||||
requiresCustomStartDate: false,
|
||||
tacitApprovalApplicable: false,
|
||||
category: 'analiza',
|
||||
},
|
||||
{
|
||||
id: 'consiliu-promovare',
|
||||
label: 'Promovare Consiliu Local',
|
||||
description: 'Termen de promovare în ședința Consiliului Local.',
|
||||
days: 30,
|
||||
dayType: 'calendar',
|
||||
startDateLabel: 'Data depunerii',
|
||||
requiresCustomStartDate: false,
|
||||
tacitApprovalApplicable: false,
|
||||
category: 'analiza',
|
||||
},
|
||||
{
|
||||
id: 'consiliu-vot',
|
||||
label: 'Vot Consiliu Local',
|
||||
description: 'Termen de vot în Consiliu Local de la finalizarea dezbaterii publice.',
|
||||
days: 45,
|
||||
dayType: 'calendar',
|
||||
startDateLabel: 'Data finalizare dezbatere',
|
||||
requiresCustomStartDate: true,
|
||||
startDateHint: 'Data finalizării dezbaterii publice',
|
||||
tacitApprovalApplicable: false,
|
||||
category: 'analiza',
|
||||
},
|
||||
|
||||
// ── Autorizare ──
|
||||
{
|
||||
id: 'verificare-ac',
|
||||
label: 'Verificare AC',
|
||||
description: 'Termen de verificare a documentației pentru Autorizația de Construire.',
|
||||
days: 5,
|
||||
dayType: 'working',
|
||||
startDateLabel: 'Data depunerii',
|
||||
requiresCustomStartDate: false,
|
||||
tacitApprovalApplicable: false,
|
||||
category: 'autorizare',
|
||||
},
|
||||
{
|
||||
id: 'prelungire-ac',
|
||||
label: 'Cerere prelungire AC',
|
||||
description: 'Cererea de prelungire trebuie depusă cu minim 45 zile lucrătoare ÎNAINTE de expirarea AC.',
|
||||
days: 45,
|
||||
dayType: 'working',
|
||||
startDateLabel: 'Data expirare AC',
|
||||
requiresCustomStartDate: true,
|
||||
startDateHint: 'Data de expirare a Autorizației de Construire',
|
||||
tacitApprovalApplicable: false,
|
||||
category: 'autorizare',
|
||||
isBackwardDeadline: true,
|
||||
},
|
||||
{
|
||||
id: 'prelungire-ac-comunicare',
|
||||
label: 'Comunicare decizie prelungire',
|
||||
description: 'Termen de comunicare a deciziei privind prelungirea AC.',
|
||||
days: 15,
|
||||
dayType: 'working',
|
||||
startDateLabel: 'Data depunere cerere',
|
||||
requiresCustomStartDate: true,
|
||||
startDateHint: 'Data depunerii cererii de prelungire',
|
||||
tacitApprovalApplicable: false,
|
||||
category: 'autorizare',
|
||||
},
|
||||
|
||||
// ── Publicitate ──
|
||||
{
|
||||
id: 'publicitate-ac',
|
||||
label: 'Publicitate AC',
|
||||
description: 'Termen de publicitate a Autorizației de Construire.',
|
||||
days: 30,
|
||||
dayType: 'calendar',
|
||||
startDateLabel: 'Data emitere AC',
|
||||
requiresCustomStartDate: true,
|
||||
startDateHint: 'Data emiterii Autorizației de Construire',
|
||||
tacitApprovalApplicable: false,
|
||||
category: 'publicitate',
|
||||
},
|
||||
{
|
||||
id: 'plangere-prealabila',
|
||||
label: 'Plângere prealabilă',
|
||||
description: 'Termen de depunere a plângerii prealabile.',
|
||||
days: 30,
|
||||
dayType: 'calendar',
|
||||
startDateLabel: 'Data ultimă publicitate',
|
||||
requiresCustomStartDate: true,
|
||||
startDateHint: 'Data ultimei publicități / aduceri la cunoștință',
|
||||
tacitApprovalApplicable: false,
|
||||
chainNextTypeId: 'contestare-instanta',
|
||||
chainNextActionLabel: 'Adaugă termen contestare instanță (60 zile)',
|
||||
category: 'publicitate',
|
||||
},
|
||||
{
|
||||
id: 'contestare-instanta',
|
||||
label: 'Contestare în instanță',
|
||||
description: 'Termen de contestare în instanța de contencios administrativ.',
|
||||
days: 60,
|
||||
dayType: 'calendar',
|
||||
startDateLabel: 'Data răspuns plângere',
|
||||
requiresCustomStartDate: true,
|
||||
startDateHint: 'Data primirii răspunsului la plângerea prealabilă',
|
||||
tacitApprovalApplicable: false,
|
||||
category: 'publicitate',
|
||||
},
|
||||
];
|
||||
|
||||
export const CATEGORY_LABELS: Record<DeadlineCategory, string> = {
|
||||
avize: 'Avize',
|
||||
completari: 'Completări',
|
||||
analiza: 'Analiză',
|
||||
autorizare: 'Autorizare',
|
||||
publicitate: 'Publicitate',
|
||||
};
|
||||
|
||||
export function getDeadlineType(typeId: string): DeadlineTypeDef | undefined {
|
||||
return DEADLINE_CATALOG.find((d) => d.id === typeId);
|
||||
}
|
||||
|
||||
export function getDeadlinesByCategory(category: DeadlineCategory): DeadlineTypeDef[] {
|
||||
return DEADLINE_CATALOG.filter((d) => d.category === category);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { TrackedDeadline, DeadlineResolution, RegistryEntry } from '../types';
|
||||
import { getDeadlineType } from './deadline-catalog';
|
||||
import { computeDueDate } from './working-days';
|
||||
|
||||
export interface DeadlineDisplayStatus {
|
||||
label: string;
|
||||
variant: 'green' | 'yellow' | 'red' | 'blue' | 'gray';
|
||||
daysRemaining: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tracked deadline from a type definition + start date.
|
||||
*/
|
||||
export function createTrackedDeadline(
|
||||
typeId: string,
|
||||
startDate: string,
|
||||
chainParentId?: string,
|
||||
): TrackedDeadline | null {
|
||||
const def = getDeadlineType(typeId);
|
||||
if (!def) return null;
|
||||
|
||||
const start = new Date(startDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
|
||||
const due = computeDueDate(start, def.days, def.dayType, def.isBackwardDeadline);
|
||||
|
||||
return {
|
||||
id: uuid(),
|
||||
typeId,
|
||||
startDate,
|
||||
dueDate: formatDate(due),
|
||||
resolution: 'pending',
|
||||
chainParentId,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a deadline with a given resolution.
|
||||
*/
|
||||
export function resolveDeadline(
|
||||
deadline: TrackedDeadline,
|
||||
resolution: DeadlineResolution,
|
||||
note?: string,
|
||||
): TrackedDeadline {
|
||||
return {
|
||||
...deadline,
|
||||
resolution,
|
||||
resolvedDate: new Date().toISOString(),
|
||||
resolutionNote: note,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display status for a tracked deadline — color coding + label.
|
||||
*/
|
||||
export function getDeadlineDisplayStatus(deadline: TrackedDeadline): DeadlineDisplayStatus {
|
||||
const def = getDeadlineType(deadline.typeId);
|
||||
|
||||
// Already resolved
|
||||
if (deadline.resolution !== 'pending') {
|
||||
if (deadline.resolution === 'aprobat-tacit') {
|
||||
return { label: 'Aprobat tacit', variant: 'blue', daysRemaining: null };
|
||||
}
|
||||
if (deadline.resolution === 'respins') {
|
||||
return { label: 'Respins', variant: 'gray', daysRemaining: null };
|
||||
}
|
||||
if (deadline.resolution === 'anulat') {
|
||||
return { label: 'Anulat', variant: 'gray', daysRemaining: null };
|
||||
}
|
||||
return { label: 'Finalizat', variant: 'gray', daysRemaining: null };
|
||||
}
|
||||
|
||||
// Pending — compute days remaining
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
const due = new Date(deadline.dueDate);
|
||||
due.setHours(0, 0, 0, 0);
|
||||
|
||||
const diff = due.getTime() - now.getTime();
|
||||
const daysRemaining = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Overdue + tacit applicable → tacit approval
|
||||
if (daysRemaining < 0 && def?.tacitApprovalApplicable) {
|
||||
return { label: 'Aprobat tacit', variant: 'blue', daysRemaining };
|
||||
}
|
||||
|
||||
if (daysRemaining < 0) {
|
||||
return { label: 'Depășit termen', variant: 'red', daysRemaining };
|
||||
}
|
||||
if (daysRemaining <= 5) {
|
||||
return { label: 'Urgent', variant: 'yellow', daysRemaining };
|
||||
}
|
||||
return { label: 'În termen', variant: 'green', daysRemaining };
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate deadline stats across all entries.
|
||||
*/
|
||||
export function aggregateDeadlines(entries: RegistryEntry[]): {
|
||||
active: number;
|
||||
urgent: number;
|
||||
overdue: number;
|
||||
tacit: number;
|
||||
all: Array<{ deadline: TrackedDeadline; entry: RegistryEntry; status: DeadlineDisplayStatus }>;
|
||||
} {
|
||||
let active = 0;
|
||||
let urgent = 0;
|
||||
let overdue = 0;
|
||||
let tacit = 0;
|
||||
const all: Array<{ deadline: TrackedDeadline; entry: RegistryEntry; status: DeadlineDisplayStatus }> = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
for (const dl of entry.trackedDeadlines ?? []) {
|
||||
const status = getDeadlineDisplayStatus(dl);
|
||||
all.push({ deadline: dl, entry, status });
|
||||
|
||||
if (dl.resolution === 'pending') {
|
||||
active++;
|
||||
if (status.variant === 'yellow') urgent++;
|
||||
if (status.variant === 'red') overdue++;
|
||||
if (status.variant === 'blue') tacit++;
|
||||
} else if (dl.resolution === 'aprobat-tacit') {
|
||||
tacit++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: overdue first, then by due date ascending
|
||||
all.sort((a, b) => {
|
||||
const aP = a.deadline.resolution === 'pending' ? 0 : 1;
|
||||
const bP = b.deadline.resolution === 'pending' ? 0 : 1;
|
||||
if (aP !== bP) return aP - bP;
|
||||
return a.deadline.dueDate.localeCompare(b.deadline.dueDate);
|
||||
});
|
||||
|
||||
return { active, urgent, overdue, tacit, all };
|
||||
}
|
||||
|
||||
function formatDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Romanian working-day arithmetic.
|
||||
*
|
||||
* Fixed public holidays + Orthodox Easter-derived moveable feasts.
|
||||
* Uses the Meeus algorithm for Orthodox Easter computation.
|
||||
*/
|
||||
|
||||
// ── Fixed Romanian public holidays (month is 0-indexed) ──
|
||||
|
||||
interface FixedHoliday {
|
||||
month: number;
|
||||
day: number;
|
||||
}
|
||||
|
||||
const FIXED_HOLIDAYS: FixedHoliday[] = [
|
||||
{ month: 0, day: 1 }, // Anul Nou
|
||||
{ month: 0, day: 2 }, // Anul Nou (zi 2)
|
||||
{ month: 0, day: 24 }, // Ziua Unirii
|
||||
{ month: 4, day: 1 }, // Ziua Muncii
|
||||
{ month: 5, day: 1 }, // Ziua Copilului
|
||||
{ month: 7, day: 15 }, // Adormirea Maicii Domnului
|
||||
{ month: 10, day: 30 }, // Sfântul Andrei
|
||||
{ month: 11, day: 1 }, // Ziua Națională
|
||||
{ month: 11, day: 25 }, // Crăciunul
|
||||
{ month: 11, day: 26 }, // Crăciunul (zi 2)
|
||||
];
|
||||
|
||||
// ── Orthodox Easter via Meeus algorithm ──
|
||||
|
||||
function orthodoxEaster(year: number): Date {
|
||||
const a = year % 4;
|
||||
const b = year % 7;
|
||||
const c = year % 19;
|
||||
const d = (19 * c + 15) % 30;
|
||||
const e = (2 * a + 4 * b - d + 34) % 7;
|
||||
const month = Math.floor((d + e + 114) / 31); // 3 = March, 4 = April
|
||||
const day = ((d + e + 114) % 31) + 1;
|
||||
|
||||
// Julian date — convert to Gregorian by adding 13 days (valid 1900–2099)
|
||||
const julian = new Date(year, month - 1, day);
|
||||
julian.setDate(julian.getDate() + 13);
|
||||
return julian;
|
||||
}
|
||||
|
||||
function getMovableHolidays(year: number): Date[] {
|
||||
const easter = orthodoxEaster(year);
|
||||
|
||||
const goodFriday = new Date(easter);
|
||||
goodFriday.setDate(easter.getDate() - 2);
|
||||
|
||||
const easterMonday = new Date(easter);
|
||||
easterMonday.setDate(easter.getDate() + 1);
|
||||
|
||||
const rusaliiSunday = new Date(easter);
|
||||
rusaliiSunday.setDate(easter.getDate() + 49);
|
||||
|
||||
const rusaliiMonday = new Date(easter);
|
||||
rusaliiMonday.setDate(easter.getDate() + 50);
|
||||
|
||||
return [goodFriday, easter, easterMonday, rusaliiSunday, rusaliiMonday];
|
||||
}
|
||||
|
||||
// ── Holiday cache per year ──
|
||||
|
||||
const holidayCache = new Map<number, Set<string>>();
|
||||
|
||||
function toKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getHolidaySet(year: number): Set<string> {
|
||||
const cached = holidayCache.get(year);
|
||||
if (cached) return cached;
|
||||
|
||||
const set = new Set<string>();
|
||||
|
||||
for (const h of FIXED_HOLIDAYS) {
|
||||
set.add(toKey(new Date(year, h.month, h.day)));
|
||||
}
|
||||
|
||||
for (const d of getMovableHolidays(year)) {
|
||||
set.add(toKey(d));
|
||||
}
|
||||
|
||||
holidayCache.set(year, set);
|
||||
return set;
|
||||
}
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
export function isPublicHoliday(date: Date): boolean {
|
||||
return getHolidaySet(date.getFullYear()).has(toKey(date));
|
||||
}
|
||||
|
||||
export function isWeekend(date: Date): boolean {
|
||||
const day = date.getDay();
|
||||
return day === 0 || day === 6;
|
||||
}
|
||||
|
||||
export function isWorkingDay(date: Date): boolean {
|
||||
return !isWeekend(date) && !isPublicHoliday(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add calendar days (simply skips no days).
|
||||
* Supports negative values.
|
||||
*/
|
||||
export function addCalendarDays(start: Date, days: number): Date {
|
||||
const result = new Date(start);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add working days, skipping weekends and Romanian public holidays.
|
||||
* Supports negative values (for backward deadlines).
|
||||
*/
|
||||
export function addWorkingDays(start: Date, days: number): Date {
|
||||
const result = new Date(start);
|
||||
const direction = days >= 0 ? 1 : -1;
|
||||
let remaining = Math.abs(days);
|
||||
|
||||
while (remaining > 0) {
|
||||
result.setDate(result.getDate() + direction);
|
||||
if (isWorkingDay(result)) {
|
||||
remaining--;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the due date for a deadline definition.
|
||||
*/
|
||||
export function computeDueDate(
|
||||
startDate: Date,
|
||||
days: number,
|
||||
dayType: 'calendar' | 'working',
|
||||
isBackward?: boolean,
|
||||
): Date {
|
||||
const effectiveDays = isBackward ? -days : days;
|
||||
return dayType === 'working'
|
||||
? addWorkingDays(startDate, effectiveDays)
|
||||
: addCalendarDays(startDate, effectiveDays);
|
||||
}
|
||||
Reference in New Issue
Block a user