"use client"; import { clsx } from "clsx"; import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useTimelineContext, type TimelineAssignmentEntry, type VacationEntry, } from "./TimelineContext.js"; import { ConflictOverlay } from "./ConflictOverlay.js"; import { computeSubLanes } from "./utils.js"; import { heatmapBgColor, heatmapColor } from "./heatmapUtils.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { formatDateLong } from "~/lib/format.js"; import { ROW_HEIGHT, SUB_LANE_HEIGHT, LABEL_WIDTH, ORDER_TYPE_COLORS, } from "./timelineConstants.js"; import type { DragState, AllocDragState, RangeState, ShiftPreviewData, } from "~/hooks/useTimelineDrag.js"; import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js"; // ─── Props ────────────────────────────────────────────────────────────────── interface TimelineResourcePanelProps { scrollContainerRef: React.RefObject; dragState: DragState; allocDragState: AllocDragState; rangeState: RangeState; shiftPreview: ShiftPreviewData | null; contextResourceIds: string[]; 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; 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 AllocMouseDownInfo { mode: "move" | "resize-start" | "resize-end"; allocationId: string; mutationAllocationId: string; projectId: string; projectName: string; resourceId: string | null; startDate: Date; endDate: Date; } export interface RowMouseDownInfo { resourceId: string; startDate: Date; suggestedProjectId?: string; } // ─── Component ────────────────────────────────────────────────────────────── export function TimelineResourcePanel({ scrollContainerRef, dragState, allocDragState, rangeState, shiftPreview, contextResourceIds, onAllocMouseDown, onAllocTouchStart, onRowMouseDown, onRowTouchStart, onAllocationContextMenu, CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, xToDate, }: TimelineResourcePanelProps) { const { resources, allocsByResource, vacationsByResource, filters, viewStart, viewEnd, displayMode, heatmapScheme, activeFilterCount, } = useTimelineContext(); // ─── Heatmap hover state ──────────────────────────────────────────────────── const heatmapRafRef = useRef(null); const lastHeatmapDayRef = useRef(-1); const vacationHoverRafRef = useRef(null); const hoveredVacationKeyRef = useRef(null); const pendingHeatmapRef = useRef<{ clientX: number; rect: DOMRect; allocs: TimelineAssignmentEntry[]; } | 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: { projectId: string; shortCode: string; projectName: string; orderType: string; hoursPerDay: number; responsiblePerson?: string | null; }[]; } | null>(null); const [vacationHover, setVacationHover] = useState(null); // ─── Virtual row list ──────────────────────────────────────────────────────── const rowVirtualizer = useVirtualizer({ count: resources.length, getScrollElement: () => scrollContainerRef.current, estimateSize: () => ROW_HEIGHT, overscan: 5, }); const virtualItems = rowVirtualizer.getVirtualItems(); const totalRowHeight = rowVirtualizer.getTotalSize(); // ─── Memo 1: resourceRows — which rows to render ───────────────────────── // (virtualizer handles which subset is visible; this memo just pre-computes // per-row data that the render loop needs) const resourceRows = useMemo(() => { return resources.map((resource) => { const allocs = allocsByResource.get(resource.id) ?? []; const isContextResource = contextResourceIds.includes(resource.id); return { resource, allocs, isContextResource }; }); }, [resources, allocsByResource, contextResourceIds]); // ─── Memo 2: vacationBlocks — vacation bar positions per resource ───────── const vacationBlocksByResource = useMemo(() => { if (!filters.showVacations) return new Map(); const result = new Map(); for (const [resourceId, vacations] of vacationsByResource) { const blocks: VacationBlockInfo[] = []; for (const v of vacations) { 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) continue; blocks.push({ vacation: v, left, width }); } if (blocks.length > 0) { result.set(resourceId, blocks); } } return result; }, [vacationsByResource, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, filters.showVacations]); // ─── Memo 3: assignmentBlocks — pre-computed per resource for strip mode ── // (Bar mode computes differently per-day, so we only pre-compute for strip.) const assignmentBlocksByResource = useMemo(() => { if (displayMode === "bar") return new Map(); const result = new Map(); for (const { resource, allocs } of resourceRows) { if (allocs.length === 0) continue; const subLaneMap = computeSubLanes( allocs.map((a) => ({ id: a.id, startDate: new Date(a.startDate), endDate: new Date(a.endDate), })), ); const laneCount = subLaneMap.size > 0 ? Math.max(...subLaneMap.values()) + 1 : 1; const blockData: AllocBlockData[] = allocs.map((alloc) => ({ alloc, lane: subLaneMap.get(alloc.id) ?? 0, })); result.set(resource.id, { laneCount, blockData }); } return result; }, [displayMode, resourceRows]); // ─── Heatmap row hover handler ──────────────────────────────────────────── const handleRowHeatmapMove = useCallback( (e: React.MouseEvent, allocs: TimelineAssignmentEntry[]) => { 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) return; pendingHeatmapRef.current = { clientX: e.clientX, rect, allocs }; if (heatmapRafRef.current !== null) return; heatmapRafRef.current = requestAnimationFrame(() => { heatmapRafRef.current = null; const pending = pendingHeatmapRef.current; pendingHeatmapRef.current = null; if (!pending) return; const { clientX, rect: r, allocs: a } = pending; const dayIdx = Math.floor((clientX - r.left) / CELL_WIDTH); const date = dates[dayIdx]; if (!date) { lastHeatmapDayRef.current = -1; startTransition(() => setHeatmapHover(null)); return; } lastHeatmapDayRef.current = dayIdx; const t = date.getTime(); const REF_H = 8; const projectHours = new Map< string, { shortCode: string; projectName: string; orderType: string; hours: number; responsiblePerson?: string | null; } >(); for (const alloc of a) { const s = new Date(alloc.startDate); s.setHours(0, 0, 0, 0); const ev = new Date(alloc.endDate); ev.setHours(0, 0, 0, 0); if (t < s.getTime() || t > ev.getTime()) continue; const existing = projectHours.get(alloc.projectId); if (existing) { existing.hours += alloc.hoursPerDay; } else { projectHours.set(alloc.projectId, { shortCode: alloc.project.shortCode, projectName: alloc.project.name, orderType: alloc.project.orderType, hours: alloc.hoursPerDay, responsiblePerson: (alloc.project as { responsiblePerson?: string | null }).responsiblePerson ?? null, }); } } const breakdown = [...projectHours.entries()] .map(([projectId, v]) => ({ projectId, ...v, hoursPerDay: v.hours })) .sort((a, b) => b.hoursPerDay - a.hoursPerDay); const totalH = breakdown.reduce((sum, b) => sum + b.hoursPerDay, 0); startTransition(() => { setHeatmapHover({ date, totalH, pct: (totalH / REF_H) * 100, breakdown }); }); }); }, [CELL_WIDTH, dates], ); // ─── Vacation hover ─────────────────────────────────────────────────────── const handleRowVacationHover = useCallback( (e: React.MouseEvent, resourceId: string) => { 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 t = date.getTime(); const resourceVacations = vacationsByResource.get(resourceId) ?? []; const hit = resourceVacations.find((v) => { const s = new Date(v.startDate); s.setHours(0, 0, 0, 0); const end = new Date(v.endDate); end.setHours(0, 0, 0, 0); return t >= s.getTime() && t <= end.getTime(); }) ?? null; const nextKey = hit ? `${resourceId}:${hit.id}` : null; if (nextKey === hoveredVacationKeyRef.current) return; hoveredVacationKeyRef.current = nextKey; startTransition(() => { setVacationHover(hit); }); }); }, [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; hoveredVacationKeyRef.current = null; if (shouldClearHeatmap || shouldClearVacation) { startTransition(() => { if (shouldClearHeatmap) setHeatmapHover(null); if (shouldClearVacation) setVacationHover(null); }); } }, []); // ─── Cleanup rAF on unmount ─────────────────────────────────────────────── useEffect( () => () => { if (heatmapRafRef.current !== null) cancelAnimationFrame(heatmapRafRef.current); if (vacationHoverRafRef.current !== null) cancelAnimationFrame(vacationHoverRafRef.current); }, [], ); // ─── Render helpers ─────────────────────────────────────────────────────── if (resources.length === 0) { return (
No allocations in this time range{activeFilterCount > 0 && " (filtered)"}.
); } return (
{virtualItems.map((virtualRow) => { const rowData = resourceRows[virtualRow.index]; if (!rowData) return null; const { resource, allocs, isContextResource } = rowData; const inBarMode = displayMode === "bar"; const precomputed = assignmentBlocksByResource.get(resource.id); const laneCount = inBarMode ? 1 : (precomputed?.laneCount ?? 1); const rowHeight = inBarMode ? ROW_HEIGHT : Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16); return (
{/* Label column */}
{resource.displayName.slice(0, 2).toUpperCase()}
{resource.displayName}
{resource.chapter ?? resource.eid}
{/* Row canvas */}
{ const rect = e.currentTarget.getBoundingClientRect(); const date = xToDate(e.clientX, rect); onRowMouseDown(e, { resourceId: resource.id, startDate: date }); }} onTouchStart={(e) => { const rect = e.currentTarget.getBoundingClientRect(); const date = xToDate(e.touches[0]?.clientX ?? 0, rect); onRowTouchStart(e, { resourceId: resource.id, startDate: date }); }} onMouseMove={(e) => { handleRowHeatmapMove(e, allocs); handleRowVacationHover(e, resource.id); }} onMouseLeave={clearHoverTooltips} > {gridLines} {inBarMode ? renderDailyBars( allocs, rowHeight, CELL_WIDTH, dates, allocDragState, onAllocMouseDown, onAllocTouchStart, onAllocationContextMenu, toLeft, toWidth, totalCanvasWidth, ) : renderAllocBlocksFromData( precomputed?.blockData ?? [], allocs, dragState, allocDragState, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, onAllocMouseDown, onAllocTouchStart, onAllocationContextMenu, )} {renderVacationBlocksForRow( vacationBlocksByResource.get(resource.id) ?? [], rowHeight, )} {displayMode === "strip" && renderLoadGraph(allocs, dates, CELL_WIDTH)} {displayMode === "heatmap" && renderHeatmapOverlay(allocs, dates, CELL_WIDTH, heatmapScheme)} {renderRangeOverlay( rangeState, resource.id, rowHeight, toLeft, toWidth, CELL_WIDTH, )} {dragState.isDragging && dragState.projectId && shiftPreview && !shiftPreview.valid && shiftPreview.conflictCount > 0 && allocs.some((a) => a.projectId === dragState.projectId) && ( )}
); })} {/* Tooltips rendered inside the panel so they live near their data source */}
); } // ─── Tooltip sub-component (portal-free: positioned fixed) ────────────────── function ResourcePanelTooltips({ 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} ); } // ─── Helper types ─────────────────────────────────────────────────────────── interface VacationBlockInfo { vacation: VacationEntry; left: number; width: number; } interface AllocBlockData { alloc: TimelineAssignmentEntry; lane: number; } // ─── Pure render functions (no hooks, extracted from TimelineView) ─────────── 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 renderVacationBlocksForRow(blocks: VacationBlockInfo[], rowHeight: number) { if (blocks.length === 0) return null; return blocks.map(({ vacation: v, left, width }) => { 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; const isPending = v.status === "PENDING"; return (
{width > 40 && ( {isPending ? "\u23F3" : "\uD83C\uDFD6"} {label} )}
); }); } function renderRangeOverlay( 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 (
); } function renderAllocBlocksFromData( blockData: AllocBlockData[], _allocs: TimelineAssignmentEntry[], dragState: DragState, 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, ) { const anyDragActive = dragState.isDragging || allocDragState.isActive; return blockData.map(({ alloc, lane }) => { const allocStart = new Date(alloc.startDate); const allocEnd = new Date(alloc.endDate); const isProjectShifted = dragState.isDragging && dragState.projectId === alloc.projectId; const isAllocDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id; const isBeingDragged = isProjectShifted || isAllocDragged; const isOtherDragged = anyDragActive && !isBeingDragged; let dispStart = allocStart; let dispEnd = allocEnd; if (isProjectShifted && dragState.currentStartDate && dragState.currentEndDate) { dispStart = dragState.currentStartDate; dispEnd = dragState.currentEndDate; } else if (isAllocDragged && allocDragState.currentStartDate && allocDragState.currentEndDate) { dispStart = allocDragState.currentStartDate; dispEnd = allocDragState.currentEndDate; } const left = toLeft(dispStart); const width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd)); if (width <= 0 || left >= totalCanvasWidth) return null; const blockTop = 8 + lane * SUB_LANE_HEIGHT; const blockHeight = SUB_LANE_HEIGHT - 8; const customColor = (alloc.project as { color?: string | null }).color; const colors = ORDER_TYPE_COLORS[alloc.project.orderType] ?? { bg: "bg-gray-400", text: "text-white", light: "", }; const HANDLE_W = width >= 48 ? 10 : 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, ); }} > {/* Left resize handle */}
onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" }); }} > {HANDLE_W >= 10 && (
)}
{/* Center -- move */}
onAllocMouseDown(e, allocInfo)} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, allocInfo); }} > {hasRecurrence && width > 28 && ( )} {alloc.project.name} {width > 130 && {alloc.role}} {width > 190 && ( {alloc.hoursPerDay}h )}
{/* Right resize handle */}
onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" }); }} > {HANDLE_W >= 10 && (
)}
); }); } // ─── Strip-mode: daily load graph ──────────────────────────────────────────── function renderLoadGraph(allocs: TimelineAssignmentEntry[], dates: Date[], CELL_WIDTH: number) { const GRAPH_H = 12; const REF_H = 8; function hoursOnDay(list: TimelineAssignmentEntry[], t: number) { return list.reduce((sum, a) => { const s = new Date(a.startDate); s.setHours(0, 0, 0, 0); const e = new Date(a.endDate); e.setHours(0, 0, 0, 0); return t >= s.getTime() && t <= e.getTime() ? sum + a.hoursPerDay : sum; }, 0); } return (
{dates.map((date, i) => { const t = date.getTime(); const totalH = hoursOnDay(allocs, t); if (totalH === 0) return null; const totalBarH = Math.min(GRAPH_H, Math.round((totalH / REF_H) * GRAPH_H)); return (
12 ? "bg-red-500 opacity-80" : totalH > 8 ? "bg-amber-400 opacity-80" : "bg-brand-500 opacity-80", )} style={{ left: i * CELL_WIDTH + 3, width: CELL_WIDTH - 6, height: totalBarH }} /> ); })}
); } // ─── Heatmap-mode: utilisation colour overlay ──────────────────────────────── function renderHeatmapOverlay( allocs: TimelineAssignmentEntry[], dates: Date[], CELL_WIDTH: number, heatmapScheme: HeatmapColorScheme, ) { const REF_H = 8; return dates.map((date, i) => { const t = date.getTime(); const totalH = allocs.reduce((sum, a) => { const s = new Date(a.startDate); s.setHours(0, 0, 0, 0); const e = new Date(a.endDate); e.setHours(0, 0, 0, 0); return t >= s.getTime() && t <= e.getTime() ? sum + a.hoursPerDay : sum; }, 0); const bg = heatmapBgColor((totalH / REF_H) * 100, heatmapScheme); if (!bg) return null; return (
); }); } // ─── Bar-mode: stacked daily bars ──────────────────────────────────────────── function renderDailyBars( allocs: TimelineAssignmentEntry[], rowHeight: number, CELL_WIDTH: number, dates: Date[], allocDragState: AllocDragState, 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, toLeft: (d: Date) => number, toWidth: (s: Date, e: Date) => number, totalCanvasWidth: number, ) { const BAR_AREA = rowHeight - 8; const REF_H = 8; return dates.flatMap((date, i) => { const t = date.getTime(); const covering = allocs.filter((a) => { const isDragged = allocDragState.isActive && allocDragState.allocationId === a.id; const s = new Date( isDragged && allocDragState.currentStartDate ? allocDragState.currentStartDate : a.startDate, ); const e = new Date( isDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : a.endDate, ); s.setHours(0, 0, 0, 0); e.setHours(0, 0, 0, 0); return t >= s.getTime() && t <= e.getTime(); }); if (covering.length === 0) return []; const totalH = covering.reduce((sum, a) => sum + a.hoursPerDay, 0); const isOver = totalH > REF_H; let stackedH = 0; const segs: React.ReactNode[] = covering.map((alloc) => { const colors = ORDER_TYPE_COLORS[alloc.project.orderType] ?? { bg: "bg-gray-400", text: "text-white", light: "", }; const segH = Math.max( 2, Math.min(BAR_AREA - stackedH, Math.round((alloc.hoursPerDay / REF_H) * BAR_AREA)), ); const bottom = 4 + stackedH; stackedH += segH; const isBeingDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id; const dispStart = new Date( isBeingDragged && allocDragState.currentStartDate ? allocDragState.currentStartDate : alloc.startDate, ); const dispEnd = new Date( isBeingDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : alloc.endDate, ); dispStart.setHours(0, 0, 0, 0); dispEnd.setHours(0, 0, 0, 0); const isFirstDay = t === dispStart.getTime(); const isLastDay = t === dispEnd.getTime(); const EDGE_W = CELL_WIDTH >= 16 ? 4 : 0; const allocInfo: AllocMouseDownInfo = { mode: "move", allocationId: alloc.id, mutationAllocationId: getPlanningEntryMutationId(alloc), projectId: alloc.projectId, projectName: alloc.project.name, resourceId: alloc.resourceId, startDate: new Date(alloc.startDate), endDate: new Date(alloc.endDate), }; return (
{ e.preventDefault(); e.stopPropagation(); onAllocationContextMenu( { allocationId: alloc.id, projectId: alloc.projectId }, e.clientX, e.clientY, ); }} > {isFirstDay && EDGE_W > 0 && (
{ e.stopPropagation(); onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" }); }} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" }); }} /> )}
{ e.stopPropagation(); onAllocMouseDown(e, allocInfo); }} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, allocInfo); }} /> {isLastDay && EDGE_W > 0 && (
{ e.stopPropagation(); onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" }); }} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" }); }} /> )}
); }); if (isOver) { segs.push(
, ); } return segs; }); } // ─── Re-export tooltip types for the parent ───────────────────────────────── export type { VacationBlockInfo, AllocBlockData };