Files
ArchiTools/src/modules/registratura/components/ac-validity-tracker.tsx
T
AI Assistant 3abf0d189c feat: Registratura thread explorer, AC validity tracker, interactive I/O toggle + Password Vault rework
Registratura improvements:
- Thread Explorer: new 'Fire conversatie' tab with timeline view, search, stats, gap tracking (la noi/la institutie), export to text report
- Interactive I/O toggle: replaced direction dropdown with visual blue/orange button group (Intrat/Iesit with icons)
- Doc type UX: alphabetical sort + immediate selection after adding custom type
- AC Validity Tracker: full Autorizatie de Construire lifecycle workflow (12mo validity, execution phases, extension request, required docs checklist, monthly reminders, abandonment/expiry tracking)

Password Vault rework (renamed to 'Parole Uzuale' v0.3.0):
- New categories: WiFi, Portale Primarii, Avize Online, PIN Semnatura, Software, Hardware (replaced server/database/api)
- Category icons (lucide-react) throughout list and form
- WiFi QR code dialog with connection string copy
- Context-aware form (PIN vs password label, hide email for WiFi/PIN, hide URL for WiFi, hide generator for PIN)
- Dynamic stat cards showing top 3 categories by count
- Removed encryption banner
- Updated i18n, flags, config
2026-02-28 16:33:36 +02:00

742 lines
28 KiB
TypeScript

