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 on the timeline (0..1) */
|
||||||
position: number;
|
position: number;
|
||||||
/** Status of this milestone */
|
/** Status of this milestone */
|
||||||
status: "completed" | "active" | "expired" | "upcoming" | "background";
|
status: "completed" | "active" | "expired" | "upcoming" | "background" | "paused";
|
||||||
/** Human-readable status text */
|
/** Human-readable status text */
|
||||||
statusText: string;
|
statusText: string;
|
||||||
/** Whether this is the main (user-created) deadline or an auto-tracked milestone */
|
/** Whether this is the main (user-created) deadline or an auto-tracked milestone */
|
||||||
@@ -189,6 +189,7 @@ function getMainStatus(
|
|||||||
): TimelineMilestone["status"] {
|
): TimelineMilestone["status"] {
|
||||||
if (dl.resolution === "completed") return "completed";
|
if (dl.resolution === "completed") return "completed";
|
||||||
if (dl.resolution !== "pending") return "completed";
|
if (dl.resolution !== "pending") return "completed";
|
||||||
|
if (dl.pausedAt) return "paused";
|
||||||
if (daysRemaining < 0) return "expired";
|
if (daysRemaining < 0) return "expired";
|
||||||
if (daysRemaining <= 5) return "active";
|
if (daysRemaining <= 5) return "active";
|
||||||
return "upcoming";
|
return "upcoming";
|
||||||
@@ -216,6 +217,8 @@ function getStatusText(
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case "completed":
|
case "completed":
|
||||||
return "Finalizat";
|
return "Finalizat";
|
||||||
|
case "paused":
|
||||||
|
return "Suspendat — așteptare completări";
|
||||||
case "expired":
|
case "expired":
|
||||||
return daysRemaining < 0
|
return daysRemaining < 0
|
||||||
? `${Math.abs(daysRemaining)}z depasit`
|
? `${Math.abs(daysRemaining)}z depasit`
|
||||||
@@ -252,7 +255,10 @@ function getExpiredDescription(
|
|||||||
status: TimelineMilestone["status"],
|
status: TimelineMilestone["status"],
|
||||||
): string {
|
): string {
|
||||||
if (status === "expired" && typeId.includes("verificare")) {
|
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 "";
|
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 ──
|
// ── Component ──
|
||||||
|
|
||||||
export function DeadlineTimeline({ deadlines }: DeadlineTimelineProps) {
|
export function DeadlineTimeline({ deadlines }: DeadlineTimelineProps) {
|
||||||
@@ -290,6 +308,8 @@ export function DeadlineTimeline({ deadlines }: DeadlineTimelineProps) {
|
|||||||
"rounded-lg border px-3 py-2",
|
"rounded-lg border px-3 py-2",
|
||||||
main.status === "expired"
|
main.status === "expired"
|
||||||
? "border-destructive/40 bg-destructive/5"
|
? "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"
|
: main.status === "active"
|
||||||
? "border-amber-300 bg-amber-50/30 dark:border-amber-800 dark:bg-amber-950/20"
|
? "border-amber-300 bg-amber-50/30 dark:border-amber-800 dark:bg-amber-950/20"
|
||||||
: main.status === "completed"
|
: main.status === "completed"
|
||||||
@@ -319,10 +339,22 @@ export function DeadlineTimeline({ deadlines }: DeadlineTimelineProps) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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>Start: {formatShortDate(main.deadline.startDate)}</span>
|
||||||
<span>Scadent: {formatShortDate(main.dueDate)}</span>
|
<span title={`Data maximă: ${formatFullDate(main.dueDate)}`}>
|
||||||
{main.daysRemaining > 0 && main.status !== "completed" && (
|
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
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-medium",
|
"font-medium",
|
||||||
@@ -341,6 +373,12 @@ export function DeadlineTimeline({ deadlines }: DeadlineTimelineProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{main.deadline.resolutionNote && (
|
||||||
<p className="mt-1 text-[10px] text-muted-foreground italic">
|
<p className="mt-1 text-[10px] text-muted-foreground italic">
|
||||||
{main.deadline.resolutionNote}
|
{main.deadline.resolutionNote}
|
||||||
@@ -394,6 +432,8 @@ function ProgressBar({
|
|||||||
"h-1.5 rounded-full transition-all",
|
"h-1.5 rounded-full transition-all",
|
||||||
status === "expired"
|
status === "expired"
|
||||||
? "bg-red-500"
|
? "bg-red-500"
|
||||||
|
: status === "paused"
|
||||||
|
? "bg-blue-400 animate-pulse"
|
||||||
: status === "active"
|
: status === "active"
|
||||||
? "bg-amber-500"
|
? "bg-amber-500"
|
||||||
: "bg-green-500",
|
: "bg-green-500",
|
||||||
@@ -432,7 +472,7 @@ function MilestoneTimeline({
|
|||||||
key={ms.deadline.id}
|
key={ms.deadline.id}
|
||||||
className="absolute top-0"
|
className="absolute top-0"
|
||||||
style={{ left: `${ms.position * 100}%` }}
|
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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ import {
|
|||||||
aggregateDeadlines,
|
aggregateDeadlines,
|
||||||
findAutoResolvableDeadlines,
|
findAutoResolvableDeadlines,
|
||||||
resolveDeadline as resolveDeadlinePure,
|
resolveDeadline as resolveDeadlinePure,
|
||||||
|
pauseDeadline,
|
||||||
|
resumeDeadline,
|
||||||
} from "../services/deadline-service";
|
} from "../services/deadline-service";
|
||||||
import type { RegistryEntry, DeadlineResolution, ClosureInfo } from "../types";
|
import type { RegistryEntry, DeadlineResolution, ClosureInfo } from "../types";
|
||||||
import type { AddressContact } from "@/modules/address-book/types";
|
import type { AddressContact } from "@/modules/address-book/types";
|
||||||
@@ -204,6 +206,51 @@ export function RegistraturaModule() {
|
|||||||
}
|
}
|
||||||
setClosesEntryId(null);
|
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);
|
setReplyToEntry(null);
|
||||||
setConexToEntry(null);
|
setConexToEntry(null);
|
||||||
setViewMode("list");
|
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.
|
* Get the display status for a tracked deadline — color coding + label.
|
||||||
*/
|
*/
|
||||||
@@ -83,6 +147,11 @@ export function getDeadlineDisplayStatus(
|
|||||||
): DeadlineDisplayStatus {
|
): DeadlineDisplayStatus {
|
||||||
const def = getDeadlineType(deadline.typeId);
|
const def = getDeadlineType(deadline.typeId);
|
||||||
|
|
||||||
|
// Paused — waiting for completions
|
||||||
|
if (deadline.pausedAt && deadline.resolution === "pending") {
|
||||||
|
return { label: "Suspendat", variant: "blue", daysRemaining: null };
|
||||||
|
}
|
||||||
|
|
||||||
// Already resolved
|
// Already resolved
|
||||||
if (deadline.resolution !== "pending") {
|
if (deadline.resolution !== "pending") {
|
||||||
if (deadline.resolution === "aprobat-tacit") {
|
if (deadline.resolution === "aprobat-tacit") {
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export interface DeadlineTypeDef {
|
|||||||
|
|
||||||
/** Audit log entry for deadline changes */
|
/** Audit log entry for deadline changes */
|
||||||
export interface DeadlineAuditEntry {
|
export interface DeadlineAuditEntry {
|
||||||
action: "created" | "resolved" | "modified" | "recipient-registered";
|
action: "created" | "resolved" | "modified" | "recipient-registered" | "paused" | "resumed";
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
/** User who performed the action (SSO name when available) */
|
/** User who performed the action (SSO name when available) */
|
||||||
actor?: string;
|
actor?: string;
|
||||||
@@ -182,6 +182,12 @@ export interface TrackedDeadline {
|
|||||||
/** Mini audit log — tracks who created/modified/resolved this deadline */
|
/** Mini audit log — tracks who created/modified/resolved this deadline */
|
||||||
auditLog?: DeadlineAuditEntry[];
|
auditLog?: DeadlineAuditEntry[];
|
||||||
createdAt: string;
|
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 ──
|
// ── AC (Autorizație de Construire) validity tracking ──
|
||||||
|
|||||||
Reference in New Issue
Block a user