feat: deadline pause/resume on clarifications + enhanced timeline UX

- TrackedDeadline gains pausedAt + totalPausedDays fields
- pauseDeadline() / resumeDeadline() in deadline-service with audit log
- Auto-pause: when incoming conex linked to parent with active deadlines
- Auto-resume: when outgoing conex linked to parent with paused deadlines
  (shifts dueDate forward by paused days)
- Timeline shows "Suspendat" state with blue pulsing progress bar
- Milestone tooltips now show exact dates (hover: "Data maximă: ...")
- ISC warning text on expired emission deadlines
- Verification expired text matches PDF spec
- DeadlineAuditEntry gains "paused" | "resumed" action types
- getDeadlineDisplayStatus returns "Suspendat" (blue) for paused deadlines

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-12 17:54:19 +02:00
parent 1361534c98
commit c892e8d820
4 changed files with 177 additions and 15 deletions
@@ -17,7 +17,7 @@ interface TimelineMilestone {
/** Position on the timeline (0..1) */
position: number;
/** Status of this milestone */
status: "completed" | "active" | "expired" | "upcoming" | "background";
status: "completed" | "active" | "expired" | "upcoming" | "background" | "paused";
/** Human-readable status text */
statusText: string;
/** Whether this is the main (user-created) deadline or an auto-tracked milestone */
@@ -189,6 +189,7 @@ function getMainStatus(
): TimelineMilestone["status"] {
if (dl.resolution === "completed") return "completed";
if (dl.resolution !== "pending") return "completed";
if (dl.pausedAt) return "paused";
if (daysRemaining < 0) return "expired";
if (daysRemaining <= 5) return "active";
return "upcoming";
@@ -216,6 +217,8 @@ function getStatusText(
switch (status) {
case "completed":
return "Finalizat";
case "paused":
return "Suspendat — așteptare completări";
case "expired":
return daysRemaining < 0
? `${Math.abs(daysRemaining)}z depasit`
@@ -252,7 +255,10 @@ function getExpiredDescription(
status: TimelineMilestone["status"],
): string {
if (status === "expired" && typeId.includes("verificare")) {
return "Posibilitatea de a solicita clarificari sau de a returna documentatia a expirat.";
return "Termenul de verificare a expirat — nu se mai pot solicita clarificari si nu se mai poate restitui dosarul.";
}
if (status === "expired" && (typeId.includes("emitere") || typeId.includes("ac-emitere"))) {
return "Termenul legal de emitere a fost depasit — poate fi sesizat ISC (sanctiuni).";
}
return "";
}
@@ -266,6 +272,18 @@ function formatShortDate(iso: string): string {
}
}
function formatFullDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("ro-RO", {
day: "2-digit",
month: "long",
year: "numeric",
});
} catch {
return iso;
}
}
// ── Component ──
export function DeadlineTimeline({ deadlines }: DeadlineTimelineProps) {
@@ -290,6 +308,8 @@ export function DeadlineTimeline({ deadlines }: DeadlineTimelineProps) {
"rounded-lg border px-3 py-2",
main.status === "expired"
? "border-destructive/40 bg-destructive/5"
: main.status === "paused"
? "border-blue-400/40 bg-blue-50/30 dark:border-blue-800 dark:bg-blue-950/20"
: main.status === "active"
? "border-amber-300 bg-amber-50/30 dark:border-amber-800 dark:bg-amber-950/20"
: main.status === "completed"
@@ -319,10 +339,22 @@ export function DeadlineTimeline({ deadlines }: DeadlineTimelineProps) {
</Badge>
</div>
<div className="mt-1 flex items-center gap-3 text-[10px] text-muted-foreground">
<div className="mt-1 flex flex-wrap 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 title={`Data maximă: ${formatFullDate(main.dueDate)}`}>
Scadent: {formatShortDate(main.dueDate)}
</span>
{main.status === "paused" && (
<span className="font-medium text-blue-600 dark:text-blue-400">
Suspendat așteptare completări
</span>
)}
{(main.deadline.totalPausedDays ?? 0) > 0 && (
<span className="text-blue-500/70" title="Total zile in care termenul a fost suspendat">
({main.deadline.totalPausedDays}z suspendate)
</span>
)}
{main.daysRemaining > 0 && main.status !== "completed" && main.status !== "paused" && (
<span
className={cn(
"font-medium",
@@ -341,6 +373,12 @@ export function DeadlineTimeline({ deadlines }: DeadlineTimelineProps) {
)}
</div>
{main.status === "expired" && main.deadline.typeId.includes("emitere") && (
<p className="mt-1 text-[10px] font-medium text-destructive">
Termenul legal de emitere a fost depasit poate fi sesizat ISC (sanctiuni)
</p>
)}
{main.deadline.resolutionNote && (
<p className="mt-1 text-[10px] text-muted-foreground italic">
{main.deadline.resolutionNote}
@@ -394,6 +432,8 @@ function ProgressBar({
"h-1.5 rounded-full transition-all",
status === "expired"
? "bg-red-500"
: status === "paused"
? "bg-blue-400 animate-pulse"
: status === "active"
? "bg-amber-500"
: "bg-green-500",
@@ -432,7 +472,7 @@ function MilestoneTimeline({
key={ms.deadline.id}
className="absolute top-0"
style={{ left: `${ms.position * 100}%` }}
title={`${ms.label}: ${ms.statusText}`}
title={`${ms.label}\nData maximă: ${formatFullDate(ms.dueDate)}\n${ms.statusText}${ms.description ? `\n${ms.description}` : ""}`}
>
<div
className={cn(
@@ -46,6 +46,8 @@ import {
aggregateDeadlines,
findAutoResolvableDeadlines,
resolveDeadline as resolveDeadlinePure,
pauseDeadline,
resumeDeadline,
} from "../services/deadline-service";
import type { RegistryEntry, DeadlineResolution, ClosureInfo } from "../types";
import type { AddressContact } from "@/modules/address-book/types";
@@ -204,6 +206,51 @@ export function RegistraturaModule() {
}
setClosesEntryId(null);
}
// ── Deadline pause/resume on conex linking ──
if (conexToEntry && !closesEntryId) {
const parentDeadlines = conexToEntry.trackedDeadlines ?? [];
const hasPendingDeadlines = parentDeadlines.some(
(dl) => dl.resolution === "pending",
);
if (hasPendingDeadlines) {
// Incoming conex (clarification request received) → pause deadlines
if (data.direction === "intrat") {
const hasPausable = parentDeadlines.some(
(dl) => dl.resolution === "pending" && !dl.pausedAt,
);
if (hasPausable) {
const pausedDeadlines = parentDeadlines.map((dl) =>
dl.resolution === "pending" && !dl.pausedAt
? pauseDeadline(dl)
: dl,
);
await updateEntry(conexToEntry.id, {
trackedDeadlines: pausedDeadlines,
});
}
}
// Outgoing conex (completions submitted) → resume paused deadlines
if (data.direction === "iesit") {
const hasPaused = parentDeadlines.some(
(dl) => dl.resolution === "pending" && dl.pausedAt,
);
if (hasPaused) {
const resumedDeadlines = parentDeadlines.map((dl) =>
dl.resolution === "pending" && dl.pausedAt
? resumeDeadline(dl, data.date)
: dl,
);
await updateEntry(conexToEntry.id, {
trackedDeadlines: resumedDeadlines,
});
}
}
}
}
setReplyToEntry(null);
setConexToEntry(null);
setViewMode("list");
@@ -75,6 +75,70 @@ export function resolveDeadline(
};
}
/**
* Pause a deadline (e.g., when a clarification request is linked).
* Records the pause date and adds audit entry.
*/
export function pauseDeadline(deadline: TrackedDeadline): TrackedDeadline {
if (deadline.pausedAt) return deadline; // already paused
const now = new Date().toISOString();
return {
...deadline,
pausedAt: now,
auditLog: [
...(deadline.auditLog ?? []),
{
action: "paused" as DeadlineAuditEntry["action"],
timestamp: now,
detail: "Termen suspendat — solicitare clarificări primită",
},
],
};
}
/**
* Resume a paused deadline (e.g., when completions are submitted).
* Adds the paused days to totalPausedDays and shifts dueDate forward.
*/
export function resumeDeadline(
deadline: TrackedDeadline,
resumeDate?: string,
): TrackedDeadline {
if (!deadline.pausedAt) return deadline; // not paused
const now = resumeDate ? new Date(resumeDate) : new Date();
now.setHours(0, 0, 0, 0);
const pausedStart = new Date(deadline.pausedAt);
pausedStart.setHours(0, 0, 0, 0);
const pausedDays = Math.max(
0,
Math.round((now.getTime() - pausedStart.getTime()) / (1000 * 60 * 60 * 24)),
);
// Shift due date forward by the number of paused days
const oldDue = new Date(deadline.dueDate);
oldDue.setHours(0, 0, 0, 0);
oldDue.setDate(oldDue.getDate() + pausedDays);
const newDueDate = formatDate(oldDue);
const totalPaused = (deadline.totalPausedDays ?? 0) + pausedDays;
const ts = new Date().toISOString();
return {
...deadline,
pausedAt: undefined,
totalPausedDays: totalPaused,
dueDate: newDueDate,
auditLog: [
...(deadline.auditLog ?? []),
{
action: "resumed" as DeadlineAuditEntry["action"],
timestamp: ts,
detail: `Termen reluat — ${pausedDays} zile suspendate, nou termen: ${newDueDate}`,
},
],
};
}
/**
* Get the display status for a tracked deadline — color coding + label.
*/
@@ -83,6 +147,11 @@ export function getDeadlineDisplayStatus(
): DeadlineDisplayStatus {
const def = getDeadlineType(deadline.typeId);
// Paused — waiting for completions
if (deadline.pausedAt && deadline.resolution === "pending") {
return { label: "Suspendat", variant: "blue", daysRemaining: null };
}
// Already resolved
if (deadline.resolution !== "pending") {
if (deadline.resolution === "aprobat-tacit") {
+7 -1
View File
@@ -163,7 +163,7 @@ export interface DeadlineTypeDef {
/** Audit log entry for deadline changes */
export interface DeadlineAuditEntry {
action: "created" | "resolved" | "modified" | "recipient-registered";
action: "created" | "resolved" | "modified" | "recipient-registered" | "paused" | "resumed";
timestamp: string;
/** User who performed the action (SSO name when available) */
actor?: string;
@@ -182,6 +182,12 @@ export interface TrackedDeadline {
/** Mini audit log — tracks who created/modified/resolved this deadline */
auditLog?: DeadlineAuditEntry[];
createdAt: string;
// ── Pause/resume for clarification requests ──
/** ISO date when deadline was paused (solicitare clarificari linked) */
pausedAt?: string;
/** Total calendar days the deadline has been paused across all pause periods */
totalPausedDays?: number;
}
// ── AC (Autorizație de Construire) validity tracking ──