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:
@@ -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,11 +308,13 @@ export function DeadlineTimeline({ deadlines }: DeadlineTimelineProps) {
|
||||
"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",
|
||||
: 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"
|
||||
? "border-muted bg-muted/20"
|
||||
: "border-border",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -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,9 +432,11 @@ function ProgressBar({
|
||||
"h-1.5 rounded-full transition-all",
|
||||
status === "expired"
|
||||
? "bg-red-500"
|
||||
: status === "active"
|
||||
? "bg-amber-500"
|
||||
: "bg-green-500",
|
||||
: status === "paused"
|
||||
? "bg-blue-400 animate-pulse"
|
||||
: status === "active"
|
||||
? "bg-amber-500"
|
||||
: "bg-green-500",
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
Reference in New Issue
Block a user