"use client"; import { clsx } from "clsx"; import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import type { CSSProperties } from "react"; import { useTimelineContext, type TimelineAssignmentEntry, type TimelineDemandEntry, } from "./TimelineContext.js"; import { heatmapColor } from "./heatmapUtils.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { formatDateLong } from "~/lib/format.js"; import { ROW_HEIGHT, SUB_LANE_HEIGHT, LABEL_WIDTH, PROJECT_HEADER_HEIGHT, ORDER_TYPE_COLORS, } from "./timelineConstants.js"; import type { DragState, AllocDragState, RangeState } from "~/hooks/useTimelineDrag.js"; import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js"; // ─── Props ────────────────────────────────────────────────────────────────── interface TimelineProjectPanelProps { scrollContainerRef: React.RefObject; dragState: DragState; allocDragState: AllocDragState; rangeState: RangeState; onProjectBarMouseDown: (e: React.MouseEvent, info: ProjectBarInfo) => void; onProjectBarTouchStart: (e: React.TouchEvent, info: ProjectBarInfo) => void; onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void; onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void; onRowMouseDown: (e: React.MouseEvent, info: RowMouseDownInfo) => void; onRowTouchStart: (e: React.TouchEvent, info: RowMouseDownInfo) => void; onOpenPanel: (projectId: string) => void; onOpenDemandClick: (demand: OpenDemandAssignment) => void; onAllocationContextMenu: ( info: { allocationId: string; projectId: string }, anchorX: number, anchorY: number, ) => void; // Layout from useTimelineLayout CELL_WIDTH: number; dates: Date[]; totalCanvasWidth: number; toLeft: (date: Date) => number; toWidth: (start: Date, end: Date) => number; gridLines: React.ReactNode; xToDate: (clientX: number, rect: DOMRect) => Date; } export interface ProjectBarInfo { projectId: string; projectName: string; startDate: Date; endDate: Date; } export interface OpenDemandAssignment { id: string; projectId: string; roleId: string | null; role: string | null; headcount: number; budgetCents?: number; startDate: Date; endDate: Date; hoursPerDay: number; roleEntity?: { id: string; name: string; color: string | null } | null; project?: { id: string; name: string; shortCode: string }; } type HeatmapBreakdownEntry = { projectId: string; shortCode: string; projectName: string; orderType: string; hoursPerDay: number; responsiblePerson?: string | null; }; type HeatmapHoverState = { date: Date; totalH: number; pct: number; breakdown: HeatmapBreakdownEntry[]; }; type ProjectDayMetric = { projH: number; totalH: number; }; type HeatmapBreakdownAccumulator = { shortCode: string; projectName: string; orderType: string; responsiblePerson: string | null; hours: number; }; type ProjectFlatRow = | { type: "header"; key: string; project: NonNullable["projectGroups"]>[number]; } | { type: "open-demand"; key: string; projectId: string; openDemands: TimelineDemandEntry[]; } | { type: "resource"; key: string; project: NonNullable["projectGroups"]>[number]; resource: NonNullable< ReturnType["projectGroups"] >[number]["resourceRows"][number]["resource"]; allocs: TimelineAssignmentEntry[]; metricsKey: string; }; const EMPTY_DAY_METRICS: ProjectDayMetric[] = []; const SVG_XMLNS = "http://www.w3.org/2000/svg"; function buildProjectRowGridBackground(dates: Date[], CELL_WIDTH: number, today: Date) { const gradientLayers: string[] = [ `repeating-linear-gradient(to right, transparent 0, transparent ${Math.max( CELL_WIDTH - 1, 0, )}px, rgba(229, 231, 235, 1) ${Math.max(CELL_WIDTH - 1, 0)}px, rgba(229, 231, 235, 1) ${CELL_WIDTH}px)`, ]; dates.forEach((date, index) => { const left = index * CELL_WIDTH; const right = left + CELL_WIDTH; const isToday = date.toDateString() === today.toDateString(); const isSaturday = date.getDay() === 6; const isSunday = date.getDay() === 0; if (isSaturday) { gradientLayers.push( `linear-gradient(to right, transparent ${left}px, rgba(254, 243, 199, 0.4) ${left}px, rgba(254, 243, 199, 0.4) ${right}px, transparent ${right}px)`, ); } else if (isSunday) { gradientLayers.push( `linear-gradient(to right, transparent ${left}px, rgba(243, 244, 246, 0.6) ${left}px, rgba(243, 244, 246, 0.6) ${right}px, transparent ${right}px)`, ); } if (isToday) { gradientLayers.push( `linear-gradient(to right, transparent ${left}px, rgba(110, 231, 183, 0.95) ${left}px, rgba(110, 231, 183, 0.95) ${Math.min( left + 2, right, )}px, transparent ${Math.min(left + 2, right)}px)`, ); } }); return { backgroundImage: gradientLayers.join(", "), backgroundRepeat: "no-repeat", } as const; } // ─── Component ────────────────────────────────────────────────────────────── export function TimelineProjectPanel({ scrollContainerRef, dragState, allocDragState, rangeState, onProjectBarMouseDown, onProjectBarTouchStart, onAllocMouseDown, onAllocTouchStart, onRowMouseDown, onRowTouchStart, onOpenPanel, onOpenDemandClick, onAllocationContextMenu, CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, xToDate, }: TimelineProjectPanelProps) { const { projectGroups, openDemandsByProject, allocsByResource, vacationsByResource, filters, displayMode, heatmapScheme, activeFilterCount, today, } = useTimelineContext(); // ─── Heatmap hover (same mechanism as resource panel) ───────────────────── const heatmapRafRef = useRef(null); const lastHeatmapDayRef = useRef(-1); const lastHeatmapResourceRef = useRef(null); const vacationHoverRafRef = useRef(null); const hoveredVacationKeyRef = useRef(null); const pendingHeatmapRef = useRef<{ clientX: number; rect: DOMRect; resourceId: string; } | null>(null); const heatmapTooltipRef = useRef(null); const vacationTooltipRef = useRef(null); const heatmapTooltipPosRef = useRef({ left: 0, top: 0 }); const vacationTooltipPosRef = useRef({ left: 0, top: 0 }); const [heatmapHover, setHeatmapHover] = useState<{ date: Date; totalH: number; pct: number; breakdown: HeatmapBreakdownEntry[]; } | null>(null); const [vacationHover, setVacationHover] = useState(null); const { resourceHeatmapById, resourceTotalHoursById } = useMemo(() => { const dateIndexByTime = new Map(); dates.forEach((date, index) => { const normalized = new Date(date); normalized.setHours(0, 0, 0, 0); dateIndexByTime.set(normalized.getTime(), index); }); const nextHeatmapById = new Map(); const nextTotalHoursById = new Map(); for (const [resourceId, allocs] of allocsByResource) { if (allocs.length === 0) continue; const totalHours = new Array(dates.length).fill(0); const breakdownMaps = Array.from({ length: dates.length }, () => new Map()); for (const alloc of allocs) { const current = new Date(alloc.startDate); current.setHours(0, 0, 0, 0); const end = new Date(alloc.endDate); end.setHours(0, 0, 0, 0); while (current.getTime() <= end.getTime()) { const dayIndex = dateIndexByTime.get(current.getTime()); if (dayIndex !== undefined) { totalHours[dayIndex] = (totalHours[dayIndex] ?? 0) + alloc.hoursPerDay; const dayBreakdown = breakdownMaps[dayIndex]; if (!dayBreakdown) { current.setDate(current.getDate() + 1); continue; } const existing = dayBreakdown.get(alloc.projectId); if (existing) { existing.hours += alloc.hoursPerDay; } else { dayBreakdown.set(alloc.projectId, { shortCode: alloc.project.shortCode, projectName: alloc.project.name, orderType: alloc.project.orderType, responsiblePerson: (alloc.project as { responsiblePerson?: string | null }).responsiblePerson ?? null, hours: alloc.hoursPerDay, }); } } current.setDate(current.getDate() + 1); } } nextTotalHoursById.set(resourceId, totalHours); nextHeatmapById.set( resourceId, totalHours.map((totalH, dayIndex) => { if (totalH === 0) return null; const dayBreakdown = breakdownMaps[dayIndex]; if (!dayBreakdown) return null; const breakdown: HeatmapBreakdownEntry[] = [...dayBreakdown.entries()] .map(([projectId, value]) => ({ projectId, shortCode: value.shortCode, projectName: value.projectName, orderType: value.orderType, responsiblePerson: value.responsiblePerson, hoursPerDay: value.hours, })) .sort((a, b) => b.hoursPerDay - a.hoursPerDay); return { date: dates[dayIndex] ?? new Date(), totalH, pct: (totalH / 8) * 100, breakdown, }; }), ); } return { resourceHeatmapById: nextHeatmapById, resourceTotalHoursById: nextTotalHoursById, }; }, [allocsByResource, dates]); const projectRowMetrics = useMemo(() => { const dateIndexByTime = new Map(); dates.forEach((date, index) => { const normalized = new Date(date); normalized.setHours(0, 0, 0, 0); dateIndexByTime.set(normalized.getTime(), index); }); const nextMetrics = new Map(); for (const project of projectGroups) { for (const { resource, allocs } of project.resourceRows) { const projectHours = new Array(dates.length).fill(0); for (const alloc of allocs) { const current = new Date(alloc.startDate); current.setHours(0, 0, 0, 0); const end = new Date(alloc.endDate); end.setHours(0, 0, 0, 0); while (current.getTime() <= end.getTime()) { const dayIndex = dateIndexByTime.get(current.getTime()); if (dayIndex !== undefined) { projectHours[dayIndex] = (projectHours[dayIndex] ?? 0) + alloc.hoursPerDay; } current.setDate(current.getDate() + 1); } } const totalHours = resourceTotalHoursById.get(resource.id); nextMetrics.set( `${project.id}:${resource.id}`, projectHours.map((projH, dayIndex) => ({ projH, totalH: totalHours?.[dayIndex] ?? 0, })), ); } } return nextMetrics; }, [dates, projectGroups, resourceTotalHoursById]); const flatRows = useMemo(() => { const rows: ProjectFlatRow[] = []; for (const project of projectGroups) { rows.push({ type: "header", key: `header-${project.id}`, project }); const openDemands = openDemandsByProject.get(project.id) ?? []; if (openDemands.length > 0) { rows.push({ type: "open-demand", key: `open-demand-${project.id}`, projectId: project.id, openDemands, }); } for (const { resource, allocs } of project.resourceRows) { rows.push({ type: "resource", key: `${project.id}-${resource.id}`, project, resource, allocs, metricsKey: `${project.id}:${resource.id}`, }); } } return rows; }, [openDemandsByProject, projectGroups]); const rowVirtualizer = useVirtualizer({ count: flatRows.length, getScrollElement: () => scrollContainerRef.current, estimateSize: (index) => { const row = flatRows[index]; if (!row) return ROW_HEIGHT; if (row.type === "header") return PROJECT_HEADER_HEIGHT; if (row.type === "open-demand") { const laneCount = assignDemandLanes(row.openDemands).size > 0 ? Math.max(...assignDemandLanes(row.openDemands).values()) + 1 : 1; return Math.max(ROW_HEIGHT, laneCount * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP) + 8); } return ROW_HEIGHT; }, overscan: 8, getItemKey: (index) => flatRows[index]?.key ?? index, }); const virtualItems = rowVirtualizer.getVirtualItems(); const totalRowHeight = rowVirtualizer.getTotalSize(); const resourceRowGridStyle = useMemo( () => buildProjectRowGridBackground(dates, CELL_WIDTH, today), [CELL_WIDTH, dates, today], ); const resourcesWithVacations = useMemo(() => { const result = new Set(); for (const [resourceId, vacations] of vacationsByResource) { if (vacations.length > 0) { result.add(resourceId); } } return result; }, [vacationsByResource]); const handleRowHeatmapMove = useCallback( (e: React.MouseEvent, resourceId: string) => { heatmapTooltipPosRef.current = { left: e.clientX + 16, top: e.clientY - 52 }; if (heatmapTooltipRef.current) { heatmapTooltipRef.current.style.left = `${heatmapTooltipPosRef.current.left}px`; heatmapTooltipRef.current.style.top = `${heatmapTooltipPosRef.current.top}px`; } const rect = e.currentTarget.getBoundingClientRect(); const dayIndex = Math.floor((e.clientX - rect.left) / CELL_WIDTH); if ( dayIndex === lastHeatmapDayRef.current && resourceId === lastHeatmapResourceRef.current ) return; pendingHeatmapRef.current = { clientX: e.clientX, rect, resourceId }; if (heatmapRafRef.current !== null) return; heatmapRafRef.current = requestAnimationFrame(() => { heatmapRafRef.current = null; const pending = pendingHeatmapRef.current; pendingHeatmapRef.current = null; if (!pending) return; const { clientX, rect: pendingRect, resourceId: pendingResourceId } = pending; const nextDayIndex = Math.floor((clientX - pendingRect.left) / CELL_WIDTH); const nextHeatmap = resourceHeatmapById.get(pendingResourceId)?.[nextDayIndex] ?? null; if (nextDayIndex < 0 || nextDayIndex >= dates.length) { lastHeatmapDayRef.current = -1; lastHeatmapResourceRef.current = null; startTransition(() => setHeatmapHover(null)); return; } lastHeatmapDayRef.current = nextDayIndex; lastHeatmapResourceRef.current = pendingResourceId; startTransition(() => { setHeatmapHover(nextHeatmap); }); }); }, [CELL_WIDTH, dates.length, resourceHeatmapById], ); const handleRowVacationHover = useCallback( (e: React.MouseEvent, resourceId: string) => { if (!resourcesWithVacations.has(resourceId)) { if (hoveredVacationKeyRef.current !== null) { hoveredVacationKeyRef.current = null; startTransition(() => { setVacationHover(null); }); } return; } vacationTooltipPosRef.current = { left: e.clientX + 14, top: e.clientY - 8 }; if (vacationTooltipRef.current) { vacationTooltipRef.current.style.left = `${vacationTooltipPosRef.current.left}px`; vacationTooltipRef.current.style.top = `${vacationTooltipPosRef.current.top}px`; } const rect = e.currentTarget.getBoundingClientRect(); const clientX = e.clientX; if (vacationHoverRafRef.current !== null) return; vacationHoverRafRef.current = requestAnimationFrame(() => { vacationHoverRafRef.current = null; const date = xToDate(clientX, rect); const time = date.getTime(); const resourceVacations = vacationsByResource.get(resourceId) ?? []; const hit = resourceVacations.find((vacation) => { const start = new Date(vacation.startDate); start.setHours(0, 0, 0, 0); const end = new Date(vacation.endDate); end.setHours(0, 0, 0, 0); return time >= start.getTime() && time <= end.getTime(); }) ?? null; const nextKey = hit ? `${resourceId}:${hit.id}` : null; if (nextKey === hoveredVacationKeyRef.current) return; hoveredVacationKeyRef.current = nextKey; startTransition(() => { setVacationHover(hit); }); }); }, [resourcesWithVacations, vacationsByResource, xToDate], ); const clearHoverTooltips = useCallback(() => { if (heatmapRafRef.current !== null) { cancelAnimationFrame(heatmapRafRef.current); heatmapRafRef.current = null; } if (vacationHoverRafRef.current !== null) { cancelAnimationFrame(vacationHoverRafRef.current); vacationHoverRafRef.current = null; } const shouldClearHeatmap = lastHeatmapDayRef.current !== -1; const shouldClearVacation = hoveredVacationKeyRef.current !== null; lastHeatmapDayRef.current = -1; lastHeatmapResourceRef.current = null; hoveredVacationKeyRef.current = null; if (shouldClearHeatmap || shouldClearVacation) { startTransition(() => { if (shouldClearHeatmap) setHeatmapHover(null); if (shouldClearVacation) setVacationHover(null); }); } }, []); useEffect( () => () => { if (heatmapRafRef.current !== null) cancelAnimationFrame(heatmapRafRef.current); if (vacationHoverRafRef.current !== null) cancelAnimationFrame(vacationHoverRafRef.current); }, [], ); if (projectGroups.length === 0) { return (
No projects in this time range{activeFilterCount > 0 && " (filtered)"}.
); } return (
{virtualItems.map((virtualRow) => { const row = flatRows[virtualRow.index]; if (!row) return null; return (
{row.type === "header" ? ( (() => { const { project } = row; const customColor = project.color; const colors = ORDER_TYPE_COLORS[project.orderType] ?? { bg: "bg-gray-400", text: "text-white", light: "bg-gray-50 border-gray-200", }; const isThisProjectShifting = dragState.isDragging && dragState.projectId === project.id; const projDispStart = isThisProjectShifting && dragState.currentStartDate ? dragState.currentStartDate : project.startDate; const projDispEnd = isThisProjectShifting && dragState.currentEndDate ? dragState.currentEndDate : project.endDate; const projLeft = toLeft(projDispStart); const projWidth = Math.max(CELL_WIDTH, toWidth(projDispStart, projDispEnd)); return (
onOpenPanel(project.id)} >
{project.name}
{project.status}
{gridLines} {projWidth > 0 && projLeft < totalCanvasWidth && (
{ if (!dragState.isDragging) onOpenPanel(project.id); }} onMouseDown={(e) => onProjectBarMouseDown(e, { projectId: project.id, projectName: project.name, startDate: project.startDate, endDate: project.endDate, }) } onTouchStart={(e) => onProjectBarTouchStart(e, { projectId: project.id, projectName: project.name, startDate: project.startDate, endDate: project.endDate, }) } onContextMenu={(e) => e.preventDefault()} > {project.name}
)}
); })() ) : row.type === "open-demand" ? ( renderOpenDemandRow( row.openDemands, CELL_WIDTH, totalCanvasWidth, toLeft, toWidth, resourceRowGridStyle, onOpenDemandClick, ) ) : (
{row.resource.displayName.slice(0, 2).toUpperCase()}
{row.resource.displayName}
{row.resource.eid}
{ const rect = e.currentTarget.getBoundingClientRect(); const date = xToDate(e.clientX, rect); onRowMouseDown(e, { resourceId: row.resource.id, startDate: date, suggestedProjectId: row.project.id, }); }} onTouchStart={(e) => { const rect = e.currentTarget.getBoundingClientRect(); const date = xToDate(e.touches[0]?.clientX ?? 0, rect); onRowTouchStart(e, { resourceId: row.resource.id, startDate: date, suggestedProjectId: row.project.id, }); }} onMouseMove={(e) => { handleRowHeatmapMove(e, row.resource.id); handleRowVacationHover(e, row.resource.id); }} onMouseLeave={clearHoverTooltips} > {renderProjectUtilOverlay( projectRowMetrics.get(row.metricsKey) ?? EMPTY_DAY_METRICS, CELL_WIDTH, displayMode, heatmapScheme, totalCanvasWidth, )} {renderProjectDragHandles( row.allocs, allocDragState, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, onAllocMouseDown, onAllocTouchStart, onAllocationContextMenu, )} {renderVacationBlocksForProjectRow( vacationsByResource.get(row.resource.id) ?? [], ROW_HEIGHT, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, filters.showVacations, )} {renderRangeOverlayProject( rangeState, row.resource.id, ROW_HEIGHT, toLeft, toWidth, CELL_WIDTH, )}
)}
); })}
); } function ProjectPanelTooltips({ heatmapTooltipRef, heatmapTooltipPos, vacationTooltipRef, vacationTooltipPos, heatmapHover, vacationHover, }: { heatmapTooltipRef: React.RefObject; heatmapTooltipPos: { left: number; top: number }; vacationTooltipRef: React.RefObject; vacationTooltipPos: { left: number; top: number }; heatmapHover: { date: Date; totalH: number; pct: number; breakdown: { projectId: string; shortCode: string; projectName: string; orderType: string; hoursPerDay: number; responsiblePerson?: string | null; }[]; } | null; vacationHover: { 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; } | null; }) { return ( <> {heatmapHover ? (
{formatDateLong(heatmapHover.date)} {heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
{heatmapHover.breakdown.length > 0 ? ( heatmapHover.breakdown.slice(0, 6).map((entry) => (
{entry.shortCode ? `${entry.shortCode} · ` : ""} {entry.projectName}
{entry.responsiblePerson ? `Lead: ${entry.responsiblePerson}` : entry.orderType}
{entry.hoursPerDay}h
)) ) : (
No bookings on this day.
)}
) : null} {vacationHover ? (
{vacationHover.type.replaceAll("_", " ")}
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
{vacationHover.note ? (
{vacationHover.note}
) : null}
) : null} ); } // ─── Pure render functions ────────────────────────────────────────────────── /** Assign lane indices to demands so overlapping bars don't stack on top of each other. */ function assignDemandLanes( demands: TimelineDemandEntry[], ): Map { const laneMap = new Map(); // Each lane tracks the latest end-date occupying it const laneEnds: Date[] = []; // Sort by start date for greedy lane assignment const sorted = [...demands].sort( (a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime(), ); for (const d of sorted) { const start = new Date(d.startDate); let assigned = -1; for (let i = 0; i < laneEnds.length; i++) { if (laneEnds[i]! < start) { assigned = i; laneEnds[i] = new Date(d.endDate); break; } } if (assigned === -1) { assigned = laneEnds.length; laneEnds.push(new Date(d.endDate)); } laneMap.set(d.id, assigned); } return laneMap; } const DEMAND_LANE_HEIGHT = 30; const DEMAND_LANE_GAP = 2; function renderOpenDemandRow( openDemands: TimelineDemandEntry[], CELL_WIDTH: number, totalCanvasWidth: number, toLeft: (d: Date) => number, toWidth: (s: Date, e: Date) => number, rowGridStyle: CSSProperties, onOpenDemandClick: (demand: OpenDemandAssignment) => void, ) { if (openDemands.length === 0) return null; const laneMap = assignDemandLanes(openDemands); const laneCount = laneMap.size > 0 ? Math.max(...laneMap.values()) + 1 : 1; const rowHeight = Math.max(ROW_HEIGHT, laneCount * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP) + 8); return (
?
Open demand
{openDemands.length} open demand{openDemands.length > 1 ? "s" : ""}
{openDemands.map((alloc) => { const allocStart = new Date(alloc.startDate); const allocEnd = new Date(alloc.endDate); const left = toLeft(allocStart); const width = Math.max(CELL_WIDTH, toWidth(allocStart, allocEnd)); if (width <= 0 || left >= totalCanvasWidth) return null; const roleEntity = ( alloc as { roleEntity?: { id: string; name: string; color: string | null } | null } ).roleEntity; const roleName = roleEntity?.name ?? (alloc as { role?: string | null }).role ?? "Open demand"; const roleColor = roleEntity?.color ?? "#f59e0b"; const headcount = (alloc as { headcount?: number }).headcount ?? 1; const lane = laneMap.get(alloc.id) ?? 0; const top = 4 + lane * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP); return (
{ onOpenDemandClick({ id: getPlanningEntryMutationId(alloc), projectId: alloc.projectId, roleId: (alloc as { roleId?: string | null }).roleId ?? null, role: (alloc as { role?: string | null }).role ?? null, headcount, startDate: allocStart, endDate: allocEnd, hoursPerDay: alloc.hoursPerDay, roleEntity: roleEntity ?? null, project: alloc.project as { id: string; name: string; shortCode: string }, }); }} > {roleName} {headcount > 1 ? ` x${headcount}` : ""}
); })}
); } // ─── Project-view: per-resource utilisation band ──────────────────────────── function renderProjectUtilOverlay( dayMetrics: ProjectDayMetric[], CELL_WIDTH: number, displayMode: string, heatmapScheme: string, totalCanvasWidth: number, ) { if (dayMetrics.length === 0 || totalCanvasWidth <= 0) return null; const BAND_H = 7; const BAR_H = ROW_HEIGHT - BAND_H - 11; const REF_H = 8; const useHeatmapColors = displayMode === "bar"; const svgParts: string[] = [ ``, ]; dayMetrics.forEach(({ projH, totalH }, i) => { if (totalH === 0 && projH === 0) return; const isOver = totalH > REF_H; const totalBarH = Math.max( projH > 0 ? 2 : 0, Math.round((Math.min(totalH, REF_H) / REF_H) * BAR_H), ); const projBarH = projH > 0 ? Math.min(totalBarH, Math.max(2, Math.round((projH / REF_H) * BAR_H))) : 0; const otherBarH = totalBarH - projBarH; const projPct = (projH / REF_H) * 100; const totalPct = (totalH / REF_H) * 100; const projColor = useHeatmapColors ? heatmapColor( projPct, heatmapScheme as import("~/hooks/useAppPreferences.js").HeatmapColorScheme, "bar", ) ?? "rgba(59,130,246,0.8)" : "rgba(96,165,250,0.8)"; const totalColor = useHeatmapColors ? heatmapColor( totalPct, heatmapScheme as import("~/hooks/useAppPreferences.js").HeatmapColorScheme, "bar", ) ?? "rgba(156,163,175,0.5)" : isOver ? "rgba(252,211,77,0.8)" : "rgba(209,213,219,0.8)"; const xBand = i * CELL_WIDTH + 1; const xBar = i * CELL_WIDTH + 3; const bandWidth = Math.max(CELL_WIDTH - 2, 0); const barWidth = Math.max(CELL_WIDTH - 6, 0); if (projH > 0 && bandWidth > 0) { svgParts.push( ``, ); } if (otherBarH > 0 && barWidth > 0) { svgParts.push( ``, ); } if (projBarH > 0 && barWidth > 0) { svgParts.push( ``, ); } if (isOver && totalBarH > 0 && barWidth > 0) { svgParts.push( ``, ); } }); svgParts.push(""); const svgDataUri = `url("data:image/svg+xml;utf8,${encodeURIComponent(svgParts.join(""))}")`; return (
); } // ─── Project-view: transparent drag handles ───────────────────────────────── function renderProjectDragHandles( allocs: TimelineAssignmentEntry[], allocDragState: AllocDragState, toLeft: (d: Date) => number, toWidth: (s: Date, e: Date) => number, CELL_WIDTH: number, totalCanvasWidth: number, onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void, onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void, onAllocationContextMenu: ( info: { allocationId: string; projectId: string }, anchorX: number, anchorY: number, ) => void, ) { return allocs.map((alloc) => { const allocStart = new Date(alloc.startDate); const allocEnd = new Date(alloc.endDate); const isAllocDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id; const dispStart = isAllocDragged && allocDragState.currentStartDate ? allocDragState.currentStartDate : allocStart; const dispEnd = isAllocDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : allocEnd; const left = toLeft(dispStart); const width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd)); if (width <= 0 || left >= totalCanvasWidth) return null; // Always show resize handles — for narrow bars, use overlapping handles const HANDLE_W = width >= 48 ? 8 : 6; const hasRecurrence = !!(alloc.metadata as Record | null)?.recurrence; const allocInfo: AllocMouseDownInfo = { mode: "move", allocationId: alloc.id, mutationAllocationId: getPlanningEntryMutationId(alloc), projectId: alloc.projectId, projectName: alloc.project.name, resourceId: alloc.resourceId, startDate: allocStart, endDate: allocEnd, }; return (
{ e.preventDefault(); e.stopPropagation(); onAllocationContextMenu( { allocationId: alloc.id, projectId: alloc.projectId }, e.clientX, e.clientY, ); }} >
onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" }); }} />
onAllocMouseDown(e, allocInfo)} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, allocInfo); }} > {hasRecurrence && width > 28 && ( )}
onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" }); }} />
); }); } // ─── Vacation blocks for project view rows ────────────────────────────────── const TYPE_COLORS: Record = { ANNUAL: "bg-orange-400/40", SICK: "bg-red-500/40", PUBLIC_HOLIDAY: "bg-violet-400/40", OTHER: "bg-amber-400/40", }; const TYPE_BORDER: Record = { ANNUAL: "border-orange-500", SICK: "border-red-600", PUBLIC_HOLIDAY: "border-violet-500", OTHER: "border-amber-500", }; const TYPE_LABELS_SHORT: Record = { ANNUAL: "Annual", SICK: "Sick", PUBLIC_HOLIDAY: "Holiday", OTHER: "Other", }; function renderVacationBlocksForProjectRow( vacations: { id: string; type: string; startDate: Date | string; endDate: Date | string }[], rowHeight: number, toLeft: (d: Date) => number, toWidth: (s: Date, e: Date) => number, CELL_WIDTH: number, totalCanvasWidth: number, showVacations: boolean, ) { if (!showVacations || vacations.length === 0) return null; return vacations.map((v) => { const vStart = new Date(v.startDate); const vEnd = new Date(v.endDate); const left = toLeft(vStart); const width = Math.max(CELL_WIDTH, toWidth(vStart, vEnd)); if (width <= 0 || left >= totalCanvasWidth) return null; const colorClass = TYPE_COLORS[v.type] ?? "bg-orange-400/40"; const borderClass = TYPE_BORDER[v.type] ?? "border-orange-500"; const label = TYPE_LABELS_SHORT[v.type] ?? v.type; return (
{width > 40 && ( 🏖 {label} )}
); }); } // ─── Range overlay for project view ───────────────────────────────────────── function renderRangeOverlayProject( rangeState: RangeState, resourceId: string, rowHeight: number, toLeft: (d: Date) => number, toWidth: (s: Date, e: Date) => number, CELL_WIDTH: number, ) { if (!rangeState.isSelecting || rangeState.resourceId !== resourceId || !rangeState.startDate) { return null; } const end = rangeState.currentDate ?? rangeState.startDate; const [selStart, selEnd] = rangeState.startDate <= end ? [rangeState.startDate, end] : [end, rangeState.startDate]; const left = toLeft(selStart); const width = Math.max(CELL_WIDTH, toWidth(selStart, selEnd)); return (
); }