3.03 Registratura Termene Legale recipient registration, audit log, expiry tracking

- Added recipientRegNumber/recipientRegDate fields for outgoing docs (deadline triggers from recipient registration date)
- Added prelungire-CU deadline type in catalog (15 calendar days, tacit approval)
- CU category already first in catalog  verified
- DeadlineAuditEntry interface + audit log on TrackedDeadline (created/resolved entries)
- Document expiry tracking: expiryDate + expiryAlertDays with live countdown
- Web scraping prep fields: externalStatusUrl + externalTrackingId
- Dashboard: 6 stat cards (added missing recipient + expiring soon)
- Alert banners for missing recipient data and expiring documents
- Version bump to 0.2.0
This commit is contained in:
AI Assistant
2026-02-28 04:31:32 +02:00
parent 85bdb59da4
commit 99fbdddb68
9 changed files with 649 additions and 150 deletions
@@ -1,12 +1,13 @@
'use client';
"use client";
import { Clock, CheckCircle2, X } from 'lucide-react';
import { Badge } from '@/shared/components/ui/badge';
import { Button } from '@/shared/components/ui/button';
import type { TrackedDeadline } from '../types';
import { getDeadlineType } from '../services/deadline-catalog';
import { getDeadlineDisplayStatus } from '../services/deadline-service';
import { cn } from '@/shared/lib/utils';
import { useState } from "react";
import { Clock, CheckCircle2, X, History } from "lucide-react";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import type { TrackedDeadline } from "../types";
import { getDeadlineType } from "../services/deadline-catalog";
import { getDeadlineDisplayStatus } from "../services/deadline-service";
import { cn } from "@/shared/lib/utils";
interface DeadlineCardProps {
deadline: TrackedDeadline;
@@ -15,78 +16,156 @@ interface DeadlineCardProps {
}
const VARIANT_CLASSES: Record<string, string> = {
green: 'border-green-500/30 bg-green-50 dark:bg-green-950/20',
yellow: 'border-yellow-500/30 bg-yellow-50 dark:bg-yellow-950/20',
red: 'border-red-500/30 bg-red-50 dark:bg-red-950/20',
blue: 'border-blue-500/30 bg-blue-50 dark:bg-blue-950/20',
gray: 'border-muted bg-muted/30',
green: "border-green-500/30 bg-green-50 dark:bg-green-950/20",
yellow: "border-yellow-500/30 bg-yellow-50 dark:bg-yellow-950/20",
red: "border-red-500/30 bg-red-50 dark:bg-red-950/20",
blue: "border-blue-500/30 bg-blue-50 dark:bg-blue-950/20",
gray: "border-muted bg-muted/30",
};
const BADGE_CLASSES: Record<string, string> = {
green: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
yellow: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
red: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
blue: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
gray: 'bg-muted text-muted-foreground',
green: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
yellow:
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
red: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
blue: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
gray: "bg-muted text-muted-foreground",
};
export function DeadlineCard({ deadline, onResolve, onRemove }: DeadlineCardProps) {
export function DeadlineCard({
deadline,
onResolve,
onRemove,
}: DeadlineCardProps) {
const def = getDeadlineType(deadline.typeId);
const status = getDeadlineDisplayStatus(deadline);
const [showAudit, setShowAudit] = useState(false);
const auditLog = deadline.auditLog ?? [];
return (
<div className={cn('flex items-center gap-3 rounded-lg border p-3', VARIANT_CLASSES[status.variant] ?? '')}>
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{def?.label ?? deadline.typeId}</span>
<Badge className={cn('text-[10px] border-0', BADGE_CLASSES[status.variant] ?? '')}>
{status.label}
{status.daysRemaining !== null && status.variant !== 'blue' && (
<span className="ml-1">
({status.daysRemaining < 0 ? `${Math.abs(status.daysRemaining)}z depășit` : `${status.daysRemaining}z`})
</span>
<div
className={cn(
"rounded-lg border p-3",
VARIANT_CLASSES[status.variant] ?? "",
)}
>
<div className="flex items-center gap-3">
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">
{def?.label ?? deadline.typeId}
</span>
<Badge
className={cn(
"text-[10px] border-0",
BADGE_CLASSES[status.variant] ?? "",
)}
>
{status.label}
{status.daysRemaining !== null && status.variant !== "blue" && (
<span className="ml-1">
(
{status.daysRemaining < 0
? `${Math.abs(status.daysRemaining)}z depășit`
: `${status.daysRemaining}z`}
)
</span>
)}
</Badge>
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{def?.isBackwardDeadline ? "Termen limită" : "Start"}:{" "}
{formatDate(deadline.startDate)}
{" → "}
{def?.isBackwardDeadline ? "Depunere până la" : "Termen"}:{" "}
{formatDate(deadline.dueDate)}
{def?.dayType === "working" && (
<span className="ml-1">(zile lucrătoare)</span>
)}
</Badge>
</div>
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{def?.isBackwardDeadline ? 'Termen limită' : 'Start'}: {formatDate(deadline.startDate)}
{' → '}
{def?.isBackwardDeadline ? 'Depunere până la' : 'Termen'}: {formatDate(deadline.dueDate)}
{def?.dayType === 'working' && <span className="ml-1">(zile lucrătoare)</span>}
</div>
</div>
<div className="flex gap-1 shrink-0">
{deadline.resolution === 'pending' && (
<div className="flex gap-1 shrink-0">
{auditLog.length > 0 && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
onClick={() => setShowAudit(!showAudit)}
title="Istoric modificări"
>
<History className="h-3.5 w-3.5" />
</Button>
)}
{deadline.resolution === "pending" && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-green-600"
onClick={() => onResolve(deadline)}
title="Rezolvă"
>
<CheckCircle2 className="h-3.5 w-3.5" />
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-green-600"
onClick={() => onResolve(deadline)}
title="Rezolvă"
className="h-7 w-7 text-destructive"
onClick={() => onRemove(deadline.id)}
title="Șterge"
>
<CheckCircle2 className="h-3.5 w-3.5" />
<X className="h-3.5 w-3.5" />
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => onRemove(deadline.id)}
title="Șterge"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* Audit log */}
{showAudit && auditLog.length > 0 && (
<div className="mt-2 border-t pt-2 space-y-1">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
Istoric modificări
</p>
{auditLog.map((entry, i) => (
<div key={i} className="flex items-start gap-2 text-[11px]">
<span className="text-muted-foreground whitespace-nowrap">
{formatDateTime(entry.timestamp)}
</span>
{entry.actor && (
<span className="font-medium">{entry.actor}</span>
)}
<span className="text-muted-foreground">{entry.detail}</span>
</div>
))}
</div>
)}
</div>
);
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
return new Date(iso).toLocaleDateString("ro-RO", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return iso;
}
}
function formatDateTime(iso: string): string {
try {
return new Date(iso).toLocaleString("ro-RO", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
@@ -1,55 +1,83 @@
'use client';
"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';
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;
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',
pending: "În așteptare",
completed: "Finalizat",
"aprobat-tacit": "Aprobat tacit",
respins: "Respins",
anulat: "Anulat",
};
export function DeadlineDashboard({ entries, onResolveDeadline, onAddChainedDeadline }: DeadlineDashboardProps) {
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 [resolvingDeadline, setResolvingDeadline] =
useState<TrackedDeadline | null>(null);
const stats = useMemo(() => aggregateDeadlines(entries), [entries]);
const filteredRows = useMemo(() => {
return stats.all.filter((row) => {
if (filters.category !== 'all') {
if (filters.category !== "all") {
const def = getDeadlineType(row.deadline.typeId);
if (def && def.category !== filters.category) return false;
}
if (filters.resolution !== 'all') {
if (filters.resolution !== "all") {
// Map tacit display status to actual resolution filter
if (filters.resolution === 'pending') {
if (row.deadline.resolution !== 'pending') return false;
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;
if (row.status.variant !== "yellow" && row.status.variant !== "red")
return false;
}
return true;
});
@@ -60,16 +88,31 @@ export function DeadlineDashboard({ entries, onResolveDeadline, onAddChainedDead
setResolvingDeadline(deadline);
};
const handleResolve = (resolution: DeadlineResolution, note: string, chainNext: boolean) => {
const handleResolve = (
resolution: DeadlineResolution,
note: string,
chainNext: boolean,
) => {
if (!resolvingEntry || !resolvingDeadline) return;
onResolveDeadline(resolvingEntry, resolvingDeadline.id, resolution, note, chainNext);
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);
onAddChainedDeadline(
resolvingEntry,
def.chainNextTypeId,
resolvedDate,
resolvingDeadline.id,
);
}
}
@@ -80,43 +123,104 @@ export function DeadlineDashboard({ entries, onResolveDeadline, onAddChainedDead
return (
<div className="space-y-4">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<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} />
<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}
/>
<StatCard
label="Lipsă nr. dest."
value={stats.missingRecipientReg}
variant={stats.missingRecipientReg > 0 ? "destructive" : undefined}
/>
<StatCard
label="Expiră curând"
value={stats.expiringSoon}
variant={stats.expiringSoon > 0 ? "destructive" : undefined}
/>
</div>
{/* Alert banners */}
{stats.missingRecipientReg > 0 && (
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-2 text-xs text-amber-700 dark:text-amber-400">
{stats.missingRecipientReg} ieșir
{stats.missingRecipientReg === 1 ? "e" : "i"} cu termene legale nu{" "}
{stats.missingRecipientReg === 1 ? "are" : "au"} completat
numărul/data de înregistrare la destinatar. Termenele legale nu
pornesc fără aceste date.
</div>
)}
{stats.expiringSoon > 0 && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-4 py-2 text-xs text-red-700 dark:text-red-400">
{stats.expiringSoon} document{stats.expiringSoon === 1 ? "" : "e"}{" "}
cu termen de valabilitate se apropie de expirare sau{" "}
{stats.expiringSoon === 1 ? "a" : "au"} expirat. Inițiați procedurile
de prelungire.
</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>
<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>
{(
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>
<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>
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant={filters.urgentOnly ? 'default' : 'outline'}
variant={filters.urgentOnly ? "default" : "outline"}
size="sm"
onClick={() => updateFilter('urgentOnly', !filters.urgentOnly)}
onClick={() => updateFilter("urgentOnly", !filters.urgentOnly)}
>
Doar urgente
</Button>
@@ -144,14 +248,24 @@ export function DeadlineDashboard({ entries, onResolveDeadline, onAddChainedDead
);
}
function StatCard({ label, value, variant }: { label: string; value: number; variant?: 'destructive' | 'blue' }) {
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' : ''}`}>
<p
className={`text-2xl font-bold ${
variant === "destructive" && value > 0 ? "text-destructive" : ""
}${variant === "blue" && value > 0 ? "text-blue-600" : ""}`}
>
{value}
</p>
</CardContent>
@@ -228,6 +228,9 @@ export function RegistraturaModule() {
[allEntries],
);
const urgentDeadlines = deadlineStats.urgent + deadlineStats.overdue;
const missingRecipient = deadlineStats.missingRecipientReg;
const expiringSoon = deadlineStats.expiringSoon;
const alertCount = urgentDeadlines + missingRecipient + expiringSoon;
const closingEntry = closingId
? allEntries.find((e) => e.id === closingId)
@@ -248,12 +251,12 @@ export function RegistraturaModule() {
<TabsTrigger value="registru">Registru</TabsTrigger>
<TabsTrigger value="termene">
Termene legale
{urgentDeadlines > 0 && (
{alertCount > 0 && (
<Badge
variant="destructive"
className="ml-1.5 text-[10px] px-1.5 py-0"
>
{urgentDeadlines}
{alertCount}
</Badge>
)}
</TabsTrigger>
@@ -261,6 +264,21 @@ export function RegistraturaModule() {
<TabsContent value="registru">
<div className="space-y-6">
{/* Alert banners */}
{missingRecipient > 0 && viewMode === "list" && (
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-2 text-xs text-amber-700 dark:text-amber-400">
{missingRecipient} ieșir{missingRecipient === 1 ? "e" : "i"} cu
termene legale nu {missingRecipient === 1 ? "are" : "au"}{" "}
completat datele de la destinatar.
</div>
)}
{expiringSoon > 0 && viewMode === "list" && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-4 py-2 text-xs text-red-700 dark:text-red-400">
{expiringSoon} document{expiringSoon === 1 ? "" : "e"} se
apropie de expirare sau {expiringSoon === 1 ? "a" : "au"} expirat.
</div>
)}
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Total" value={total} />
@@ -10,6 +10,9 @@ import {
Info,
GitBranch,
Loader2,
AlertTriangle,
Calendar,
Globe,
} from "lucide-react";
import type { CompanyId } from "@/core/auth/types";
import type {
@@ -149,6 +152,24 @@ export function RegistryEntryForm({
const [linkedSearch, setLinkedSearch] = useState("");
const [threadSearch, setThreadSearch] = useState("");
// ── 3.03 new fields ──
const [recipientRegNumber, setRecipientRegNumber] = useState(
initial?.recipientRegNumber ?? "",
);
const [recipientRegDate, setRecipientRegDate] = useState(
initial?.recipientRegDate ?? "",
);
const [expiryDate, setExpiryDate] = useState(initial?.expiryDate ?? "");
const [expiryAlertDays, setExpiryAlertDays] = useState(
initial?.expiryAlertDays ?? 30,
);
const [externalStatusUrl, setExternalStatusUrl] = useState(
initial?.externalStatusUrl ?? "",
);
const [externalTrackingId, setExternalTrackingId] = useState(
initial?.externalTrackingId ?? "",
);
// ── Submission lock + file upload tracking ──
const [isSubmitting, setIsSubmitting] = useState(false);
const [uploadingCount, setUploadingCount] = useState(0);
@@ -340,6 +361,12 @@ export function RegistryEntryForm({
assignee: assignee || undefined,
assigneeContactId: assigneeContactId || undefined,
threadParentId: threadParentId || undefined,
recipientRegNumber: recipientRegNumber || undefined,
recipientRegDate: recipientRegDate || undefined,
expiryDate: expiryDate || undefined,
expiryAlertDays: expiryDate ? expiryAlertDays : undefined,
externalStatusUrl: externalStatusUrl || undefined,
externalTrackingId: externalTrackingId || undefined,
linkedEntryIds,
attachments,
trackedDeadlines:
@@ -595,7 +622,59 @@ export function RegistryEntryForm({
</div>
</div>
{/* Assignee (Responsabil) */}
{/* Recipient registration fields — only for outgoing (iesit) documents */}
{direction === "iesit" && (
<div className="rounded-md border border-blue-500/30 bg-blue-500/5 p-3 space-y-3">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<Label className="text-sm font-medium text-blue-700 dark:text-blue-300">
Înregistrare la destinatar
</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p className="text-xs">
Termenul legal pentru ieșiri curge DOAR de la data
înregistrării la destinatar, nu de la data trimiterii.
Completează aceste câmpuri când primești confirmarea.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label className="text-xs">Nr. înregistrare destinatar</Label>
<Input
value={recipientRegNumber}
onChange={(e) => setRecipientRegNumber(e.target.value)}
className="mt-1"
placeholder="Ex: 12345/2026"
/>
</div>
<div>
<Label className="text-xs">Data înregistrare destinatar</Label>
<Input
type="date"
value={recipientRegDate}
onChange={(e) => setRecipientRegDate(e.target.value)}
className="mt-1"
/>
</div>
</div>
{!recipientRegNumber && !recipientRegDate && (
<p className="text-[10px] text-amber-600 dark:text-amber-400">
Atenție: Datele de la destinatar nu sunt completate. Termenele
legale atașate nu vor porni până la completarea lor.
</p>
)}
</div>
)}
{/* Assignee (Responsabil) — kept below */}
<div className="relative">
<Label className="flex items-center gap-1.5">
Responsabil
@@ -707,6 +786,104 @@ export function RegistryEntryForm({
</div>
</div>
{/* Document Expiry — CU/AC validity tracking */}
<div className="rounded-md border border-muted p-3 space-y-3">
<Label className="flex items-center gap-1.5 text-sm font-medium">
<Calendar className="h-3.5 w-3.5" />
Valabilitate document
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p className="text-xs">
Pentru documente cu termen de valabilitate (CU, AC etc.).
Sistemul va genera alerte înainte de expirare.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label className="text-xs">Data expirare</Label>
<Input
type="date"
value={expiryDate}
onChange={(e) => setExpiryDate(e.target.value)}
className="mt-1"
/>
</div>
{expiryDate && (
<div>
<Label className="text-xs">Alertă cu X zile înainte</Label>
<Input
type="number"
value={expiryAlertDays}
onChange={(e) =>
setExpiryAlertDays(parseInt(e.target.value, 10) || 30)
}
className="mt-1"
min={1}
max={365}
/>
</div>
)}
</div>
{expiryDate &&
(() => {
const expiry = new Date(expiryDate);
const now = new Date();
now.setHours(0, 0, 0, 0);
expiry.setHours(0, 0, 0, 0);
const daysLeft = Math.ceil(
(expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
);
if (daysLeft < 0) {
return (
<p className="text-[10px] text-red-600 dark:text-red-400 font-medium">
Document expirat de {Math.abs(daysLeft)} zile!
</p>
);
}
if (daysLeft <= expiryAlertDays) {
return (
<p className="text-[10px] text-amber-600 dark:text-amber-400">
Expiră în {daysLeft} zile inițiați procedurile de
prelungire.
</p>
);
}
return null;
})()}
</div>
{/* Web scraping prep — external tracking */}
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label className="flex items-center gap-1.5 text-xs">
<Globe className="h-3 w-3" />
URL verificare status (opțional)
</Label>
<Input
value={externalStatusUrl}
onChange={(e) => setExternalStatusUrl(e.target.value)}
className="mt-1 text-xs"
placeholder="https://portal.primaria.ro/..."
/>
</div>
<div>
<Label className="text-xs">ID urmărire extern (opțional)</Label>
<Input
value={externalTrackingId}
onChange={(e) => setExternalTrackingId(e.target.value)}
className="mt-1 text-xs"
placeholder="Ex: REF-2026-001"
/>
</div>
</div>
{/* Thread parent — reply to another entry */}
{allEntries && allEntries.length > 0 && (
<div>
+13 -12
View File
@@ -1,17 +1,18 @@
import type { ModuleConfig } from '@/core/module-registry/types';
import type { ModuleConfig } from "@/core/module-registry/types";
export const registraturaConfig: ModuleConfig = {
id: 'registratura',
name: 'Registratură',
description: 'Registru de corespondență multi-firmă cu urmărire documente',
icon: 'book-open',
route: '/registratura',
category: 'operations',
featureFlag: 'module.registratura',
visibility: 'all',
version: '0.1.0',
id: "registratura",
name: "Registratură",
description:
"Registru de corespondență cu termene legale, audit log și urmărire valabilitate",
icon: "book-open",
route: "/registratura",
category: "operations",
featureFlag: "module.registratura",
visibility: "all",
version: "0.2.0",
dependencies: [],
storageNamespace: 'registratura',
storageNamespace: "registratura",
navOrder: 10,
tags: ['registru', 'corespondență', 'documente'],
tags: ["registru", "corespondență", "documente"],
};
+1
View File
@@ -11,5 +11,6 @@ export type {
DeadlineCategory,
DeadlineTypeDef,
TrackedDeadline,
DeadlineAuditEntry,
} from "./types";
export { DEFAULT_DOCUMENT_TYPES, DEFAULT_DOC_TYPE_LABELS } from "./types";
@@ -15,6 +15,20 @@ export const DEADLINE_CATALOG: DeadlineTypeDef[] = [
category: "certificat",
legalReference: "Legea 50/1991, art. 6¹",
},
{
id: "prelungire-cu",
label: "Cerere prelungire CU",
description:
"Cerere de prelungire a Certificatului de Urbanism. Se depune înainte de expirare.",
days: 15,
dayType: "calendar",
startDateLabel: "Data depunerii cererii de prelungire",
requiresCustomStartDate: true,
startDateHint: "Data la care s-a depus cererea de prelungire a CU",
tacitApprovalApplicable: true,
category: "certificat",
legalReference: "Legea 50/1991, art. 6¹",
},
// ── Avize ──
{
@@ -1,11 +1,16 @@
import { v4 as uuid } from 'uuid';
import type { TrackedDeadline, DeadlineResolution, RegistryEntry } from '../types';
import { getDeadlineType } from './deadline-catalog';
import { computeDueDate } from './working-days';
import { v4 as uuid } from "uuid";
import type {
TrackedDeadline,
DeadlineResolution,
RegistryEntry,
DeadlineAuditEntry,
} from "../types";
import { getDeadlineType } from "./deadline-catalog";
import { computeDueDate } from "./working-days";
export interface DeadlineDisplayStatus {
label: string;
variant: 'green' | 'yellow' | 'red' | 'blue' | 'gray';
variant: "green" | "yellow" | "red" | "blue" | "gray";
daysRemaining: number | null;
}
@@ -23,15 +28,27 @@ export function createTrackedDeadline(
const start = new Date(startDate);
start.setHours(0, 0, 0, 0);
const due = computeDueDate(start, def.days, def.dayType, def.isBackwardDeadline);
const due = computeDueDate(
start,
def.days,
def.dayType,
def.isBackwardDeadline,
);
return {
id: uuid(),
typeId,
startDate,
dueDate: formatDate(due),
resolution: 'pending',
resolution: "pending",
chainParentId,
auditLog: [
{
action: "created",
timestamp: new Date().toISOString(),
detail: `Termen creat: ${def.label} (${def.days} ${def.dayType === "working" ? "zile lucrătoare" : "zile calendaristice"})`,
},
],
createdAt: new Date().toISOString(),
};
}
@@ -44,32 +61,40 @@ export function resolveDeadline(
resolution: DeadlineResolution,
note?: string,
): TrackedDeadline {
const auditEntry: DeadlineAuditEntry = {
action: "resolved",
timestamp: new Date().toISOString(),
detail: `Rezolvat: ${resolution}${note ? `${note}` : ""}`,
};
return {
...deadline,
resolution,
resolvedDate: new Date().toISOString(),
resolutionNote: note,
auditLog: [...(deadline.auditLog ?? []), auditEntry],
};
}
/**
* Get the display status for a tracked deadline — color coding + label.
*/
export function getDeadlineDisplayStatus(deadline: TrackedDeadline): DeadlineDisplayStatus {
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 !== "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 === "respins") {
return { label: "Respins", variant: "gray", daysRemaining: null };
}
if (deadline.resolution === 'anulat') {
return { label: 'Anulat', variant: 'gray', daysRemaining: null };
if (deadline.resolution === "anulat") {
return { label: "Anulat", variant: "gray", daysRemaining: null };
}
return { label: 'Finalizat', variant: 'gray', daysRemaining: null };
return { label: "Finalizat", variant: "gray", daysRemaining: null };
}
// Pending — compute days remaining
@@ -83,16 +108,16 @@ export function getDeadlineDisplayStatus(deadline: TrackedDeadline): DeadlineDis
// Overdue + tacit applicable → tacit approval
if (daysRemaining < 0 && def?.tacitApprovalApplicable) {
return { label: 'Aprobat tacit', variant: 'blue', daysRemaining };
return { label: "Aprobat tacit", variant: "blue", daysRemaining };
}
if (daysRemaining < 0) {
return { label: 'Depășit termen', variant: 'red', daysRemaining };
return { label: "Depășit termen", variant: "red", daysRemaining };
}
if (daysRemaining <= 5) {
return { label: 'Urgent', variant: 'yellow', daysRemaining };
return { label: "Urgent", variant: "yellow", daysRemaining };
}
return { label: 'În termen', variant: 'green', daysRemaining };
return { label: "În termen", variant: "green", daysRemaining };
}
/**
@@ -103,25 +128,64 @@ export function aggregateDeadlines(entries: RegistryEntry[]): {
urgent: number;
overdue: number;
tacit: number;
all: Array<{ deadline: TrackedDeadline; entry: RegistryEntry; status: DeadlineDisplayStatus }>;
missingRecipientReg: number;
expiringSoon: 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 }> = [];
const all: Array<{
deadline: TrackedDeadline;
entry: RegistryEntry;
status: DeadlineDisplayStatus;
}> = [];
// Count entries missing recipient registration (outgoing with deadlines)
let missingRecipientReg = 0;
let expiringSoon = 0;
const now = new Date();
now.setHours(0, 0, 0, 0);
for (const entry of entries) {
// Check missing recipient registration for outgoing entries
if (
entry.direction === "iesit" &&
entry.status === "deschis" &&
(entry.trackedDeadlines ?? []).length > 0 &&
!entry.recipientRegDate
) {
missingRecipientReg++;
}
// Check document expiry
if (entry.expiryDate && entry.status === "deschis") {
const expiry = new Date(entry.expiryDate);
expiry.setHours(0, 0, 0, 0);
const daysLeft = Math.ceil(
(expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
);
const alertDays = entry.expiryAlertDays ?? 30;
if (daysLeft <= alertDays) {
expiringSoon++;
}
}
for (const dl of entry.trackedDeadlines ?? []) {
const status = getDeadlineDisplayStatus(dl);
all.push({ deadline: dl, entry, status });
if (dl.resolution === 'pending') {
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') {
if (status.variant === "yellow") urgent++;
if (status.variant === "red") overdue++;
if (status.variant === "blue") tacit++;
} else if (dl.resolution === "aprobat-tacit") {
tacit++;
}
}
@@ -129,18 +193,26 @@ export function aggregateDeadlines(entries: RegistryEntry[]): {
// 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;
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 };
return {
active,
urgent,
overdue,
tacit,
missingRecipientReg,
expiringSoon,
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');
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
+23
View File
@@ -116,6 +116,15 @@ export interface DeadlineTypeDef {
isBackwardDeadline?: boolean;
}
/** Audit log entry for deadline changes */
export interface DeadlineAuditEntry {
action: "created" | "resolved" | "modified" | "recipient-registered";
timestamp: string;
/** User who performed the action (SSO name when available) */
actor?: string;
detail: string;
}
export interface TrackedDeadline {
id: string;
typeId: string;
@@ -125,6 +134,8 @@ export interface TrackedDeadline {
resolvedDate?: string;
resolutionNote?: string;
chainParentId?: string;
/** Mini audit log — tracks who created/modified/resolved this deadline */
auditLog?: DeadlineAuditEntry[];
createdAt: string;
}
@@ -145,6 +156,10 @@ export interface RegistryEntry {
/** Destinatar — free text or linked contact ID */
recipient: string;
recipientContactId?: string;
/** Număr înregistrare la destinatar (for outgoing docs — triggers legal deadline) */
recipientRegNumber?: string;
/** Data înregistrare la destinatar (YYYY-MM-DD) — legal deadlines start from this date */
recipientRegDate?: string;
company: CompanyId;
status: RegistryStatus;
/** Structured closure metadata (populated when status = 'inchis') */
@@ -162,6 +177,14 @@ export interface RegistryEntry {
attachments: RegistryAttachment[];
/** Tracked legal deadlines */
trackedDeadlines?: TrackedDeadline[];
/** Document expiry date — for CU/AC validity tracking (YYYY-MM-DD) */
expiryDate?: string;
/** Days before expiry to trigger alert (default 30) */
expiryAlertDays?: number;
/** URL for external status checking (web scraping prep) */
externalStatusUrl?: string;
/** External tracking ID (e.g., portal reference number) */
externalTrackingId?: string;
tags: string[];
notes: string;
visibility: Visibility;