423 lines
13 KiB
TypeScript
423 lines
13 KiB
TypeScript
"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<HTMLDivElement | null>;
|
|
heatmapTooltipPos: { left: number; top: number };
|
|
vacationTooltipRef: React.RefObject<HTMLDivElement | null>;
|
|
vacationTooltipPos: { left: number; top: number };
|
|
heatmapHover: HeatmapHoverData | null;
|
|
vacationHover: VacationHoverData | null;
|
|
demandTooltipRef?: React.RefObject<HTMLDivElement | null>;
|
|
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<HTMLDivElement>;
|
|
className: string;
|
|
dataTestId?: string;
|
|
backgroundColor: string;
|
|
}) {
|
|
return (
|
|
<div
|
|
ref={tooltipRef}
|
|
data-testid={dataTestId}
|
|
style={{
|
|
left: position.left,
|
|
top: position.top,
|
|
backgroundColor,
|
|
}}
|
|
className={className}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TooltipMetric({
|
|
label,
|
|
value,
|
|
valueClassName = "font-medium text-gray-100",
|
|
}: {
|
|
label: string;
|
|
value: React.ReactNode;
|
|
valueClassName?: string;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<div className="text-gray-500">{label}</div>
|
|
<div className={valueClassName}>{value}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HeatmapBreakdownList({ breakdown }: { breakdown: HeatmapHoverData["breakdown"] }) {
|
|
if (breakdown.length === 0) {
|
|
return <div className="text-[11px] text-gray-400">No bookings on this day.</div>;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{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>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
|
|
{entry.hoursPerDay}h
|
|
</span>
|
|
</div>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className={className}>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="inline-block h-2 w-2 flex-shrink-0 rounded-full bg-amber-500" />
|
|
<span className="font-semibold text-amber-300">{title}</span>
|
|
</div>
|
|
<div className="mt-0.5 text-[11px] text-amber-200/80">
|
|
{formatDateLong(vacation.startDate)} to {formatDateLong(vacation.endDate)}
|
|
</div>
|
|
{holidayMeta ? (
|
|
<div className="mt-1 text-[11px] text-amber-100/75">{holidayMeta}</div>
|
|
) : null}
|
|
{holidayLocationBasis ? (
|
|
<div className="mt-1 text-[11px] text-amber-100/85">{holidayLocationBasis}</div>
|
|
) : null}
|
|
{isPublicHoliday && vacation.calendarName ? (
|
|
<div className="mt-1 text-[11px] text-amber-200/60">
|
|
Calendar: {vacation.calendarName}
|
|
</div>
|
|
) : null}
|
|
{vacation.note && !isPublicHoliday ? (
|
|
<div className="mt-1 text-[11px] text-amber-200/60">{vacation.note}</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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(
|
|
<TooltipSurface
|
|
tooltipRef={demandTooltipRef}
|
|
dataTestId="timeline-demand-tooltip"
|
|
position={demandTooltipPos}
|
|
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-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-1.5">
|
|
<span
|
|
className="inline-block h-2 w-2 rounded-full flex-shrink-0"
|
|
style={{ backgroundColor: demandHover.roleColor }}
|
|
/>
|
|
<span className="truncate font-semibold">{demandHover.roleName}</span>
|
|
</div>
|
|
<div className="truncate text-[11px] text-gray-400">
|
|
{demandHover.projectShortCode ? `${demandHover.projectShortCode} · ` : ""}
|
|
{demandHover.projectName}
|
|
</div>
|
|
</div>
|
|
{demandHover.status ? (
|
|
<span className="text-[10px] uppercase tracking-wide text-amber-300">
|
|
{demandHover.status}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px]">
|
|
<TooltipMetric
|
|
label="Requested"
|
|
value={`${demandHover.requestedHeadcount} ${demandHover.requestedHeadcount === 1 ? "seat" : "seats"}`}
|
|
/>
|
|
<TooltipMetric
|
|
label="Open"
|
|
value={`${demandHover.unfilledHeadcount} ${demandHover.unfilledHeadcount === 1 ? "seat" : "seats"}`}
|
|
valueClassName="font-medium text-amber-300"
|
|
/>
|
|
<TooltipMetric
|
|
label="Range"
|
|
value={`${formatDateLong(demandHover.startDate)} to ${formatDateLong(demandHover.endDate)}`}
|
|
/>
|
|
<TooltipMetric
|
|
label="Load"
|
|
value={`${demandHover.hoursPerDay}h/day · ${demandHover.totalHours}h`}
|
|
/>
|
|
{typeof demandHover.percentage === "number" && demandHover.percentage > 0 ? (
|
|
<TooltipMetric label="Allocation" value={`${demandHover.percentage}%`} />
|
|
) : null}
|
|
{typeof demandHover.totalCostCents === "number" && demandHover.totalCostCents > 0 ? (
|
|
<TooltipMetric
|
|
label="Cost"
|
|
value={`${formatCents(demandHover.totalCostCents)} EUR${
|
|
typeof demandHover.dailyCostCents === "number" && demandHover.dailyCostCents > 0
|
|
? ` · ${formatCents(demandHover.dailyCostCents)}/d`
|
|
: ""
|
|
}`}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="mt-2 border-t border-gray-800/90 pt-2 text-[10px] uppercase tracking-[0.14em] text-gray-500">
|
|
Click for details and actions
|
|
</div>
|
|
</TooltipSurface>,
|
|
);
|
|
}
|
|
|
|
// When both are active, render a single merged tooltip using the heatmap position
|
|
if (heatmapHover && vacationHover) {
|
|
return renderTooltipPortal(
|
|
<TooltipSurface
|
|
tooltipRef={(el) => {
|
|
(heatmapTooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
|
// Keep the merged tooltip attached to the heatmap pointer path only.
|
|
(vacationTooltipRef as React.MutableRefObject<HTMLDivElement | null>).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"
|
|
>
|
|
<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">
|
|
<HeatmapBreakdownList breakdown={heatmapHover.breakdown} />
|
|
</div>
|
|
|
|
<VacationSummary
|
|
vacation={vacationHover}
|
|
title={vacationTitle ?? "Vacation"}
|
|
className="mt-2 border-t border-amber-700/40 pt-2"
|
|
/>
|
|
</TooltipSurface>,
|
|
);
|
|
}
|
|
|
|
// Heatmap only
|
|
if (heatmapHover) {
|
|
return renderTooltipPortal(
|
|
<TooltipSurface
|
|
tooltipRef={heatmapTooltipRef}
|
|
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"
|
|
>
|
|
<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">
|
|
<HeatmapBreakdownList breakdown={heatmapHover.breakdown} />
|
|
</div>
|
|
</TooltipSurface>,
|
|
);
|
|
}
|
|
|
|
// Vacation only
|
|
if (vacationHover) {
|
|
return renderTooltipPortal(
|
|
<TooltipSurface
|
|
tooltipRef={vacationTooltipRef}
|
|
position={vacationTooltipPos}
|
|
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"
|
|
>
|
|
<VacationSummary vacation={vacationHover} title={vacationTitle ?? "Vacation"} />
|
|
</TooltipSurface>,
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|