3c0befb7db
The tooltip shown when hovering over project strips in the timeline now includes additional information: - Role name (e.g. "3D Artist", "Project Manager") - Assignment date range (2026-03-01 → 2026-06-30) - Status badge when not CONFIRMED (shows PROPOSED, DRAFT, etc.) - Lead person and order type on the same line Data comes from already-loaded timeline entries — no extra API calls. Safe change: tooltip is pointer-events-none and read-only. Co-Authored-By: claude-flow <ruv@ruv.net>
216 lines
8.0 KiB
TypeScript
216 lines
8.0 KiB
TypeScript
"use client";
|
|
|
|
import { formatDateLong } from "~/lib/format.js";
|
|
|
|
export type HeatmapHoverData = {
|
|
date: Date;
|
|
totalH: number;
|
|
pct: number;
|
|
breakdown: {
|
|
projectId: string;
|
|
shortCode: string;
|
|
projectName: string;
|
|
orderType: string;
|
|
hoursPerDay: number;
|
|
responsiblePerson?: string | null;
|
|
role?: string | null;
|
|
status?: string;
|
|
startDate?: string;
|
|
endDate?: string;
|
|
}[];
|
|
};
|
|
|
|
export type VacationHoverData = {
|
|
type: string;
|
|
startDate: Date | string;
|
|
endDate: Date | string;
|
|
note?: string | null;
|
|
requestedBy?: { name?: string | null; email: string } | null;
|
|
approvedBy?: { name?: string | null; email: string } | null;
|
|
approvedAt?: Date | string | null;
|
|
};
|
|
|
|
interface TimelineTooltipProps {
|
|
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
|
|
heatmapTooltipPos: { left: number; top: number };
|
|
vacationTooltipRef: React.RefObject<HTMLDivElement | null>;
|
|
vacationTooltipPos: { left: number; top: number };
|
|
heatmapHover: HeatmapHoverData | null;
|
|
vacationHover: VacationHoverData | null;
|
|
}
|
|
|
|
export function TimelineTooltip({
|
|
heatmapTooltipRef,
|
|
heatmapTooltipPos,
|
|
vacationTooltipRef,
|
|
vacationTooltipPos,
|
|
heatmapHover,
|
|
vacationHover,
|
|
}: TimelineTooltipProps) {
|
|
// When both are active, render a single merged tooltip using the heatmap position
|
|
if (heatmapHover && vacationHover) {
|
|
return (
|
|
<div
|
|
ref={(el) => {
|
|
// Wire both refs to the same element so position updates work from either handler
|
|
(heatmapTooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
|
(vacationTooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
|
}}
|
|
style={{
|
|
left: heatmapTooltipPos.left,
|
|
top: heatmapTooltipPos.top,
|
|
backgroundColor: "rgba(3, 7, 18, 0.96)",
|
|
}}
|
|
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
|
|
>
|
|
{/* Date + hours header */}
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
|
|
<span className="text-[11px] text-gray-300">
|
|
{heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
|
|
</span>
|
|
</div>
|
|
|
|
{/* Project breakdown */}
|
|
<div className="mt-2 space-y-1.5">
|
|
{heatmapHover.breakdown.length > 0 ? (
|
|
heatmapHover.breakdown.slice(0, 6).map((entry) => (
|
|
<div
|
|
key={`${entry.projectId}-${entry.shortCode}`}
|
|
className="flex items-start justify-between gap-3"
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="truncate font-medium text-white">
|
|
{entry.shortCode ? `${entry.shortCode} · ` : ""}
|
|
{entry.projectName}
|
|
</div>
|
|
<div className="truncate text-[11px] text-gray-400">
|
|
{[
|
|
entry.role,
|
|
entry.responsiblePerson ? `Lead: ${entry.responsiblePerson}` : null,
|
|
entry.orderType,
|
|
].filter(Boolean).join(" · ")}
|
|
</div>
|
|
{entry.startDate && entry.endDate && (
|
|
<div className="text-[10px] text-gray-500">
|
|
{entry.startDate} → {entry.endDate}
|
|
{entry.status && entry.status !== "CONFIRMED" && (
|
|
<span className="ml-1 uppercase text-amber-400">{entry.status}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
|
|
{entry.hoursPerDay}h
|
|
</span>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Vacation section — merged below */}
|
|
<div className="mt-2 pt-2 border-t border-amber-700/40">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="inline-block w-2 h-2 rounded-full bg-amber-500 flex-shrink-0" />
|
|
<span className="font-semibold text-amber-300">
|
|
{vacationHover.type.replaceAll("_", " ")}
|
|
</span>
|
|
</div>
|
|
<div className="mt-0.5 text-[11px] text-amber-200/80">
|
|
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
|
|
</div>
|
|
{vacationHover.note ? (
|
|
<div className="mt-1 text-[11px] text-amber-200/60">{vacationHover.note}</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Heatmap only
|
|
if (heatmapHover) {
|
|
return (
|
|
<div
|
|
ref={heatmapTooltipRef}
|
|
style={{
|
|
left: heatmapTooltipPos.left,
|
|
top: heatmapTooltipPos.top,
|
|
backgroundColor: "rgba(3, 7, 18, 0.96)",
|
|
}}
|
|
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
|
|
<span className="text-[11px] text-gray-300">
|
|
{heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
|
|
</span>
|
|
</div>
|
|
<div className="mt-2 space-y-1.5">
|
|
{heatmapHover.breakdown.length > 0 ? (
|
|
heatmapHover.breakdown.slice(0, 6).map((entry) => (
|
|
<div
|
|
key={`${entry.projectId}-${entry.shortCode}`}
|
|
className="flex items-start justify-between gap-3"
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="truncate font-medium text-white">
|
|
{entry.shortCode ? `${entry.shortCode} · ` : ""}
|
|
{entry.projectName}
|
|
</div>
|
|
<div className="truncate text-[11px] text-gray-400">
|
|
{[
|
|
entry.role,
|
|
entry.responsiblePerson ? `Lead: ${entry.responsiblePerson}` : null,
|
|
entry.orderType,
|
|
].filter(Boolean).join(" · ")}
|
|
</div>
|
|
{entry.startDate && entry.endDate && (
|
|
<div className="text-[10px] text-gray-500">
|
|
{entry.startDate} → {entry.endDate}
|
|
{entry.status && entry.status !== "CONFIRMED" && (
|
|
<span className="ml-1 uppercase text-amber-400">{entry.status}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
|
|
{entry.hoursPerDay}h
|
|
</span>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Vacation only
|
|
if (vacationHover) {
|
|
return (
|
|
<div
|
|
ref={vacationTooltipRef}
|
|
style={{
|
|
left: vacationTooltipPos.left,
|
|
top: vacationTooltipPos.top,
|
|
backgroundColor: "rgba(120, 53, 15, 0.95)",
|
|
}}
|
|
className="fixed z-40 max-w-xs pointer-events-none rounded-xl border border-amber-700/50 bg-amber-950/95 px-3 py-2 text-xs text-amber-50 shadow-2xl"
|
|
>
|
|
<div className="font-semibold">{vacationHover.type.replaceAll("_", " ")}</div>
|
|
<div className="mt-1 text-[11px] text-amber-100/90">
|
|
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
|
|
</div>
|
|
{vacationHover.note ? (
|
|
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|