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,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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user