feat(platform): checkpoint current implementation state
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { GERMAN_FEDERAL_STATES } from "@capakraken/shared";
|
||||
import { createPortal } from "react-dom";
|
||||
import { formatCents, formatDateLong } from "~/lib/format.js";
|
||||
|
||||
@@ -33,11 +34,73 @@ export type VacationHoverData = {
|
||||
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;
|
||||
@@ -67,6 +130,142 @@ interface TimelineTooltipProps {
|
||||
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,
|
||||
@@ -79,18 +278,14 @@ export function TimelineTooltip({
|
||||
demandHover,
|
||||
}: TimelineTooltipProps) {
|
||||
const vacationTitle = vacationHover ? getVacationTitle(vacationHover) : null;
|
||||
const renderTooltip = (content: React.ReactNode) =>
|
||||
typeof document === "undefined" ? content : createPortal(content, document.body);
|
||||
|
||||
if (demandHover && demandTooltipRef && demandTooltipPos) {
|
||||
return renderTooltip(
|
||||
<div
|
||||
ref={demandTooltipRef}
|
||||
style={{
|
||||
left: demandTooltipPos.left,
|
||||
top: demandTooltipPos.top,
|
||||
backgroundColor: "rgba(3, 7, 18, 0.96)",
|
||||
}}
|
||||
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">
|
||||
@@ -115,73 +310,58 @@ export function TimelineTooltip({
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px]">
|
||||
<div>
|
||||
<div className="text-gray-500">Requested</div>
|
||||
<div className="font-medium text-gray-100">
|
||||
{demandHover.requestedHeadcount} {demandHover.requestedHeadcount === 1 ? "seat" : "seats"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Open</div>
|
||||
<div className="font-medium text-amber-300">
|
||||
{demandHover.unfilledHeadcount} {demandHover.unfilledHeadcount === 1 ? "seat" : "seats"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Range</div>
|
||||
<div className="font-medium text-gray-100">
|
||||
{formatDateLong(demandHover.startDate)} to {formatDateLong(demandHover.endDate)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Load</div>
|
||||
<div className="font-medium text-gray-100">
|
||||
{demandHover.hoursPerDay}h/day · {demandHover.totalHours}h
|
||||
</div>
|
||||
</div>
|
||||
<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 ? (
|
||||
<div>
|
||||
<div className="text-gray-500">Allocation</div>
|
||||
<div className="font-medium text-gray-100">{demandHover.percentage}%</div>
|
||||
</div>
|
||||
<TooltipMetric label="Allocation" value={`${demandHover.percentage}%`} />
|
||||
) : null}
|
||||
{typeof demandHover.totalCostCents === "number" && demandHover.totalCostCents > 0 ? (
|
||||
<div>
|
||||
<div className="text-gray-500">Cost</div>
|
||||
<div className="font-medium text-gray-100">
|
||||
{formatCents(demandHover.totalCostCents)} EUR
|
||||
{typeof demandHover.dailyCostCents === "number" && demandHover.dailyCostCents > 0
|
||||
<TooltipMetric
|
||||
label="Cost"
|
||||
value={`${formatCents(demandHover.totalCostCents)} EUR${
|
||||
typeof demandHover.dailyCostCents === "number" && demandHover.dailyCostCents > 0
|
||||
? ` · ${formatCents(demandHover.dailyCostCents)}/d`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
) : 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>
|
||||
</div>,
|
||||
</TooltipSurface>,
|
||||
);
|
||||
}
|
||||
|
||||
// When both are active, render a single merged tooltip using the heatmap position
|
||||
if (heatmapHover && vacationHover) {
|
||||
return renderTooltip(
|
||||
<div
|
||||
ref={(el) => {
|
||||
// Wire both refs to the same element so position updates work from either handler
|
||||
return renderTooltipPortal(
|
||||
<TooltipSurface
|
||||
tooltipRef={(el) => {
|
||||
(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)",
|
||||
// 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"
|
||||
>
|
||||
{/* 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">
|
||||
@@ -189,72 +369,26 @@ export function TimelineTooltip({
|
||||
</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>
|
||||
)}
|
||||
<HeatmapBreakdownList breakdown={heatmapHover.breakdown} />
|
||||
</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">{vacationTitle}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-amber-200/80">
|
||||
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
|
||||
</div>
|
||||
{vacationHover.note && vacationHover.type !== "PUBLIC_HOLIDAY" ? (
|
||||
<div className="mt-1 text-[11px] text-amber-200/60">{vacationHover.note}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>,
|
||||
<VacationSummary
|
||||
vacation={vacationHover}
|
||||
title={vacationTitle ?? "Vacation"}
|
||||
className="mt-2 border-t border-amber-700/40 pt-2"
|
||||
/>
|
||||
</TooltipSurface>,
|
||||
);
|
||||
}
|
||||
|
||||
// Heatmap only
|
||||
if (heatmapHover) {
|
||||
return renderTooltip(
|
||||
<div
|
||||
ref={heatmapTooltipRef}
|
||||
style={{
|
||||
left: heatmapTooltipPos.left,
|
||||
top: heatmapTooltipPos.top,
|
||||
backgroundColor: "rgba(3, 7, 18, 0.96)",
|
||||
}}
|
||||
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">
|
||||
@@ -264,66 +398,23 @@ export function TimelineTooltip({
|
||||
</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>
|
||||
)}
|
||||
<HeatmapBreakdownList breakdown={heatmapHover.breakdown} />
|
||||
</div>
|
||||
</div>,
|
||||
</TooltipSurface>,
|
||||
);
|
||||
}
|
||||
|
||||
// Vacation only
|
||||
if (vacationHover) {
|
||||
return renderTooltip(
|
||||
<div
|
||||
ref={vacationTooltipRef}
|
||||
style={{
|
||||
left: vacationTooltipPos.left,
|
||||
top: vacationTooltipPos.top,
|
||||
backgroundColor: "rgba(120, 53, 15, 0.95)",
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<div className="font-semibold">{vacationTitle}</div>
|
||||
<div className="mt-1 text-[11px] text-amber-100/90">
|
||||
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
|
||||
</div>
|
||||
{vacationHover.note && vacationHover.type !== "PUBLIC_HOLIDAY" ? (
|
||||
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
|
||||
) : null}
|
||||
</div>,
|
||||
<VacationSummary vacation={vacationHover} title={vacationTitle ?? "Vacation"} />
|
||||
</TooltipSurface>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user