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:
@@ -1,12 +1,13 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { Clock, CheckCircle2, X } from 'lucide-react';
|
import { useState } from "react";
|
||||||
import { Badge } from '@/shared/components/ui/badge';
|
import { Clock, CheckCircle2, X, History } from "lucide-react";
|
||||||
import { Button } from '@/shared/components/ui/button';
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import type { TrackedDeadline } from '../types';
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { getDeadlineType } from '../services/deadline-catalog';
|
import type { TrackedDeadline } from "../types";
|
||||||
import { getDeadlineDisplayStatus } from '../services/deadline-service';
|
import { getDeadlineType } from "../services/deadline-catalog";
|
||||||
import { cn } from '@/shared/lib/utils';
|
import { getDeadlineDisplayStatus } from "../services/deadline-service";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
interface DeadlineCardProps {
|
interface DeadlineCardProps {
|
||||||
deadline: TrackedDeadline;
|
deadline: TrackedDeadline;
|
||||||
@@ -15,78 +16,156 @@ interface DeadlineCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const VARIANT_CLASSES: Record<string, string> = {
|
const VARIANT_CLASSES: Record<string, string> = {
|
||||||
green: 'border-green-500/30 bg-green-50 dark:bg-green-950/20',
|
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',
|
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',
|
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',
|
blue: "border-blue-500/30 bg-blue-50 dark:bg-blue-950/20",
|
||||||
gray: 'border-muted bg-muted/30',
|
gray: "border-muted bg-muted/30",
|
||||||
};
|
};
|
||||||
|
|
||||||
const BADGE_CLASSES: Record<string, string> = {
|
const BADGE_CLASSES: Record<string, string> = {
|
||||||
green: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
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',
|
yellow:
|
||||||
red: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
||||||
blue: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
red: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
||||||
gray: 'bg-muted text-muted-foreground',
|
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 def = getDeadlineType(deadline.typeId);
|
||||||
const status = getDeadlineDisplayStatus(deadline);
|
const status = getDeadlineDisplayStatus(deadline);
|
||||||
|
const [showAudit, setShowAudit] = useState(false);
|
||||||
|
const auditLog = deadline.auditLog ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center gap-3 rounded-lg border p-3', VARIANT_CLASSES[status.variant] ?? '')}>
|
<div
|
||||||
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
className={cn(
|
||||||
<div className="flex-1 min-w-0">
|
"rounded-lg border p-3",
|
||||||
<div className="flex items-center gap-2">
|
VARIANT_CLASSES[status.variant] ?? "",
|
||||||
<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}
|
<div className="flex items-center gap-3">
|
||||||
{status.daysRemaining !== null && status.variant !== 'blue' && (
|
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
<span className="ml-1">
|
<div className="flex-1 min-w-0">
|
||||||
({status.daysRemaining < 0 ? `${Math.abs(status.daysRemaining)}z depășit` : `${status.daysRemaining}z`})
|
<div className="flex items-center gap-2">
|
||||||
</span>
|
<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>
|
||||||
<div className="text-xs text-muted-foreground mt-0.5">
|
<div className="flex gap-1 shrink-0">
|
||||||
{def?.isBackwardDeadline ? 'Termen limită' : 'Start'}: {formatDate(deadline.startDate)}
|
{auditLog.length > 0 && (
|
||||||
{' → '}
|
<Button
|
||||||
{def?.isBackwardDeadline ? 'Depunere până la' : 'Termen'}: {formatDate(deadline.dueDate)}
|
type="button"
|
||||||
{def?.dayType === 'working' && <span className="ml-1">(zile lucrătoare)</span>}
|
variant="ghost"
|
||||||
</div>
|
size="icon"
|
||||||
</div>
|
className="h-7 w-7 text-muted-foreground"
|
||||||
<div className="flex gap-1 shrink-0">
|
onClick={() => setShowAudit(!showAudit)}
|
||||||
{deadline.resolution === 'pending' && (
|
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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-green-600"
|
className="h-7 w-7 text-destructive"
|
||||||
onClick={() => onResolve(deadline)}
|
onClick={() => onRemove(deadline.id)}
|
||||||
title="Rezolvă"
|
title="Șterge"
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return iso;
|
return iso;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,83 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from "react";
|
||||||
import { Card, CardContent } from '@/shared/components/ui/card';
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
import { Badge } from '@/shared/components/ui/badge';
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Label } from '@/shared/components/ui/label';
|
import { Label } from "@/shared/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
import {
|
||||||
import { Button } from '@/shared/components/ui/button';
|
Select,
|
||||||
import type { RegistryEntry, TrackedDeadline, DeadlineResolution, DeadlineCategory } from '../types';
|
SelectContent,
|
||||||
import { aggregateDeadlines } from '../services/deadline-service';
|
SelectItem,
|
||||||
import { CATEGORY_LABELS, getDeadlineType } from '../services/deadline-catalog';
|
SelectTrigger,
|
||||||
import { useDeadlineFilters } from '../hooks/use-deadline-filters';
|
SelectValue,
|
||||||
import { DeadlineTable } from './deadline-table';
|
} from "@/shared/components/ui/select";
|
||||||
import { DeadlineResolveDialog } from './deadline-resolve-dialog';
|
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 {
|
interface DeadlineDashboardProps {
|
||||||
entries: RegistryEntry[];
|
entries: RegistryEntry[];
|
||||||
onResolveDeadline: (entryId: string, deadlineId: string, resolution: DeadlineResolution, note: string, chainNext: boolean) => void;
|
onResolveDeadline: (
|
||||||
onAddChainedDeadline: (entryId: string, typeId: string, startDate: string, parentId: string) => void;
|
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> = {
|
const RESOLUTION_LABELS: Record<string, string> = {
|
||||||
pending: 'În așteptare',
|
pending: "În așteptare",
|
||||||
completed: 'Finalizat',
|
completed: "Finalizat",
|
||||||
'aprobat-tacit': 'Aprobat tacit',
|
"aprobat-tacit": "Aprobat tacit",
|
||||||
respins: 'Respins',
|
respins: "Respins",
|
||||||
anulat: 'Anulat',
|
anulat: "Anulat",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DeadlineDashboard({ entries, onResolveDeadline, onAddChainedDeadline }: DeadlineDashboardProps) {
|
export function DeadlineDashboard({
|
||||||
|
entries,
|
||||||
|
onResolveDeadline,
|
||||||
|
onAddChainedDeadline,
|
||||||
|
}: DeadlineDashboardProps) {
|
||||||
const { filters, updateFilter } = useDeadlineFilters();
|
const { filters, updateFilter } = useDeadlineFilters();
|
||||||
const [resolvingEntry, setResolvingEntry] = useState<string | null>(null);
|
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 stats = useMemo(() => aggregateDeadlines(entries), [entries]);
|
||||||
|
|
||||||
const filteredRows = useMemo(() => {
|
const filteredRows = useMemo(() => {
|
||||||
return stats.all.filter((row) => {
|
return stats.all.filter((row) => {
|
||||||
if (filters.category !== 'all') {
|
if (filters.category !== "all") {
|
||||||
const def = getDeadlineType(row.deadline.typeId);
|
const def = getDeadlineType(row.deadline.typeId);
|
||||||
if (def && def.category !== filters.category) return false;
|
if (def && def.category !== filters.category) return false;
|
||||||
}
|
}
|
||||||
if (filters.resolution !== 'all') {
|
if (filters.resolution !== "all") {
|
||||||
// Map tacit display status to actual resolution filter
|
// Map tacit display status to actual resolution filter
|
||||||
if (filters.resolution === 'pending') {
|
if (filters.resolution === "pending") {
|
||||||
if (row.deadline.resolution !== 'pending') return false;
|
if (row.deadline.resolution !== "pending") return false;
|
||||||
} else if (row.deadline.resolution !== filters.resolution) {
|
} else if (row.deadline.resolution !== filters.resolution) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (filters.urgentOnly) {
|
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;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -60,16 +88,31 @@ export function DeadlineDashboard({ entries, onResolveDeadline, onAddChainedDead
|
|||||||
setResolvingDeadline(deadline);
|
setResolvingDeadline(deadline);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResolve = (resolution: DeadlineResolution, note: string, chainNext: boolean) => {
|
const handleResolve = (
|
||||||
|
resolution: DeadlineResolution,
|
||||||
|
note: string,
|
||||||
|
chainNext: boolean,
|
||||||
|
) => {
|
||||||
if (!resolvingEntry || !resolvingDeadline) return;
|
if (!resolvingEntry || !resolvingDeadline) return;
|
||||||
onResolveDeadline(resolvingEntry, resolvingDeadline.id, resolution, note, chainNext);
|
onResolveDeadline(
|
||||||
|
resolvingEntry,
|
||||||
|
resolvingDeadline.id,
|
||||||
|
resolution,
|
||||||
|
note,
|
||||||
|
chainNext,
|
||||||
|
);
|
||||||
|
|
||||||
// Handle chain creation
|
// Handle chain creation
|
||||||
if (chainNext) {
|
if (chainNext) {
|
||||||
const def = getDeadlineType(resolvingDeadline.typeId);
|
const def = getDeadlineType(resolvingDeadline.typeId);
|
||||||
if (def?.chainNextTypeId) {
|
if (def?.chainNextTypeId) {
|
||||||
const resolvedDate = new Date().toISOString().slice(0, 10);
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Stats */}
|
{/* 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="Active" value={stats.active} />
|
||||||
<StatCard label="Urgente" value={stats.urgent} variant={stats.urgent > 0 ? 'destructive' : undefined} />
|
<StatCard
|
||||||
<StatCard label="Depășit termen" value={stats.overdue} variant={stats.overdue > 0 ? 'destructive' : undefined} />
|
label="Urgente"
|
||||||
<StatCard label="Aprobat tacit" value={stats.tacit} variant={stats.tacit > 0 ? 'blue' : undefined} />
|
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>
|
</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 */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">Categorie</Label>
|
<Label className="text-xs">Categorie</Label>
|
||||||
<Select value={filters.category} onValueChange={(v) => updateFilter('category', v as DeadlineCategory | 'all')}>
|
<Select
|
||||||
<SelectTrigger className="mt-1 w-[160px]"><SelectValue /></SelectTrigger>
|
value={filters.category}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateFilter("category", v as DeadlineCategory | "all")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 w-[160px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Toate</SelectItem>
|
<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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">Status</Label>
|
<Label className="text-xs">Status</Label>
|
||||||
<Select value={filters.resolution} onValueChange={(v) => updateFilter('resolution', v as DeadlineResolution | 'all')}>
|
<Select
|
||||||
<SelectTrigger className="mt-1 w-[160px]"><SelectValue /></SelectTrigger>
|
value={filters.resolution}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateFilter("resolution", v as DeadlineResolution | "all")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 w-[160px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Toate</SelectItem>
|
<SelectItem value="all">Toate</SelectItem>
|
||||||
{Object.entries(RESOLUTION_LABELS).map(([key, label]) => (
|
{Object.entries(RESOLUTION_LABELS).map(([key, label]) => (
|
||||||
<SelectItem key={key} value={key}>{label}</SelectItem>
|
<SelectItem key={key} value={key}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant={filters.urgentOnly ? 'default' : 'outline'}
|
variant={filters.urgentOnly ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => updateFilter('urgentOnly', !filters.urgentOnly)}
|
onClick={() => updateFilter("urgentOnly", !filters.urgentOnly)}
|
||||||
>
|
>
|
||||||
Doar urgente
|
Doar urgente
|
||||||
</Button>
|
</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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
<p className={`text-2xl font-bold ${
|
<p
|
||||||
variant === 'destructive' && value > 0 ? 'text-destructive' : ''
|
className={`text-2xl font-bold ${
|
||||||
}${variant === 'blue' && value > 0 ? 'text-blue-600' : ''}`}>
|
variant === "destructive" && value > 0 ? "text-destructive" : ""
|
||||||
|
}${variant === "blue" && value > 0 ? "text-blue-600" : ""}`}
|
||||||
|
>
|
||||||
{value}
|
{value}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -228,6 +228,9 @@ export function RegistraturaModule() {
|
|||||||
[allEntries],
|
[allEntries],
|
||||||
);
|
);
|
||||||
const urgentDeadlines = deadlineStats.urgent + deadlineStats.overdue;
|
const urgentDeadlines = deadlineStats.urgent + deadlineStats.overdue;
|
||||||
|
const missingRecipient = deadlineStats.missingRecipientReg;
|
||||||
|
const expiringSoon = deadlineStats.expiringSoon;
|
||||||
|
const alertCount = urgentDeadlines + missingRecipient + expiringSoon;
|
||||||
|
|
||||||
const closingEntry = closingId
|
const closingEntry = closingId
|
||||||
? allEntries.find((e) => e.id === closingId)
|
? allEntries.find((e) => e.id === closingId)
|
||||||
@@ -248,12 +251,12 @@ export function RegistraturaModule() {
|
|||||||
<TabsTrigger value="registru">Registru</TabsTrigger>
|
<TabsTrigger value="registru">Registru</TabsTrigger>
|
||||||
<TabsTrigger value="termene">
|
<TabsTrigger value="termene">
|
||||||
Termene legale
|
Termene legale
|
||||||
{urgentDeadlines > 0 && (
|
{alertCount > 0 && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="ml-1.5 text-[10px] px-1.5 py-0"
|
className="ml-1.5 text-[10px] px-1.5 py-0"
|
||||||
>
|
>
|
||||||
{urgentDeadlines}
|
{alertCount}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -261,6 +264,21 @@ export function RegistraturaModule() {
|
|||||||
|
|
||||||
<TabsContent value="registru">
|
<TabsContent value="registru">
|
||||||
<div className="space-y-6">
|
<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 */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<StatCard label="Total" value={total} />
|
<StatCard label="Total" value={total} />
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
AlertTriangle,
|
||||||
|
Calendar,
|
||||||
|
Globe,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { CompanyId } from "@/core/auth/types";
|
import type { CompanyId } from "@/core/auth/types";
|
||||||
import type {
|
import type {
|
||||||
@@ -149,6 +152,24 @@ export function RegistryEntryForm({
|
|||||||
const [linkedSearch, setLinkedSearch] = useState("");
|
const [linkedSearch, setLinkedSearch] = useState("");
|
||||||
const [threadSearch, setThreadSearch] = 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 ──
|
// ── Submission lock + file upload tracking ──
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [uploadingCount, setUploadingCount] = useState(0);
|
const [uploadingCount, setUploadingCount] = useState(0);
|
||||||
@@ -340,6 +361,12 @@ export function RegistryEntryForm({
|
|||||||
assignee: assignee || undefined,
|
assignee: assignee || undefined,
|
||||||
assigneeContactId: assigneeContactId || undefined,
|
assigneeContactId: assigneeContactId || undefined,
|
||||||
threadParentId: threadParentId || undefined,
|
threadParentId: threadParentId || undefined,
|
||||||
|
recipientRegNumber: recipientRegNumber || undefined,
|
||||||
|
recipientRegDate: recipientRegDate || undefined,
|
||||||
|
expiryDate: expiryDate || undefined,
|
||||||
|
expiryAlertDays: expiryDate ? expiryAlertDays : undefined,
|
||||||
|
externalStatusUrl: externalStatusUrl || undefined,
|
||||||
|
externalTrackingId: externalTrackingId || undefined,
|
||||||
linkedEntryIds,
|
linkedEntryIds,
|
||||||
attachments,
|
attachments,
|
||||||
trackedDeadlines:
|
trackedDeadlines:
|
||||||
@@ -595,7 +622,59 @@ export function RegistryEntryForm({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="relative">
|
||||||
<Label className="flex items-center gap-1.5">
|
<Label className="flex items-center gap-1.5">
|
||||||
Responsabil
|
Responsabil
|
||||||
@@ -707,6 +786,104 @@ export function RegistryEntryForm({
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Thread parent — reply to another entry */}
|
||||||
{allEntries && allEntries.length > 0 && (
|
{allEntries && allEntries.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import type { ModuleConfig } from '@/core/module-registry/types';
|
import type { ModuleConfig } from "@/core/module-registry/types";
|
||||||
|
|
||||||
export const registraturaConfig: ModuleConfig = {
|
export const registraturaConfig: ModuleConfig = {
|
||||||
id: 'registratura',
|
id: "registratura",
|
||||||
name: 'Registratură',
|
name: "Registratură",
|
||||||
description: 'Registru de corespondență multi-firmă cu urmărire documente',
|
description:
|
||||||
icon: 'book-open',
|
"Registru de corespondență cu termene legale, audit log și urmărire valabilitate",
|
||||||
route: '/registratura',
|
icon: "book-open",
|
||||||
category: 'operations',
|
route: "/registratura",
|
||||||
featureFlag: 'module.registratura',
|
category: "operations",
|
||||||
visibility: 'all',
|
featureFlag: "module.registratura",
|
||||||
version: '0.1.0',
|
visibility: "all",
|
||||||
|
version: "0.2.0",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
storageNamespace: 'registratura',
|
storageNamespace: "registratura",
|
||||||
navOrder: 10,
|
navOrder: 10,
|
||||||
tags: ['registru', 'corespondență', 'documente'],
|
tags: ["registru", "corespondență", "documente"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,5 +11,6 @@ export type {
|
|||||||
DeadlineCategory,
|
DeadlineCategory,
|
||||||
DeadlineTypeDef,
|
DeadlineTypeDef,
|
||||||
TrackedDeadline,
|
TrackedDeadline,
|
||||||
|
DeadlineAuditEntry,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
export { DEFAULT_DOCUMENT_TYPES, DEFAULT_DOC_TYPE_LABELS } from "./types";
|
export { DEFAULT_DOCUMENT_TYPES, DEFAULT_DOC_TYPE_LABELS } from "./types";
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ export const DEADLINE_CATALOG: DeadlineTypeDef[] = [
|
|||||||
category: "certificat",
|
category: "certificat",
|
||||||
legalReference: "Legea 50/1991, art. 6¹",
|
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 ──
|
// ── Avize ──
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from "uuid";
|
||||||
import type { TrackedDeadline, DeadlineResolution, RegistryEntry } from '../types';
|
import type {
|
||||||
import { getDeadlineType } from './deadline-catalog';
|
TrackedDeadline,
|
||||||
import { computeDueDate } from './working-days';
|
DeadlineResolution,
|
||||||
|
RegistryEntry,
|
||||||
|
DeadlineAuditEntry,
|
||||||
|
} from "../types";
|
||||||
|
import { getDeadlineType } from "./deadline-catalog";
|
||||||
|
import { computeDueDate } from "./working-days";
|
||||||
|
|
||||||
export interface DeadlineDisplayStatus {
|
export interface DeadlineDisplayStatus {
|
||||||
label: string;
|
label: string;
|
||||||
variant: 'green' | 'yellow' | 'red' | 'blue' | 'gray';
|
variant: "green" | "yellow" | "red" | "blue" | "gray";
|
||||||
daysRemaining: number | null;
|
daysRemaining: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,15 +28,27 @@ export function createTrackedDeadline(
|
|||||||
const start = new Date(startDate);
|
const start = new Date(startDate);
|
||||||
start.setHours(0, 0, 0, 0);
|
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 {
|
return {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
typeId,
|
typeId,
|
||||||
startDate,
|
startDate,
|
||||||
dueDate: formatDate(due),
|
dueDate: formatDate(due),
|
||||||
resolution: 'pending',
|
resolution: "pending",
|
||||||
chainParentId,
|
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(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -44,32 +61,40 @@ export function resolveDeadline(
|
|||||||
resolution: DeadlineResolution,
|
resolution: DeadlineResolution,
|
||||||
note?: string,
|
note?: string,
|
||||||
): TrackedDeadline {
|
): TrackedDeadline {
|
||||||
|
const auditEntry: DeadlineAuditEntry = {
|
||||||
|
action: "resolved",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
detail: `Rezolvat: ${resolution}${note ? ` — ${note}` : ""}`,
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
...deadline,
|
...deadline,
|
||||||
resolution,
|
resolution,
|
||||||
resolvedDate: new Date().toISOString(),
|
resolvedDate: new Date().toISOString(),
|
||||||
resolutionNote: note,
|
resolutionNote: note,
|
||||||
|
auditLog: [...(deadline.auditLog ?? []), auditEntry],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the display status for a tracked deadline — color coding + label.
|
* 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);
|
const def = getDeadlineType(deadline.typeId);
|
||||||
|
|
||||||
// Already resolved
|
// Already resolved
|
||||||
if (deadline.resolution !== 'pending') {
|
if (deadline.resolution !== "pending") {
|
||||||
if (deadline.resolution === 'aprobat-tacit') {
|
if (deadline.resolution === "aprobat-tacit") {
|
||||||
return { label: 'Aprobat tacit', variant: 'blue', daysRemaining: null };
|
return { label: "Aprobat tacit", variant: "blue", daysRemaining: null };
|
||||||
}
|
}
|
||||||
if (deadline.resolution === 'respins') {
|
if (deadline.resolution === "respins") {
|
||||||
return { label: 'Respins', variant: 'gray', daysRemaining: null };
|
return { label: "Respins", variant: "gray", daysRemaining: null };
|
||||||
}
|
}
|
||||||
if (deadline.resolution === 'anulat') {
|
if (deadline.resolution === "anulat") {
|
||||||
return { label: 'Anulat', variant: 'gray', daysRemaining: null };
|
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
|
// Pending — compute days remaining
|
||||||
@@ -83,16 +108,16 @@ export function getDeadlineDisplayStatus(deadline: TrackedDeadline): DeadlineDis
|
|||||||
|
|
||||||
// Overdue + tacit applicable → tacit approval
|
// Overdue + tacit applicable → tacit approval
|
||||||
if (daysRemaining < 0 && def?.tacitApprovalApplicable) {
|
if (daysRemaining < 0 && def?.tacitApprovalApplicable) {
|
||||||
return { label: 'Aprobat tacit', variant: 'blue', daysRemaining };
|
return { label: "Aprobat tacit", variant: "blue", daysRemaining };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (daysRemaining < 0) {
|
if (daysRemaining < 0) {
|
||||||
return { label: 'Depășit termen', variant: 'red', daysRemaining };
|
return { label: "Depășit termen", variant: "red", daysRemaining };
|
||||||
}
|
}
|
||||||
if (daysRemaining <= 5) {
|
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;
|
urgent: number;
|
||||||
overdue: number;
|
overdue: number;
|
||||||
tacit: 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 active = 0;
|
||||||
let urgent = 0;
|
let urgent = 0;
|
||||||
let overdue = 0;
|
let overdue = 0;
|
||||||
let tacit = 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) {
|
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 ?? []) {
|
for (const dl of entry.trackedDeadlines ?? []) {
|
||||||
const status = getDeadlineDisplayStatus(dl);
|
const status = getDeadlineDisplayStatus(dl);
|
||||||
all.push({ deadline: dl, entry, status });
|
all.push({ deadline: dl, entry, status });
|
||||||
|
|
||||||
if (dl.resolution === 'pending') {
|
if (dl.resolution === "pending") {
|
||||||
active++;
|
active++;
|
||||||
if (status.variant === 'yellow') urgent++;
|
if (status.variant === "yellow") urgent++;
|
||||||
if (status.variant === 'red') overdue++;
|
if (status.variant === "red") overdue++;
|
||||||
if (status.variant === 'blue') tacit++;
|
if (status.variant === "blue") tacit++;
|
||||||
} else if (dl.resolution === 'aprobat-tacit') {
|
} else if (dl.resolution === "aprobat-tacit") {
|
||||||
tacit++;
|
tacit++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,18 +193,26 @@ export function aggregateDeadlines(entries: RegistryEntry[]): {
|
|||||||
|
|
||||||
// Sort: overdue first, then by due date ascending
|
// Sort: overdue first, then by due date ascending
|
||||||
all.sort((a, b) => {
|
all.sort((a, b) => {
|
||||||
const aP = a.deadline.resolution === 'pending' ? 0 : 1;
|
const aP = a.deadline.resolution === "pending" ? 0 : 1;
|
||||||
const bP = b.deadline.resolution === 'pending' ? 0 : 1;
|
const bP = b.deadline.resolution === "pending" ? 0 : 1;
|
||||||
if (aP !== bP) return aP - bP;
|
if (aP !== bP) return aP - bP;
|
||||||
return a.deadline.dueDate.localeCompare(b.deadline.dueDate);
|
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 {
|
function formatDate(d: Date): string {
|
||||||
const y = d.getFullYear();
|
const y = d.getFullYear();
|
||||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
const day = String(d.getDate()).padStart(2, '0');
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
return `${y}-${m}-${day}`;
|
return `${y}-${m}-${day}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,15 @@ export interface DeadlineTypeDef {
|
|||||||
isBackwardDeadline?: boolean;
|
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 {
|
export interface TrackedDeadline {
|
||||||
id: string;
|
id: string;
|
||||||
typeId: string;
|
typeId: string;
|
||||||
@@ -125,6 +134,8 @@ export interface TrackedDeadline {
|
|||||||
resolvedDate?: string;
|
resolvedDate?: string;
|
||||||
resolutionNote?: string;
|
resolutionNote?: string;
|
||||||
chainParentId?: string;
|
chainParentId?: string;
|
||||||
|
/** Mini audit log — tracks who created/modified/resolved this deadline */
|
||||||
|
auditLog?: DeadlineAuditEntry[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +156,10 @@ export interface RegistryEntry {
|
|||||||
/** Destinatar — free text or linked contact ID */
|
/** Destinatar — free text or linked contact ID */
|
||||||
recipient: string;
|
recipient: string;
|
||||||
recipientContactId?: 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;
|
company: CompanyId;
|
||||||
status: RegistryStatus;
|
status: RegistryStatus;
|
||||||
/** Structured closure metadata (populated when status = 'inchis') */
|
/** Structured closure metadata (populated when status = 'inchis') */
|
||||||
@@ -162,6 +177,14 @@ export interface RegistryEntry {
|
|||||||
attachments: RegistryAttachment[];
|
attachments: RegistryAttachment[];
|
||||||
/** Tracked legal deadlines */
|
/** Tracked legal deadlines */
|
||||||
trackedDeadlines?: TrackedDeadline[];
|
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[];
|
tags: string[];
|
||||||
notes: string;
|
notes: string;
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
|
|||||||
Reference in New Issue
Block a user