feat: milestone dots on dashboard progress bar with legend
- Progress bar now shows auto-tracked sub-deadline milestones as dots - Passed milestones: grayed out dot + strikethrough label - Active milestones: amber dot with descriptive label - Each milestone has a tooltip with context-aware text (e.g., "Verificare expirata — nu se mai pot solicita clarificari") - Legend below progress bar shows all milestone labels - Wider progress bar (w-32) to accommodate milestone dots Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -291,6 +291,7 @@ function EntryDeadlineCard({
|
||||
deadline={item.deadline}
|
||||
status={item.status}
|
||||
entryId={entry.id}
|
||||
allDeadlines={entry.trackedDeadlines ?? []}
|
||||
chainCount={chainCount}
|
||||
isExpanded={isExpanded}
|
||||
onToggleChain={() => onToggleChain(item.deadline.id)}
|
||||
@@ -319,10 +320,88 @@ function EntryDeadlineCard({
|
||||
|
||||
// ── Single deadline row ──
|
||||
|
||||
// ── Milestone data for progress bar ──
|
||||
|
||||
interface ProgressMilestone {
|
||||
position: number; // 0..1 on the progress bar
|
||||
label: string;
|
||||
isPassed: boolean;
|
||||
typeId: string;
|
||||
}
|
||||
|
||||
function computeMilestones(
|
||||
mainDeadline: TrackedDeadline,
|
||||
allDeadlines: TrackedDeadline[],
|
||||
): ProgressMilestone[] {
|
||||
const mainStart = new Date(mainDeadline.startDate).getTime();
|
||||
const mainDue = new Date(mainDeadline.dueDate).getTime();
|
||||
const totalSpan = mainDue - mainStart;
|
||||
if (totalSpan <= 0) return [];
|
||||
|
||||
const now = Date.now();
|
||||
const milestones: ProgressMilestone[] = [];
|
||||
|
||||
for (const dl of allDeadlines) {
|
||||
if (dl.id === mainDeadline.id) continue;
|
||||
const def = getDeadlineType(dl.typeId);
|
||||
if (!def?.autoTrack) continue;
|
||||
if (def.backgroundOnly) continue;
|
||||
|
||||
const dlDue = new Date(dl.dueDate).getTime();
|
||||
// Only show milestones that fall within the main deadline's time range
|
||||
if (dlDue <= mainStart || dlDue >= mainDue) continue;
|
||||
|
||||
const pos = (dlDue - mainStart) / totalSpan;
|
||||
if (pos < 0.03 || pos > 0.97) continue; // too close to edges
|
||||
|
||||
milestones.push({
|
||||
position: pos,
|
||||
label: getMilestoneLabel(def.id, dlDue < now),
|
||||
isPassed: dlDue < now,
|
||||
typeId: def.id,
|
||||
});
|
||||
}
|
||||
|
||||
milestones.sort((a, b) => a.position - b.position);
|
||||
return milestones;
|
||||
}
|
||||
|
||||
function getMilestoneLabel(typeId: string, isPassed: boolean): string {
|
||||
if (typeId.includes("verificare")) {
|
||||
return isPassed
|
||||
? "Verificare expirata — nu se mai pot solicita clarificari"
|
||||
: "Termen verificare cerere";
|
||||
}
|
||||
if (typeId.includes("completari-limit")) {
|
||||
return isPassed
|
||||
? "Termen solicitare completari expirat"
|
||||
: "Limita solicitare completari";
|
||||
}
|
||||
if (typeId.includes("comunicare")) {
|
||||
return isPassed ? "Termen comunicare expirat" : "Termen comunicare";
|
||||
}
|
||||
if (typeId.includes("solicitare-aviz")) {
|
||||
return isPassed
|
||||
? "Termen solicitare aviz expirat"
|
||||
: "Solicitare aviz primar";
|
||||
}
|
||||
if (typeId.includes("aviz-primar")) {
|
||||
return isPassed ? "Aviz primar expirat" : "Aviz primar";
|
||||
}
|
||||
if (typeId.includes("depunere-comisie")) {
|
||||
return isPassed ? "Depunere comisie expirata" : "Depunere la comisie";
|
||||
}
|
||||
const def = getDeadlineType(typeId);
|
||||
return def?.label ?? typeId;
|
||||
}
|
||||
|
||||
// ── Single deadline row ──
|
||||
|
||||
function DeadlineRow({
|
||||
deadline,
|
||||
status,
|
||||
entryId,
|
||||
allDeadlines,
|
||||
chainCount,
|
||||
isExpanded,
|
||||
isChainChild,
|
||||
@@ -332,6 +411,7 @@ function DeadlineRow({
|
||||
deadline: TrackedDeadline;
|
||||
status: DeadlineDisplayStatus;
|
||||
entryId: string;
|
||||
allDeadlines?: TrackedDeadline[];
|
||||
chainCount?: number;
|
||||
isExpanded?: boolean;
|
||||
isChainChild?: boolean;
|
||||
@@ -358,98 +438,150 @@ function DeadlineRow({
|
||||
? Math.min(100, Math.max(0, (elapsedDays / totalDays) * 100))
|
||||
: 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-2",
|
||||
isChainChild && "bg-muted/30 pl-8",
|
||||
)}
|
||||
>
|
||||
{/* Chain expand toggle */}
|
||||
{!isChainChild && (chainCount ?? 0) > 0 ? (
|
||||
<button
|
||||
onClick={onToggleChain}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
) : isChainChild ? (
|
||||
<span className="text-muted-foreground/40 text-xs">└</span>
|
||||
) : (
|
||||
<span className="w-3.5" />
|
||||
)}
|
||||
// Milestones on the progress bar (auto-tracked sub-deadlines)
|
||||
const milestones = isPending && allDeadlines
|
||||
? computeMilestones(deadline, allDeadlines)
|
||||
: [];
|
||||
|
||||
{/* Type label */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className={cn("text-xs", isChainChild && "text-muted-foreground")}>
|
||||
{def?.label ?? deadline.typeId}
|
||||
</span>
|
||||
{!isChainChild && (chainCount ?? 0) > 0 && (
|
||||
<span className="ml-2 text-[10px] text-muted-foreground">
|
||||
(+{chainCount} etap{chainCount === 1 ? "a" : "e"})
|
||||
</span>
|
||||
return (
|
||||
<div className={cn("px-4 py-2", isChainChild && "bg-muted/30 pl-8")}>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Chain expand toggle */}
|
||||
{!isChainChild && (chainCount ?? 0) > 0 ? (
|
||||
<button
|
||||
onClick={onToggleChain}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
) : isChainChild ? (
|
||||
<span className="text-muted-foreground/40 text-xs">{"\u2514"}</span>
|
||||
) : (
|
||||
<span className="w-3.5" />
|
||||
)}
|
||||
|
||||
{/* Type label */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className={cn("text-xs", isChainChild && "text-muted-foreground")}>
|
||||
{def?.label ?? deadline.typeId}
|
||||
</span>
|
||||
{!isChainChild && (chainCount ?? 0) > 0 && (
|
||||
<span className="ml-2 text-[10px] text-muted-foreground">
|
||||
(+{chainCount} etap{chainCount === 1 ? "a" : "e"})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar with milestones (only for pending) */}
|
||||
{isPending && (
|
||||
<div className="hidden w-32 sm:block">
|
||||
<div className="relative h-4">
|
||||
{/* Track */}
|
||||
<div className="absolute top-[7px] left-0 right-0 h-1.5 rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-1.5 rounded-full transition-all",
|
||||
status.variant === "red"
|
||||
? "bg-red-500"
|
||||
: status.variant === "yellow"
|
||||
? "bg-amber-500"
|
||||
: "bg-green-500",
|
||||
)}
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
{/* Milestone dots */}
|
||||
{milestones.map((ms) => (
|
||||
<div
|
||||
key={ms.typeId}
|
||||
className="absolute top-[4px] -ml-[4px]"
|
||||
style={{ left: `${ms.position * 100}%` }}
|
||||
title={ms.label}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full border",
|
||||
ms.isPassed
|
||||
? "border-muted-foreground/50 bg-muted-foreground/30"
|
||||
: "border-amber-500 bg-amber-400",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Countdown / status */}
|
||||
<div className="shrink-0 text-right">
|
||||
{isPending && status.daysRemaining !== null ? (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
VARIANT_TEXT[status.variant],
|
||||
)}
|
||||
>
|
||||
{status.daysRemaining < 0
|
||||
? `${Math.abs(status.daysRemaining)}z depasit`
|
||||
: status.daysRemaining === 0
|
||||
? "azi"
|
||||
: `${status.daysRemaining}z ramase`}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{status.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resolve button */}
|
||||
<div className="w-7 shrink-0">
|
||||
{isPending && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-green-600"
|
||||
onClick={() => onResolve(entryId, deadline)}
|
||||
title="Rezolva"
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar (only for pending) */}
|
||||
{isPending && (
|
||||
<div className="hidden w-24 sm:block">
|
||||
<div className="h-1.5 rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-1.5 rounded-full transition-all",
|
||||
status.variant === "red"
|
||||
? "bg-red-500"
|
||||
: status.variant === "yellow"
|
||||
? "bg-amber-500"
|
||||
: "bg-green-500",
|
||||
)}
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
{/* Milestone legend (below progress bar, only when milestones exist) */}
|
||||
{milestones.length > 0 && isPending && (
|
||||
<div className="ml-7 mt-1 hidden sm:flex flex-wrap gap-x-3 gap-y-0.5">
|
||||
{milestones.map((ms) => (
|
||||
<div key={ms.typeId} className="flex items-center gap-1 text-[9px]">
|
||||
<span
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full",
|
||||
ms.isPassed
|
||||
? "bg-muted-foreground/40"
|
||||
: "bg-amber-400",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
ms.isPassed
|
||||
? "text-muted-foreground line-through"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{ms.isPassed
|
||||
? getMilestoneLabel(ms.typeId, true)
|
||||
: getMilestoneLabel(ms.typeId, false)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Countdown / status */}
|
||||
<div className="shrink-0 text-right">
|
||||
{isPending && status.daysRemaining !== null ? (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
VARIANT_TEXT[status.variant],
|
||||
)}
|
||||
>
|
||||
{status.daysRemaining < 0
|
||||
? `${Math.abs(status.daysRemaining)}z depasit`
|
||||
: status.daysRemaining === 0
|
||||
? "azi"
|
||||
: `${status.daysRemaining}z ramase`}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{status.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resolve button */}
|
||||
<div className="w-7 shrink-0">
|
||||
{isPending && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-green-600"
|
||||
onClick={() => onResolve(entryId, deadline)}
|
||||
title="Rezolva"
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user