"use client"; import { GERMAN_FEDERAL_STATES } from "@capakraken/shared"; import { createPortal } from "react-dom"; import { formatCents, formatDateLong } from "~/lib/format.js"; function getVacationTitle(vacation: VacationHoverData): string { if (vacation.type === "PUBLIC_HOLIDAY" && vacation.note) { return vacation.note; } return vacation.type.replaceAll("_", " "); } 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; scope?: string | null; calendarName?: string | null; sourceType?: string | null; countryCode?: string | null; countryName?: string | null; federalState?: string | null; metroCityName?: string | null; requestedBy?: { name?: string | null; email: string } | null; approvedBy?: { name?: string | null; email: string } | null; approvedAt?: Date | string | null; }; function formatHolidayScope(scope?: string | null): string | null { switch (scope) { case "COUNTRY": return "Country"; case "STATE": return "State"; case "CITY": return "City"; default: return scope ? scope.replaceAll("_", " ") : null; } } function formatHolidaySourceType(sourceType?: string | null): string | null { switch (sourceType) { case "SYSTEM": return "Built-in"; case "CALENDAR": return "Calendar"; default: return sourceType ? sourceType.replaceAll("_", " ") : null; } } function formatHolidayStateName( federalState?: string | null, countryCode?: string | null, ): string | null { if (!federalState) { return null; } if (countryCode === "DE") { return GERMAN_FEDERAL_STATES[federalState] ?? federalState; } return federalState; } function buildHolidayLocationBasis(vacation: VacationHoverData): string | null { const parts = [ vacation.countryName ? `Country: ${vacation.countryName}` : vacation.countryCode ? `Country: ${vacation.countryCode}` : null, formatHolidayStateName(vacation.federalState, vacation.countryCode) ? `State: ${formatHolidayStateName(vacation.federalState, vacation.countryCode)}` : null, vacation.metroCityName ? `City: ${vacation.metroCityName}` : null, ].filter(Boolean); return parts.length > 0 ? parts.join(" · ") : null; } export type DemandHoverData = { roleName: string; roleColor: string; projectName: string; projectShortCode?: string | null; requestedHeadcount: number; unfilledHeadcount: number; startDate: Date | string; endDate: Date | string; hoursPerDay: number; totalHours: number; percentage?: number; status?: string; totalCostCents?: number; dailyCostCents?: number; }; interface TimelineTooltipProps { heatmapTooltipRef: React.RefObject; heatmapTooltipPos: { left: number; top: number }; vacationTooltipRef: React.RefObject; vacationTooltipPos: { left: number; top: number }; heatmapHover: HeatmapHoverData | null; vacationHover: VacationHoverData | null; demandTooltipRef?: React.RefObject; demandTooltipPos?: { left: number; top: number }; demandHover?: DemandHoverData | null; } function renderTooltipPortal(content: React.ReactNode) { return typeof document === "undefined" ? content : createPortal(content, document.body); } function TooltipSurface({ children, position, tooltipRef, className, dataTestId, backgroundColor, }: { children: React.ReactNode; position: { left: number; top: number }; tooltipRef?: React.Ref; className: string; dataTestId?: string; backgroundColor: string; }) { return (
{children}
); } function TooltipMetric({ label, value, valueClassName = "font-medium text-gray-100", }: { label: string; value: React.ReactNode; valueClassName?: string; }) { return (
{label}
{value}
); } function HeatmapBreakdownList({ breakdown }: { breakdown: HeatmapHoverData["breakdown"] }) { if (breakdown.length === 0) { return
No bookings on this day.
; } return ( <> {breakdown.slice(0, 6).map((entry) => (
{entry.shortCode ? `${entry.shortCode} · ` : ""} {entry.projectName}
{[ entry.role, entry.responsiblePerson ? `Lead: ${entry.responsiblePerson}` : null, entry.orderType, ].filter(Boolean).join(" · ")}
{entry.startDate && entry.endDate ? (
{entry.startDate} → {entry.endDate} {entry.status && entry.status !== "CONFIRMED" ? ( {entry.status} ) : null}
) : null}
{entry.hoursPerDay}h
))} ); } function VacationSummary({ vacation, title, className, }: { vacation: VacationHoverData; title: string; className?: string; }) { const isPublicHoliday = vacation.type === "PUBLIC_HOLIDAY"; const scopeLabel = formatHolidayScope(vacation.scope); const sourceLabel = formatHolidaySourceType(vacation.sourceType); const holidayMeta = [scopeLabel, sourceLabel].filter(Boolean).join(" · "); const holidayLocationBasis = isPublicHoliday ? buildHolidayLocationBasis(vacation) : null; return (
{title}
{formatDateLong(vacation.startDate)} to {formatDateLong(vacation.endDate)}
{holidayMeta ? (
{holidayMeta}
) : null} {holidayLocationBasis ? (
{holidayLocationBasis}
) : null} {isPublicHoliday && vacation.calendarName ? (
Calendar: {vacation.calendarName}
) : null} {vacation.note && !isPublicHoliday ? (
{vacation.note}
) : null}
); } export function TimelineTooltip({ heatmapTooltipRef, heatmapTooltipPos, vacationTooltipRef, vacationTooltipPos, heatmapHover, vacationHover, demandTooltipRef, demandTooltipPos, demandHover, }: TimelineTooltipProps) { const vacationTitle = vacationHover ? getVacationTitle(vacationHover) : null; if (demandHover && demandTooltipRef && demandTooltipPos) { return renderTooltipPortal(
{demandHover.roleName}
{demandHover.projectShortCode ? `${demandHover.projectShortCode} · ` : ""} {demandHover.projectName}
{demandHover.status ? ( {demandHover.status} ) : null}
{typeof demandHover.percentage === "number" && demandHover.percentage > 0 ? ( ) : null} {typeof demandHover.totalCostCents === "number" && demandHover.totalCostCents > 0 ? ( 0 ? ` · ${formatCents(demandHover.dailyCostCents)}/d` : "" }`} /> ) : null}
Click for details and actions
, ); } // When both are active, render a single merged tooltip using the heatmap position if (heatmapHover && vacationHover) { return renderTooltipPortal( { (heatmapTooltipRef as React.MutableRefObject).current = el; // Keep the merged tooltip attached to the heatmap pointer path only. (vacationTooltipRef as React.MutableRefObject).current = null; }} position={heatmapTooltipPos} 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" >
{formatDateLong(heatmapHover.date)} {heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
, ); } // Heatmap only if (heatmapHover) { return renderTooltipPortal(
{formatDateLong(heatmapHover.date)} {heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
, ); } // Vacation only if (vacationHover) { return renderTooltipPortal( , ); } return null; }