"use client";
import { useState, useMemo } from "react";
import {
Shield,
Calendar,
AlertTriangle,
CheckCircle2,
Clock,
Bell,
BellOff,
ChevronDown,
ChevronRight,
FileText,
Building2,
Newspaper,
Construction,
Info,
XCircle,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Badge } from "@/shared/components/ui/badge";
import { Switch } from "@/shared/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { Textarea } from "@/shared/components/ui/textarea";
import type {
ACValidityTracking,
ACExecutionDuration,
ACPhase,
} from "../types";
import { addWorkingDays, addCalendarDays } from "../services/working-days";
import { cn } from "@/shared/lib/utils";
interface ACValidityTrackerProps {
value: ACValidityTracking | undefined;
onChange: (value: ACValidityTracking | undefined) => void;
/** The entry's date (used as default issuance date) */
entryDate: string;
}
const EXECUTION_LABELS: Record<ACExecutionDuration, string> = {
6: "6 luni",
12: "12 luni",
24: "24 luni",
36: "36 luni",
};
const PHASE_LABELS: Record<ACPhase, string> = {
validity: "Valabilitate AC",
execution: "Execuție lucrări",
extended: "Prelungit (+24 luni)",
abandoned: "Abandonat",
expired: "Expirat",
};
const PHASE_COLORS: Record<ACPhase, string> = {
validity: "bg-blue-500",
execution: "bg-green-500",
extended: "bg-amber-500",
abandoned: "bg-gray-500",
expired: "bg-red-500",
};
function createDefault(entryDate: string): ACValidityTracking {
return {
enabled: true,
issuanceDate: entryDate,
phase: "validity",
executionDuration: 12,
requiredDocs: {
cfNotation: false,
newspaperPublication: false,
sitePanel: false,
},
reminder: { snoozeCount: 0, dismissed: false },
extensionGranted: false,
abandonedDeclaration: false,
notes: [],
};
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("ro-RO", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return iso;
}
}
function daysBetween(d1: string, d2: string): number {
const a = new Date(d1);
const b = new Date(d2);
a.setHours(0, 0, 0, 0);
b.setHours(0, 0, 0, 0);
return Math.round((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24));
}
function monthsBetween(d1: string, d2: string): number {
const a = new Date(d1);
const b = new Date(d2);
return (
(b.getFullYear() - a.getFullYear()) * 12 + (b.getMonth() - a.getMonth())
);
}
export function ACValidityTracker({
value,
onChange,
entryDate,
}: ACValidityTrackerProps) {
const [expanded, setExpanded] = useState(!!value?.enabled);
const ac = value ?? createDefault(entryDate);
const handleToggle = (enabled: boolean) => {
if (enabled) {
const newAc = createDefault(entryDate);
onChange(newAc);
setExpanded(true);
} else {
onChange(undefined);
setExpanded(false);
}
};
const update = (changes: Partial<ACValidityTracking>) => {
onChange({ ...ac, ...changes });
};
const updateDocs = (changes: Partial<ACValidityTracking["requiredDocs"]>) => {
onChange({ ...ac, requiredDocs: { ...ac.requiredDocs, ...changes } });
};
// ── Computed dates ──
const computedData = useMemo(() => {
if (!ac.enabled || !ac.issuanceDate) return null;
const issuance = new Date(ac.issuanceDate);
const now = new Date();
now.setHours(0, 0, 0, 0);
const today = now.toISOString().slice(0, 10);
// 12-month validity period
const validityEnd = new Date(issuance);
validityEnd.setMonth(validityEnd.getMonth() + 12);
const validityEndStr = validityEnd.toISOString().slice(0, 10);
const daysToValidityEnd = daysBetween(today, validityEndStr);
const monthsToValidityEnd = monthsBetween(today, validityEndStr);
// Announcement deadline: 10 days before starting works (within 12-month window)
const announcementDeadline = addCalendarDays(validityEnd, -10);
const announcementDeadlineStr = announcementDeadline
.toISOString()
.slice(0, 10);
// Extension request deadline: 45 working days before AC expiry
const extensionRequestDeadline = addWorkingDays(validityEnd, -45);
const extensionRequestDeadlineStr = extensionRequestDeadline
.toISOString()
.slice(0, 10);
const daysToExtensionDeadline = daysBetween(
today,
extensionRequestDeadlineStr,
);
// Execution period end (if works started)
let executionEnd: string | null = null;
if (ac.worksStartDate) {
const start = new Date(ac.worksStartDate);
start.setMonth(start.getMonth() + ac.executionDuration);
executionEnd = start.toISOString().slice(0, 10);
}
// Extension end (if granted, +24 months from original end)
let extendedEnd: string | null = null;
if (ac.extensionGranted && executionEnd) {
const execEnd = new Date(executionEnd);
execEnd.setMonth(execEnd.getMonth() + 24);
extendedEnd = execEnd.toISOString().slice(0, 10);
}
// Are all required docs fulfilled?
const allDocsComplete =
ac.requiredDocs.cfNotation &&
ac.requiredDocs.newspaperPublication &&
ac.requiredDocs.sitePanel;
// Can the validity period be "closed" (works announced)?
const canAnnounce = allDocsComplete && !ac.worksAnnouncedDate;
// Extension prelungire deadline for execution (45 working days before exec end)
let execExtensionDeadline: string | null = null;
if (executionEnd) {
const d = addWorkingDays(new Date(executionEnd), -45);
execExtensionDeadline = d.toISOString().slice(0, 10);
}
return {
validityEndStr,
daysToValidityEnd,
monthsToValidityEnd,
announcementDeadlineStr,
extensionRequestDeadlineStr,
daysToExtensionDeadline,
executionEnd,
extendedEnd,
allDocsComplete,
canAnnounce,
execExtensionDeadline,
};
}, [ac]);
const isEnabled = !!value?.enabled;
return (
<div className="rounded-md border border-indigo-500/30 bg-indigo-500/5 p-3 space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-1.5 text-sm font-medium text-indigo-700 dark:text-indigo-300">
<Shield className="h-4 w-4" />
Valabilitate Autorizație de Construire (AC)
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-sm">
<p className="text-xs">
Urmărirea completă a ciclului de viață al AC: valabilitate 12
luni, anunțare lucrări, documente obligatorii, durată
execuție, prelungire. Conform Legii 50/1991 și normelor
aferente.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<Switch checked={isEnabled} onCheckedChange={handleToggle} />
</div>
{isEnabled && computedData && (
<>
{/* Phase badge + overview */}
<div className="flex items-center gap-2 flex-wrap">
<div
className={cn("h-2.5 w-2.5 rounded-full", PHASE_COLORS[ac.phase])}
/>
<Badge variant="outline" className="text-xs">
{PHASE_LABELS[ac.phase]}
</Badge>
{computedData.daysToValidityEnd > 0 && ac.phase === "validity" && (
<span
className={cn(
"text-xs",
computedData.daysToValidityEnd <= 30
? "text-red-600 dark:text-red-400 font-medium"
: computedData.daysToValidityEnd <= 90
? "text-amber-600 dark:text-amber-400"
: "text-muted-foreground",
)}
>
{computedData.daysToValidityEnd} zile rămase din valabilitate
</span>
)}
{computedData.daysToValidityEnd <= 0 &&
ac.phase === "validity" &&
!ac.worksAnnouncedDate && (
<span className="text-xs text-red-600 dark:text-red-400 font-medium">
Valabilitate expirată!
</span>
)}
</div>
{/* Issuance date + duration */}
<div className="grid gap-3 sm:grid-cols-3">
<div>
<Label className="text-xs">Data emitere AC</Label>
<Input
type="date"
value={ac.issuanceDate}
onChange={(e) => update({ issuanceDate: e.target.value })}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs">Valabilitate până la</Label>
<div className="mt-1 rounded border bg-muted/50 px-3 py-2 text-sm font-mono">
{formatDate(computedData.validityEndStr)}
</div>
</div>
<div>
<Label className="text-xs">Durată execuție</Label>
<Select
value={String(ac.executionDuration)}
onValueChange={(v) =>
update({
executionDuration: parseInt(v, 10) as ACExecutionDuration,
})
}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{([6, 12, 24, 36] as ACExecutionDuration[]).map((d) => (
<SelectItem key={d} value={String(d)}>
{EXECUTION_LABELS[d]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Step 1: Required documents for starting works */}
{ac.phase === "validity" && !ac.abandonedDeclaration && (
<div className="space-y-2">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1.5 text-xs font-medium text-indigo-700 dark:text-indigo-300"
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
Documente obligatorii pentru începere lucrări
</button>
{expanded && (
<div className="ml-4 space-y-2 rounded border bg-background/50 p-3">
<p className="text-[10px] text-muted-foreground mb-2">
Toate cele 3 documente sunt necesare pentru a putea anunța
începerea lucrărilor. Minim 10 zile înainte de începerea
lucrărilor, anunțați la Primărie și ISC.
</p>
{/* CF Notation */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Switch
checked={ac.requiredDocs.cfNotation}
onCheckedChange={(v) =>
updateDocs({
cfNotation: v,
cfNotationDate: v
? new Date().toISOString().slice(0, 10)
: undefined,
})
}
/>
<Label className="text-xs flex items-center gap-1.5 cursor-pointer">
<FileText className="h-3.5 w-3.5" />
Notare în Cartea Funciară (CF)
</Label>
</div>
{ac.requiredDocs.cfNotation && (
<Input
type="date"
value={ac.requiredDocs.cfNotationDate ?? ""}
onChange={(e) =>
updateDocs({ cfNotationDate: e.target.value })
}
className="w-[150px] text-xs h-7"
/>
)}
</div>
{/* Newspaper publication */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Switch
checked={ac.requiredDocs.newspaperPublication}
onCheckedChange={(v) =>
updateDocs({
newspaperPublication: v,
newspaperPublicationDate: v
? new Date().toISOString().slice(0, 10)
: undefined,
})
}
/>
<Label className="text-xs flex items-center gap-1.5 cursor-pointer">
<Newspaper className="h-3.5 w-3.5" />
Publicare în ziar
</Label>
</div>
{ac.requiredDocs.newspaperPublication && (
<Input
type="date"
value={ac.requiredDocs.newspaperPublicationDate ?? ""}
onChange={(e) =>
updateDocs({
newspaperPublicationDate: e.target.value,
})
}
className="w-[150px] text-xs h-7"
/>
)}
</div>
{/* Site panel */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Switch
checked={ac.requiredDocs.sitePanel}
onCheckedChange={(v) =>
updateDocs({
sitePanel: v,
sitePanelDate: v
? new Date().toISOString().slice(0, 10)
: undefined,
})
}
/>
<Label className="text-xs flex items-center gap-1.5 cursor-pointer">
<Construction className="h-3.5 w-3.5" />
Afișare panou de șantier
</Label>
</div>
{ac.requiredDocs.sitePanel && (
<Input
type="date"
value={ac.requiredDocs.sitePanelDate ?? ""}
onChange={(e) =>
updateDocs({ sitePanelDate: e.target.value })
}
className="w-[150px] text-xs h-7"
/>
)}
</div>
{/* Progress indicator */}
<div className="flex items-center gap-2 mt-2">
<div className="h-1.5 flex-1 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-indigo-500 transition-all"
style={{
width: `${
(((ac.requiredDocs.cfNotation ? 1 : 0) +
(ac.requiredDocs.newspaperPublication ? 1 : 0) +
(ac.requiredDocs.sitePanel ? 1 : 0)) /
3) *
100
}%`,
}}
/>
</div>
<span className="text-[10px] text-muted-foreground">
{(ac.requiredDocs.cfNotation ? 1 : 0) +
(ac.requiredDocs.newspaperPublication ? 1 : 0) +
(ac.requiredDocs.sitePanel ? 1 : 0)}
/3
</span>
</div>
</div>
)}
</div>
)}
{/* Step 2: Announce works (when all docs are ready) */}
{ac.phase === "validity" &&
computedData.allDocsComplete &&
!ac.worksAnnouncedDate &&
!ac.abandonedDeclaration && (
<div className="rounded border border-green-500/30 bg-green-500/5 p-3 space-y-2">
<p className="text-xs font-medium text-green-700 dark:text-green-300 flex items-center gap-1.5">
<CheckCircle2 className="h-4 w-4" />
Documente complete! Puteți anunța începerea lucrărilor.
</p>
<p className="text-[10px] text-muted-foreground">
Anunțați la Primărie și ISC cu minim 10 zile înainte de
începerea lucrărilor. Termen recomandat anunțare:{" "}
{formatDate(computedData.announcementDeadlineStr)}
</p>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label className="text-xs">Data anunțare lucrări</Label>
<Input
type="date"
value={ac.worksAnnouncedDate ?? ""}
onChange={(e) => {
if (e.target.value) {
update({
worksAnnouncedDate: e.target.value,
worksStartDate: e.target.value,
phase: "execution",
});
}
}}
className="mt-1"
/>
</div>
</div>
</div>
)}
{/* Works announced — execution phase */}
{(ac.phase === "execution" || ac.phase === "extended") &&
ac.worksStartDate &&
computedData.executionEnd && (
<div className="rounded border border-green-500/30 bg-green-500/5 p-3 space-y-2">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-xs font-medium text-green-700 dark:text-green-300">
Lucrări în execuție
</span>
</div>
<div className="grid gap-3 sm:grid-cols-3 text-xs">
<div>
<span className="text-muted-foreground">Început:</span>
<p className="font-mono">{formatDate(ac.worksStartDate)}</p>
</div>
<div>
<span className="text-muted-foreground">Durată:</span>
<p className="font-medium">
{ac.executionDuration} luni
{ac.phase === "extended" ? " + 24 luni" : ""}
</p>
</div>
<div>
<span className="text-muted-foreground">
Termen execuție:
</span>
<p className="font-mono">
{formatDate(
ac.phase === "extended" && computedData.extendedEnd
? computedData.extendedEnd
: computedData.executionEnd,
)}
</p>
</div>
</div>
{/* Extension request reminder */}
{computedData.execExtensionDeadline &&
!ac.extensionGranted &&
ac.phase !== "extended" && (
<div className="rounded border border-amber-500/30 bg-amber-500/5 p-2 text-[10px]">
<p className="text-amber-700 dark:text-amber-400 flex items-center gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
Cererea de prelungire trebuie depusă cu cel puțin 45
zile lucrătoare înainte de expirare. Termen limită:{" "}
<strong>
{formatDate(computedData.execExtensionDeadline)}
</strong>
</p>
<p className="mt-1 text-muted-foreground">
Prelungirea se acordă o singură dată, gratuit, pentru
max 24 luni. Se înscrie pe originalul AC fără
documentație nouă. Decizia vine în max 15 zile
lucrătoare de la depunere.
</p>
{!ac.extensionRequestDate && (
<div className="mt-2 flex items-center gap-2">
<Label className="text-[10px]">
Data cerere prelungire:
</Label>
<Input
type="date"
value=""
onChange={(e) => {
if (e.target.value) {
update({
extensionRequestDate: e.target.value,
});
}
}}
className="w-[140px] text-[10px] h-6"
/>
</div>
)}
{ac.extensionRequestDate && !ac.extensionGranted && (
<div className="mt-2 flex items-center gap-2">
<span className="text-green-600 text-[10px]">
Cerere depusă la{" "}
{formatDate(ac.extensionRequestDate)}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-6 text-[10px] px-2"
onClick={() =>
update({
extensionGranted: true,
extensionGrantedDate: new Date()
.toISOString()
.slice(0, 10),
phase: "extended",
})
}
>
Prelungire acordată
</Button>
</div>
)}
</div>
)}
</div>
)}
{/* Reminders section */}
{ac.phase === "validity" &&
!ac.worksAnnouncedDate &&
!ac.abandonedDeclaration && (
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
{ac.reminder.dismissed ? (
<BellOff className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<Bell className="h-3.5 w-3.5 text-amber-500" />
)}
<span className="text-muted-foreground">
{ac.reminder.dismissed
? "Remindere dezactivate"
: `Reminder lunar activ (luna ${computedData.monthsToValidityEnd > 0 ? 12 - computedData.monthsToValidityEnd : 12}/12)`}
</span>
</div>
<div className="flex gap-1">
{!ac.reminder.dismissed &&
computedData.monthsToValidityEnd >= 2 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 text-[10px] px-2"
onClick={() =>
update({
reminder: {
...ac.reminder,
snoozeCount: ac.reminder.snoozeCount + 1,
lastSnoozed: new Date().toISOString(),
},
})
}
>
<Clock className="mr-1 h-3 w-3" /> Amână 1 lună
</Button>
)}
</div>
</div>
)}
{/* Abandon option (after month 10 or when snooze limit reached) */}
{ac.phase === "validity" &&
!ac.worksAnnouncedDate &&
!ac.abandonedDeclaration && (
<div className="border-t pt-3 space-y-2">
<p className="text-[10px] text-muted-foreground">
Dacă nu se mai dorește începerea construcției:
</p>
<div className="flex items-center gap-2">
<Switch
checked={ac.abandonedDeclaration}
onCheckedChange={(v) => {
if (v) {
update({
abandonedDeclaration: true,
abandonedDate: new Date().toISOString().slice(0, 10),
phase: "abandoned",
});
}
}}
/>
<Label className="text-xs cursor-pointer flex items-center gap-1.5">
<XCircle className="h-3.5 w-3.5 text-muted-foreground" />
Declar nu se mai dorește începerea construcției
</Label>
</div>
</div>
)}
{/* Abandoned state */}
{ac.abandonedDeclaration && (
<div className="rounded border border-gray-500/30 bg-gray-500/5 p-3 text-xs space-y-2">
<p className="font-medium text-muted-foreground flex items-center gap-1.5">
<XCircle className="h-4 w-4" />
Construcția a fost declarată abandonată
</p>
{ac.abandonedDate && (
<p className="text-[10px] text-muted-foreground">
Data declarației: {formatDate(ac.abandonedDate)}
</p>
)}
<Textarea
value={ac.abandonedReason ?? ""}
onChange={(e) => update({ abandonedReason: e.target.value })}
placeholder="Motiv (opțional)..."
rows={2}
className="text-xs"
/>
</div>
)}
{/* Validity extension option (within validity phase) */}
{ac.phase === "validity" &&
!ac.worksAnnouncedDate &&
!ac.abandonedDeclaration &&
computedData.daysToExtensionDeadline <= 90 && (
<div className="rounded border border-amber-500/30 bg-amber-500/5 p-3 space-y-2">
<p className="text-xs text-amber-700 dark:text-amber-300 flex items-center gap-1.5">
<AlertTriangle className="h-4 w-4 shrink-0" />
Opțiune: Prelungire valabilitate AC cu 24 luni
</p>
<p className="text-[10px] text-muted-foreground">
Termen limită depunere cerere:{" "}
<strong>
{formatDate(computedData.extensionRequestDeadlineStr)}
</strong>{" "}
(45 zile lucrătoare înainte de expirare). Atenție: este mai
avantajos declarați începerea lucrărilor și cereți
prelungirea duratei de execuție astfel aveți mai mult timp
total.
</p>
</div>
)}
</>
)}
</div>
);
}