feat: timeline milestones for deadlines, auto-close reply entries, cleanup
- Entry created via "Inchide" flow now gets status "inchis" with closureInfo - New DeadlineTimeline component: main deadlines as cards with progress bar, auto-tracked sub-deadlines as milestone dots on horizontal timeline - Auto-tracked deadlines hidden from dashboard when user deadlines exist - Verification milestone shows "Expirat — nu se mai pot solicita clarificari" - Parent closureInfo now includes linkedEntryId/Number of the closing act - Removed orphaned deadline-table.tsx and use-deadline-filters.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,127 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { CheckCircle2 } from 'lucide-react';
|
|
||||||
import { Badge } from '@/shared/components/ui/badge';
|
|
||||||
import { Button } from '@/shared/components/ui/button';
|
|
||||||
import type { TrackedDeadline, RegistryEntry } from '../types';
|
|
||||||
import type { DeadlineDisplayStatus } from '../services/deadline-service';
|
|
||||||
import { getDeadlineType, CATEGORY_LABELS } from '../services/deadline-catalog';
|
|
||||||
import { cn } from '@/shared/lib/utils';
|
|
||||||
|
|
||||||
interface DeadlineRow {
|
|
||||||
deadline: TrackedDeadline;
|
|
||||||
entry: RegistryEntry;
|
|
||||||
status: DeadlineDisplayStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DeadlineTableProps {
|
|
||||||
rows: DeadlineRow[];
|
|
||||||
onResolve: (entryId: string, deadline: TrackedDeadline) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BADGE_CLASSES: Record<string, string> = {
|
|
||||||
green: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 border-0',
|
|
||||||
yellow: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 border-0',
|
|
||||||
red: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 border-0',
|
|
||||||
blue: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 border-0',
|
|
||||||
gray: 'bg-muted text-muted-foreground border-0',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ROW_CLASSES: Record<string, string> = {
|
|
||||||
red: 'bg-destructive/5',
|
|
||||||
yellow: 'bg-yellow-50/50 dark:bg-yellow-950/10',
|
|
||||||
blue: 'bg-blue-50/50 dark:bg-blue-950/10',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DeadlineTable({ rows, onResolve }: DeadlineTableProps) {
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return (
|
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
|
||||||
Niciun termen legal urmărit.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="overflow-x-auto rounded-lg border">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b bg-muted/40">
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Nr. înreg.</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Categorie</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Tip termen</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Data start</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Termen limită</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Zile</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">Status</th>
|
|
||||||
<th className="px-3 py-2 text-right font-medium">Acțiuni</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((row) => {
|
|
||||||
const def = getDeadlineType(row.deadline.typeId);
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={row.deadline.id}
|
|
||||||
className={cn(
|
|
||||||
'border-b transition-colors hover:bg-muted/20',
|
|
||||||
ROW_CLASSES[row.status.variant] ?? '',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">{row.entry.number}</td>
|
|
||||||
<td className="px-3 py-2 text-xs">
|
|
||||||
{def ? CATEGORY_LABELS[def.category] : '—'}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-xs">
|
|
||||||
<span className="font-medium">{def?.label ?? row.deadline.typeId}</span>
|
|
||||||
{def?.dayType === 'working' && (
|
|
||||||
<span className="ml-1 text-muted-foreground">(lucr.)</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-xs whitespace-nowrap">{formatDate(row.deadline.startDate)}</td>
|
|
||||||
<td className="px-3 py-2 text-xs whitespace-nowrap font-medium">{formatDate(row.deadline.dueDate)}</td>
|
|
||||||
<td className="px-3 py-2 text-xs whitespace-nowrap">
|
|
||||||
{row.status.daysRemaining !== null ? (
|
|
||||||
<span className={cn(row.status.daysRemaining < 0 && 'text-destructive font-medium')}>
|
|
||||||
{row.status.daysRemaining < 0
|
|
||||||
? `${Math.abs(row.status.daysRemaining)}z depășit`
|
|
||||||
: `${row.status.daysRemaining}z`}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'—'
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<Badge className={cn('text-[10px]', BADGE_CLASSES[row.status.variant] ?? '')}>
|
|
||||||
{row.status.label}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-right">
|
|
||||||
{row.deadline.resolution === 'pending' && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 text-green-600"
|
|
||||||
onClick={() => onResolve(row.entry.id, row.deadline)}
|
|
||||||
title="Rezolvă"
|
|
||||||
>
|
|
||||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import type { TrackedDeadline } from "../types";
|
||||||
|
import { getDeadlineType } from "../services/deadline-catalog";
|
||||||
|
import { getDeadlineDisplayStatus } from "../services/deadline-service";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
interface TimelineMilestone {
|
||||||
|
deadline: TrackedDeadline;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
dueDate: string;
|
||||||
|
/** Position on the timeline (0..1) */
|
||||||
|
position: number;
|
||||||
|
/** Status of this milestone */
|
||||||
|
status: "completed" | "active" | "expired" | "upcoming" | "background";
|
||||||
|
/** Human-readable status text */
|
||||||
|
statusText: string;
|
||||||
|
/** Whether this is the main (user-created) deadline or an auto-tracked milestone */
|
||||||
|
isMain: boolean;
|
||||||
|
/** Days remaining (negative = overdue) */
|
||||||
|
daysRemaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeadlineTimelineProps {
|
||||||
|
/** All tracked deadlines on this entry */
|
||||||
|
deadlines: TrackedDeadline[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build timeline from deadlines ──
|
||||||
|
|
||||||
|
function buildTimeline(deadlines: TrackedDeadline[]): {
|
||||||
|
mainDeadlines: TimelineMilestone[];
|
||||||
|
groups: Map<string, TimelineMilestone[]>;
|
||||||
|
} {
|
||||||
|
const mainDeadlines: TimelineMilestone[] = [];
|
||||||
|
// Group auto-tracked by their chain parent (or by category proximity to a main deadline)
|
||||||
|
const groups = new Map<string, TimelineMilestone[]>();
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
now.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// Separate main vs auto-tracked deadlines
|
||||||
|
const mainDls: TrackedDeadline[] = [];
|
||||||
|
const autoDls: TrackedDeadline[] = [];
|
||||||
|
|
||||||
|
for (const dl of deadlines) {
|
||||||
|
const def = getDeadlineType(dl.typeId);
|
||||||
|
if (def?.backgroundOnly) continue; // Skip background-only entirely
|
||||||
|
if (def?.autoTrack) {
|
||||||
|
autoDls.push(dl);
|
||||||
|
} else {
|
||||||
|
mainDls.push(dl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each main deadline, find its associated milestones
|
||||||
|
for (const main of mainDls) {
|
||||||
|
const def = getDeadlineType(main.typeId);
|
||||||
|
const mainStart = new Date(main.startDate);
|
||||||
|
mainStart.setHours(0, 0, 0, 0);
|
||||||
|
const mainDue = new Date(main.dueDate);
|
||||||
|
mainDue.setHours(0, 0, 0, 0);
|
||||||
|
const totalSpan = Math.max(1, mainDue.getTime() - mainStart.getTime());
|
||||||
|
const mainDaysRemaining = Math.ceil(
|
||||||
|
(mainDue.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mainStatus = getMainStatus(main, mainDaysRemaining);
|
||||||
|
|
||||||
|
mainDeadlines.push({
|
||||||
|
deadline: main,
|
||||||
|
label: def?.label ?? main.typeId,
|
||||||
|
description: def?.description ?? "",
|
||||||
|
dueDate: main.dueDate,
|
||||||
|
position: 1,
|
||||||
|
status: mainStatus,
|
||||||
|
statusText: getStatusText(mainStatus, mainDaysRemaining),
|
||||||
|
isMain: true,
|
||||||
|
daysRemaining: mainDaysRemaining,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find auto-tracked milestones that fall within this main deadline's range
|
||||||
|
const milestones: TimelineMilestone[] = [];
|
||||||
|
for (const auto of autoDls) {
|
||||||
|
const autoDef = getDeadlineType(auto.typeId);
|
||||||
|
if (!autoDef) continue;
|
||||||
|
|
||||||
|
// Check if this auto-tracked deadline is related (same start date or chain parent in this group)
|
||||||
|
const autoStart = new Date(auto.startDate);
|
||||||
|
autoStart.setHours(0, 0, 0, 0);
|
||||||
|
const autoDue = new Date(auto.dueDate);
|
||||||
|
autoDue.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// Only include if it starts from the same point or is within the main's time range
|
||||||
|
const startsNearMain =
|
||||||
|
Math.abs(autoStart.getTime() - mainStart.getTime()) <
|
||||||
|
2 * 24 * 60 * 60 * 1000;
|
||||||
|
const isChainRelated = auto.chainParentId
|
||||||
|
? deadlines.some(
|
||||||
|
(d) =>
|
||||||
|
d.id === auto.chainParentId &&
|
||||||
|
(d.typeId === main.typeId ||
|
||||||
|
d.chainParentId === main.id),
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (!startsNearMain && !isChainRelated) continue;
|
||||||
|
|
||||||
|
const pos = Math.min(
|
||||||
|
0.95,
|
||||||
|
Math.max(
|
||||||
|
0.05,
|
||||||
|
(autoDue.getTime() - mainStart.getTime()) / totalSpan,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const autoDaysRemaining = Math.ceil(
|
||||||
|
(autoDue.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
|
);
|
||||||
|
const autoStatus = getAutoStatus(auto, autoDaysRemaining, autoDef.id);
|
||||||
|
|
||||||
|
milestones.push({
|
||||||
|
deadline: auto,
|
||||||
|
label: autoDef.label,
|
||||||
|
description: getExpiredDescription(autoDef.id, autoStatus),
|
||||||
|
dueDate: auto.dueDate,
|
||||||
|
position: pos,
|
||||||
|
status: autoStatus,
|
||||||
|
statusText: getAutoStatusText(autoDef.id, autoStatus, autoDaysRemaining),
|
||||||
|
isMain: false,
|
||||||
|
daysRemaining: autoDaysRemaining,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort milestones by position
|
||||||
|
milestones.sort((a, b) => a.position - b.position);
|
||||||
|
if (milestones.length > 0) {
|
||||||
|
groups.set(main.id, milestones);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle auto-tracked deadlines that aren't associated with any main deadline
|
||||||
|
const associatedAutoIds = new Set<string>();
|
||||||
|
for (const ms of groups.values()) {
|
||||||
|
for (const m of ms) {
|
||||||
|
associatedAutoIds.add(m.deadline.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const orphanedAuto = autoDls.filter(
|
||||||
|
(dl) => !associatedAutoIds.has(dl.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orphanedAuto.length > 0 && mainDls.length === 0) {
|
||||||
|
// If there are ONLY auto-tracked deadlines (no main), show them as standalone milestones
|
||||||
|
for (const auto of orphanedAuto) {
|
||||||
|
const autoDef = getDeadlineType(auto.typeId);
|
||||||
|
if (!autoDef) continue;
|
||||||
|
const autoDue = new Date(auto.dueDate);
|
||||||
|
autoDue.setHours(0, 0, 0, 0);
|
||||||
|
const daysRemaining = Math.ceil(
|
||||||
|
(autoDue.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
|
);
|
||||||
|
const status = getMainStatus(auto, daysRemaining);
|
||||||
|
|
||||||
|
mainDeadlines.push({
|
||||||
|
deadline: auto,
|
||||||
|
label: autoDef.label,
|
||||||
|
description: autoDef.description,
|
||||||
|
dueDate: auto.dueDate,
|
||||||
|
position: 1,
|
||||||
|
status,
|
||||||
|
statusText: getStatusText(status, daysRemaining),
|
||||||
|
isMain: true,
|
||||||
|
daysRemaining,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mainDeadlines, groups };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMainStatus(
|
||||||
|
dl: TrackedDeadline,
|
||||||
|
daysRemaining: number,
|
||||||
|
): TimelineMilestone["status"] {
|
||||||
|
if (dl.resolution === "completed") return "completed";
|
||||||
|
if (dl.resolution !== "pending") return "completed";
|
||||||
|
if (daysRemaining < 0) return "expired";
|
||||||
|
if (daysRemaining <= 5) return "active";
|
||||||
|
return "upcoming";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAutoStatus(
|
||||||
|
dl: TrackedDeadline,
|
||||||
|
daysRemaining: number,
|
||||||
|
typeId: string,
|
||||||
|
): TimelineMilestone["status"] {
|
||||||
|
if (dl.resolution !== "pending") return "completed";
|
||||||
|
if (daysRemaining < 0) {
|
||||||
|
// For verification-type deadlines, "expired" means the window has passed
|
||||||
|
if (typeId.includes("verificare")) return "expired";
|
||||||
|
return "expired";
|
||||||
|
}
|
||||||
|
if (daysRemaining <= 2) return "active";
|
||||||
|
return "upcoming";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusText(
|
||||||
|
status: TimelineMilestone["status"],
|
||||||
|
daysRemaining: number,
|
||||||
|
): string {
|
||||||
|
switch (status) {
|
||||||
|
case "completed":
|
||||||
|
return "Finalizat";
|
||||||
|
case "expired":
|
||||||
|
return daysRemaining < 0
|
||||||
|
? `${Math.abs(daysRemaining)}z depasit`
|
||||||
|
: "Depasit";
|
||||||
|
case "active":
|
||||||
|
return daysRemaining === 0
|
||||||
|
? "Astazi"
|
||||||
|
: `${daysRemaining}z ramase`;
|
||||||
|
case "upcoming":
|
||||||
|
return `${daysRemaining}z ramase`;
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAutoStatusText(
|
||||||
|
typeId: string,
|
||||||
|
status: TimelineMilestone["status"],
|
||||||
|
daysRemaining: number,
|
||||||
|
): string {
|
||||||
|
if (status === "completed") return "Finalizat";
|
||||||
|
if (status === "expired") {
|
||||||
|
if (typeId.includes("verificare")) {
|
||||||
|
return "Expirat — nu se mai pot solicita clarificari";
|
||||||
|
}
|
||||||
|
return `Depasit cu ${Math.abs(daysRemaining)}z`;
|
||||||
|
}
|
||||||
|
if (daysRemaining === 0) return "Astazi";
|
||||||
|
return `${daysRemaining}z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExpiredDescription(
|
||||||
|
typeId: string,
|
||||||
|
status: TimelineMilestone["status"],
|
||||||
|
): string {
|
||||||
|
if (status === "expired" && typeId.includes("verificare")) {
|
||||||
|
return "Posibilitatea de a solicita clarificari sau de a returna documentatia a expirat.";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShortDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function DeadlineTimeline({ deadlines }: DeadlineTimelineProps) {
|
||||||
|
const { mainDeadlines, groups } = useMemo(
|
||||||
|
() => buildTimeline(deadlines),
|
||||||
|
[deadlines],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mainDeadlines.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{mainDeadlines.map((main) => {
|
||||||
|
const milestones = groups.get(main.deadline.id) ?? [];
|
||||||
|
const displayStatus = getDeadlineDisplayStatus(main.deadline);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={main.deadline.id} className="space-y-2">
|
||||||
|
{/* Main deadline header */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border px-3 py-2",
|
||||||
|
main.status === "expired"
|
||||||
|
? "border-destructive/40 bg-destructive/5"
|
||||||
|
: main.status === "active"
|
||||||
|
? "border-amber-300 bg-amber-50/30 dark:border-amber-800 dark:bg-amber-950/20"
|
||||||
|
: main.status === "completed"
|
||||||
|
? "border-muted bg-muted/20"
|
||||||
|
: "border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium">{main.label}</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"text-[10px]",
|
||||||
|
displayStatus.variant === "red" &&
|
||||||
|
"border-red-400 text-red-700 dark:text-red-400",
|
||||||
|
displayStatus.variant === "yellow" &&
|
||||||
|
"border-amber-400 text-amber-700 dark:text-amber-400",
|
||||||
|
displayStatus.variant === "green" &&
|
||||||
|
"border-green-400 text-green-700 dark:text-green-400",
|
||||||
|
displayStatus.variant === "blue" &&
|
||||||
|
"border-blue-400 text-blue-700 dark:text-blue-400",
|
||||||
|
displayStatus.variant === "gray" &&
|
||||||
|
"border-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{displayStatus.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 flex items-center gap-3 text-[10px] text-muted-foreground">
|
||||||
|
<span>Start: {formatShortDate(main.deadline.startDate)}</span>
|
||||||
|
<span>Scadent: {formatShortDate(main.dueDate)}</span>
|
||||||
|
{main.daysRemaining > 0 && main.status !== "completed" && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-medium",
|
||||||
|
main.daysRemaining <= 5
|
||||||
|
? "text-amber-600 dark:text-amber-400"
|
||||||
|
: "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{main.daysRemaining}z ramase
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{main.daysRemaining < 0 && main.status === "expired" && (
|
||||||
|
<span className="font-medium text-destructive">
|
||||||
|
{Math.abs(main.daysRemaining)}z depasit
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{main.deadline.resolutionNote && (
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground italic">
|
||||||
|
{main.deadline.resolutionNote}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
{main.status !== "completed" && (
|
||||||
|
<ProgressBar deadline={main.deadline} status={main.status} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Milestones timeline */}
|
||||||
|
{milestones.length > 0 && (
|
||||||
|
<MilestoneTimeline
|
||||||
|
milestones={milestones}
|
||||||
|
mainDeadline={main.deadline}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Progress bar ──
|
||||||
|
|
||||||
|
function ProgressBar({
|
||||||
|
deadline,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
deadline: TrackedDeadline;
|
||||||
|
status: TimelineMilestone["status"];
|
||||||
|
}) {
|
||||||
|
const now = new Date();
|
||||||
|
now.setHours(0, 0, 0, 0);
|
||||||
|
const start = new Date(deadline.startDate);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
const due = new Date(deadline.dueDate);
|
||||||
|
due.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const total = Math.max(1, due.getTime() - start.getTime());
|
||||||
|
const elapsed = now.getTime() - start.getTime();
|
||||||
|
const pct = Math.min(100, Math.max(0, (elapsed / total) * 100));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 h-1.5 rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-1.5 rounded-full transition-all",
|
||||||
|
status === "expired"
|
||||||
|
? "bg-red-500"
|
||||||
|
: status === "active"
|
||||||
|
? "bg-amber-500"
|
||||||
|
: "bg-green-500",
|
||||||
|
)}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Milestone timeline (horizontal dots along the progress bar) ──
|
||||||
|
|
||||||
|
function MilestoneTimeline({
|
||||||
|
milestones,
|
||||||
|
mainDeadline,
|
||||||
|
}: {
|
||||||
|
milestones: TimelineMilestone[];
|
||||||
|
mainDeadline: TrackedDeadline;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mt-3 space-y-0">
|
||||||
|
{/* Timeline track */}
|
||||||
|
<div className="relative h-6">
|
||||||
|
{/* Base line */}
|
||||||
|
<div className="absolute top-[10px] left-0 right-0 h-px bg-border" />
|
||||||
|
|
||||||
|
{/* Start marker */}
|
||||||
|
<div className="absolute top-[7px] left-0 h-1.5 w-1.5 rounded-full bg-muted-foreground" />
|
||||||
|
|
||||||
|
{/* End marker (main deadline) */}
|
||||||
|
<div className="absolute top-[7px] right-0 h-1.5 w-1.5 rounded-full bg-muted-foreground" />
|
||||||
|
|
||||||
|
{/* Milestone dots */}
|
||||||
|
{milestones.map((ms) => (
|
||||||
|
<div
|
||||||
|
key={ms.deadline.id}
|
||||||
|
className="absolute top-0"
|
||||||
|
style={{ left: `${ms.position * 100}%` }}
|
||||||
|
title={`${ms.label}: ${ms.statusText}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-5 w-5 -ml-2.5 rounded-full border-2 flex items-center justify-center",
|
||||||
|
ms.status === "completed"
|
||||||
|
? "border-green-500 bg-green-100 dark:bg-green-900/40"
|
||||||
|
: ms.status === "expired"
|
||||||
|
? "border-muted-foreground/40 bg-muted"
|
||||||
|
: ms.status === "active"
|
||||||
|
? "border-amber-500 bg-amber-100 dark:bg-amber-900/40"
|
||||||
|
: "border-border bg-background",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ms.status === "completed" && (
|
||||||
|
<span className="text-[8px] text-green-700 dark:text-green-300">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{ms.status === "expired" && (
|
||||||
|
<span className="text-[8px] text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Milestone labels below */}
|
||||||
|
<div className="space-y-1.5 pt-1">
|
||||||
|
{milestones.map((ms) => (
|
||||||
|
<div key={ms.deadline.id} className="flex items-start gap-2 text-[10px]">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-0.5 h-1.5 w-1.5 shrink-0 rounded-full",
|
||||||
|
ms.status === "completed"
|
||||||
|
? "bg-green-500"
|
||||||
|
: ms.status === "expired"
|
||||||
|
? "bg-muted-foreground/40"
|
||||||
|
: ms.status === "active"
|
||||||
|
? "bg-amber-500"
|
||||||
|
: "bg-border",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-medium",
|
||||||
|
ms.status === "expired" && "text-muted-foreground line-through",
|
||||||
|
ms.status === "completed" && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ms.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{formatShortDate(ms.dueDate)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
ms.status === "expired"
|
||||||
|
? "text-muted-foreground italic"
|
||||||
|
: ms.status === "active"
|
||||||
|
? "text-amber-600 dark:text-amber-400 font-medium"
|
||||||
|
: ms.status === "completed"
|
||||||
|
? "text-green-600 dark:text-green-400"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ms.statusText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{ms.description && (
|
||||||
|
<p className="text-muted-foreground italic">{ms.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -168,18 +168,37 @@ export function RegistraturaModule() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const closureInfo: ClosureInfo = {
|
const parentClosureInfo: ClosureInfo = {
|
||||||
resolution: "finalizat",
|
resolution: "finalizat",
|
||||||
reason: "Inchis prin inregistrare conex",
|
reason: "Inchis prin inregistrare conex",
|
||||||
closedBy: "",
|
closedBy: "",
|
||||||
closedAt: new Date().toISOString(),
|
closedAt: new Date().toISOString(),
|
||||||
hadActiveDeadlines: resolvable.length > 0,
|
hadActiveDeadlines: resolvable.length > 0,
|
||||||
|
linkedEntryId: newEntry?.id,
|
||||||
|
linkedEntryNumber: newEntry?.number,
|
||||||
};
|
};
|
||||||
await updateEntry(closesEntryId, {
|
await updateEntry(closesEntryId, {
|
||||||
closureInfo,
|
closureInfo: parentClosureInfo,
|
||||||
trackedDeadlines: updatedDeadlines,
|
trackedDeadlines: updatedDeadlines,
|
||||||
});
|
});
|
||||||
await closeEntry(closesEntryId, false);
|
await closeEntry(closesEntryId, false);
|
||||||
|
|
||||||
|
// Also close the new entry (act administrativ) — it was created to close the parent
|
||||||
|
if (newEntry) {
|
||||||
|
const replyClosureInfo: ClosureInfo = {
|
||||||
|
resolution: "finalizat",
|
||||||
|
reason: `Act administrativ emis — inchide ${parentEntry.number}`,
|
||||||
|
closedBy: "",
|
||||||
|
closedAt: new Date().toISOString(),
|
||||||
|
hadActiveDeadlines: false,
|
||||||
|
linkedEntryId: parentEntry.id,
|
||||||
|
linkedEntryNumber: parentEntry.number,
|
||||||
|
};
|
||||||
|
await updateEntry(newEntry.id, {
|
||||||
|
status: "inchis",
|
||||||
|
closureInfo: replyClosureInfo,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setClosesEntryId(null);
|
setClosesEntryId(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { findAuthorityForContact } from "../services/authority-catalog";
|
|||||||
import { computeTransmissionStatus } from "../services/deadline-service";
|
import { computeTransmissionStatus } from "../services/deadline-service";
|
||||||
import { StatusMonitorConfig } from "./status-monitor-config";
|
import { StatusMonitorConfig } from "./status-monitor-config";
|
||||||
import { FlowDiagram } from "./flow-diagram";
|
import { FlowDiagram } from "./flow-diagram";
|
||||||
|
import { DeadlineTimeline } from "./deadline-timeline";
|
||||||
|
|
||||||
interface RegistryEntryDetailProps {
|
interface RegistryEntryDetailProps {
|
||||||
entry: RegistryEntry | null;
|
entry: RegistryEntry | null;
|
||||||
@@ -584,58 +585,10 @@ export function RegistryEntryDetail({
|
|||||||
</DetailSection>
|
</DetailSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Legal deadlines ── */}
|
{/* ── Legal deadlines (timeline view) ── */}
|
||||||
{(entry.trackedDeadlines ?? []).length > 0 && (
|
{(entry.trackedDeadlines ?? []).length > 0 && (
|
||||||
<DetailSection
|
<DetailSection title="Termene legale">
|
||||||
title={`Termene legale (${entry.trackedDeadlines!.length})`}
|
<DeadlineTimeline deadlines={entry.trackedDeadlines!} />
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{entry.trackedDeadlines!.map((dl) => (
|
|
||||||
<div
|
|
||||||
key={dl.id}
|
|
||||||
className={cn(
|
|
||||||
"rounded-md border px-3 py-2 text-xs",
|
|
||||||
dl.resolution === "pending" &&
|
|
||||||
new Date(dl.dueDate) < new Date()
|
|
||||||
? "border-destructive/50 bg-destructive/5"
|
|
||||||
: dl.resolution === "pending"
|
|
||||||
? "border-amber-300 dark:border-amber-700 bg-amber-50/50 dark:bg-amber-950/20"
|
|
||||||
: "border-muted",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-medium">{dl.typeId}</span>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"text-[10px]",
|
|
||||||
dl.resolution === "pending" &&
|
|
||||||
"border-amber-400 text-amber-700 dark:text-amber-300",
|
|
||||||
dl.resolution === "completed" &&
|
|
||||||
"border-green-400 text-green-700 dark:text-green-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{DEADLINE_RES_LABELS[dl.resolution] ?? dl.resolution}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-4 mt-1 text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
<Calendar className="mr-0.5 inline h-2.5 w-2.5" />
|
|
||||||
Start: {formatDate(dl.startDate)}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<Clock className="mr-0.5 inline h-2.5 w-2.5" />
|
|
||||||
Scadent: {formatDate(dl.dueDate)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{dl.resolutionNote && (
|
|
||||||
<p className="mt-1 text-muted-foreground">
|
|
||||||
{dl.resolutionNote}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
import type { DeadlineCategory, DeadlineResolution } from '../types';
|
|
||||||
|
|
||||||
export interface DeadlineFilters {
|
|
||||||
category: DeadlineCategory | 'all';
|
|
||||||
resolution: DeadlineResolution | 'all';
|
|
||||||
urgentOnly: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeadlineFilters() {
|
|
||||||
const [filters, setFilters] = useState<DeadlineFilters>({
|
|
||||||
category: 'all',
|
|
||||||
resolution: 'all',
|
|
||||||
urgentOnly: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateFilter = useCallback(<K extends keyof DeadlineFilters>(key: K, value: DeadlineFilters[K]) => {
|
|
||||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetFilters = useCallback(() => {
|
|
||||||
setFilters({ category: 'all', resolution: 'all', urgentOnly: false });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { filters, updateFilter, resetFilters };
|
|
||||||
}
|
|
||||||
@@ -275,14 +275,24 @@ export function groupDeadlinesByEntry(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if there are any user-selectable (non-autoTrack) deadlines
|
||||||
|
const hasUserDeadlines = deadlines.some((dl) => {
|
||||||
|
const d = getDeadlineType(dl.typeId);
|
||||||
|
return !d?.autoTrack && !d?.backgroundOnly && !dl.chainParentId;
|
||||||
|
});
|
||||||
|
|
||||||
// Second pass: build main deadline items
|
// Second pass: build main deadline items
|
||||||
for (const dl of deadlines) {
|
for (const dl of deadlines) {
|
||||||
const def = getDeadlineType(dl.typeId);
|
const def = getDeadlineType(dl.typeId);
|
||||||
if (def?.backgroundOnly) continue;
|
if (def?.backgroundOnly) continue;
|
||||||
if (dl.chainParentId) continue; // chain children are nested
|
if (dl.chainParentId) continue; // chain children are nested
|
||||||
|
|
||||||
// Auto-tracked without chain parent: treat as main if pending, skip if resolved
|
// Auto-tracked: hide when there are user-selectable deadlines (they appear as milestones)
|
||||||
if (def?.autoTrack && dl.resolution !== "pending") continue;
|
// Only show auto-tracked as standalone when there are NO user deadlines on the entry
|
||||||
|
if (def?.autoTrack) {
|
||||||
|
if (hasUserDeadlines) continue;
|
||||||
|
if (dl.resolution !== "pending") continue;
|
||||||
|
}
|
||||||
|
|
||||||
mainItems.push({
|
mainItems.push({
|
||||||
deadline: dl,
|
deadline: dl,
|
||||||
|
|||||||
Reference in New Issue
Block a user