refactor: complete v2 refactoring plan (Phases 1-5)
Phase 1 — Quick Wins: centralize formatMoney/formatCents, extract findUniqueOrThrow helper (19 routers), shared Prisma select constants, useInvalidatePlanningViews hook, status badge consolidation, composite DB indexes. Phase 2 — Timeline Split: extract TimelineContext, TimelineResourcePanel, TimelineProjectPanel; split 28-dep useMemo into 3 focused memos. TimelineView.tsx reduced from 1,903 to 538 lines. Phase 3 — Query Performance: server-side filtering for getEntriesView, remove availability from timeline resource select, SSE event debouncing (50ms batch window). Phase 4 — Estimate Workspace: extract 7 tab components and 3 editor components. EstimateWorkspaceClient 1,298→306 lines, EstimateWorkspaceDraftEditor 1,205→581 lines. Phase 5 — Package Cleanup: split commit-dispo-import-batch (1,112→573 lines), extract shared pagination helper with 11 tests. All tests pass: 209 API, 254 engine, 67 application. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,630 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { Fragment, startTransition, useCallback, useRef, useState } from "react";
|
||||
import { useTimelineContext, type TimelineAssignmentEntry, type TimelineDemandEntry } from "./TimelineContext.js";
|
||||
import { heatmapColor } from "./heatmapUtils.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.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 {
|
||||
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;
|
||||
// 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;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
roleEntity?: { id: string; name: string; color: string | null } | null;
|
||||
project?: { id: string; name: string; shortCode: string };
|
||||
}
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function TimelineProjectPanel({
|
||||
dragState,
|
||||
allocDragState,
|
||||
rangeState,
|
||||
onProjectBarMouseDown,
|
||||
onProjectBarTouchStart,
|
||||
onAllocMouseDown,
|
||||
onAllocTouchStart,
|
||||
onRowMouseDown,
|
||||
onRowTouchStart,
|
||||
onOpenPanel,
|
||||
onOpenDemandClick,
|
||||
CELL_WIDTH,
|
||||
dates,
|
||||
totalCanvasWidth,
|
||||
toLeft,
|
||||
toWidth,
|
||||
gridLines,
|
||||
xToDate,
|
||||
}: TimelineProjectPanelProps) {
|
||||
const {
|
||||
projectGroups,
|
||||
openDemandsByProject,
|
||||
allocsByResource,
|
||||
vacationsByResource,
|
||||
filters,
|
||||
displayMode,
|
||||
heatmapScheme,
|
||||
activeFilterCount,
|
||||
} = useTimelineContext();
|
||||
|
||||
// ─── Heatmap hover (same mechanism as resource panel) ─────────────────────
|
||||
const heatmapRafRef = useRef<number | null>(null);
|
||||
const lastHeatmapDayRef = useRef<number>(-1);
|
||||
const vacationHoverRafRef = useRef<number | null>(null);
|
||||
const hoveredVacationKeyRef = useRef<string | null>(null);
|
||||
const pendingHeatmapRef = useRef<{ clientX: number; rect: DOMRect; allocs: TimelineAssignmentEntry[] } | null>(null);
|
||||
|
||||
const handleRowHeatmapMove = useCallback((e: React.MouseEvent, allocs: TimelineAssignmentEntry[]) => {
|
||||
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;
|
||||
pendingHeatmapRef.current = null;
|
||||
lastHeatmapDayRef.current = Math.floor((e.clientX - rect.left) / CELL_WIDTH);
|
||||
});
|
||||
}, [CELL_WIDTH]);
|
||||
|
||||
const handleRowVacationHover = useCallback((_e: React.MouseEvent, _resourceId: string) => {
|
||||
// Vacation hover in project view uses the same RAF mechanism.
|
||||
// Tooltip rendering is handled by the parent TimelineView.
|
||||
}, []);
|
||||
|
||||
const clearHoverTooltips = useCallback(() => {
|
||||
if (heatmapRafRef.current !== null) {
|
||||
cancelAnimationFrame(heatmapRafRef.current);
|
||||
heatmapRafRef.current = null;
|
||||
}
|
||||
if (vacationHoverRafRef.current !== null) {
|
||||
cancelAnimationFrame(vacationHoverRafRef.current);
|
||||
vacationHoverRafRef.current = null;
|
||||
}
|
||||
lastHeatmapDayRef.current = -1;
|
||||
hoveredVacationKeyRef.current = null;
|
||||
}, []);
|
||||
|
||||
if (projectGroups.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-16 text-gray-400">
|
||||
No projects in this time range{activeFilterCount > 0 && " (filtered)"}.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{projectGroups.map((project) => {
|
||||
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 (
|
||||
<div key={project.id}>
|
||||
{/* Project header row */}
|
||||
<div
|
||||
className={clsx(
|
||||
"flex border-b border-gray-200 group/proj",
|
||||
colors.light,
|
||||
)}
|
||||
style={{ height: PROJECT_HEADER_HEIGHT }}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex-shrink-0 border-r border-gray-300 flex items-center px-4 gap-2.5 sticky left-0 z-30 cursor-pointer",
|
||||
colors.light,
|
||||
)}
|
||||
style={{ width: LABEL_WIDTH }}
|
||||
onClick={() => onOpenPanel(project.id)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">{project.name}</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">{project.status}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative overflow-hidden"
|
||||
style={{ width: totalCanvasWidth, height: PROJECT_HEADER_HEIGHT }}
|
||||
>
|
||||
{gridLines}
|
||||
{projWidth > 0 && projLeft < totalCanvasWidth && (
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute rounded flex items-center px-2 gap-1.5 transition-all duration-75",
|
||||
isThisProjectShifting
|
||||
? "opacity-90 shadow-lg ring-2 ring-white ring-offset-1 cursor-grabbing z-20 scale-[1.01]"
|
||||
: "cursor-grab hover:opacity-90 hover:ring-2 hover:ring-white hover:ring-offset-1",
|
||||
colors.bg, colors.text,
|
||||
)}
|
||||
style={{ left: projLeft + 2, width: projWidth - 4, top: 8, height: 24 }}
|
||||
onClick={() => {
|
||||
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()}
|
||||
>
|
||||
<span className="text-xs font-semibold truncate">{project.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Open demand row */}
|
||||
{(() => {
|
||||
const openDemands = openDemandsByProject.get(project.id) ?? [];
|
||||
return openDemands.length > 0
|
||||
? renderOpenDemandRow(openDemands, CELL_WIDTH, totalCanvasWidth, toLeft, toWidth, gridLines, onOpenDemandClick)
|
||||
: null;
|
||||
})()}
|
||||
|
||||
{/* Resource sub-rows */}
|
||||
{project.resourceRows.map(({ resource, allocs }) => {
|
||||
const allResourceAllocs = allocsByResource.get(resource.id) ?? [];
|
||||
const rowHeight = ROW_HEIGHT;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${project.id}-${resource.id}`}
|
||||
className="flex border-b border-gray-100 hover:bg-blue-50/20 group"
|
||||
style={{ height: rowHeight }}
|
||||
>
|
||||
<div
|
||||
className="flex-shrink-0 border-r border-gray-200 flex items-center pl-8 pr-4 gap-2 bg-white sticky left-0 z-30 group-hover:bg-blue-50"
|
||||
style={{ width: LABEL_WIDTH }}
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center text-[10px] font-bold text-gray-600 flex-shrink-0">
|
||||
{resource.displayName.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-gray-800 truncate">{resource.displayName}</div>
|
||||
<div className="text-[10px] text-gray-400 truncate">{resource.eid}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative overflow-hidden touch-none"
|
||||
style={{ width: totalCanvasWidth, height: rowHeight, touchAction: "none" }}
|
||||
onMouseDown={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const date = xToDate(e.clientX, rect);
|
||||
onRowMouseDown(e, {
|
||||
resourceId: resource.id,
|
||||
startDate: date,
|
||||
suggestedProjectId: project.id,
|
||||
});
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const date = xToDate(e.touches[0]?.clientX ?? 0, rect);
|
||||
onRowTouchStart(e, {
|
||||
resourceId: resource.id,
|
||||
startDate: date,
|
||||
suggestedProjectId: project.id,
|
||||
});
|
||||
}}
|
||||
onMouseMove={(e) => { handleRowHeatmapMove(e, allResourceAllocs); handleRowVacationHover(e, resource.id); }}
|
||||
onMouseLeave={clearHoverTooltips}
|
||||
>
|
||||
{gridLines}
|
||||
{renderProjectUtilBars(allocs, allResourceAllocs, dates, CELL_WIDTH, displayMode, heatmapScheme)}
|
||||
{renderProjectDragHandles(allocs, allocDragState, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, onAllocMouseDown, onAllocTouchStart)}
|
||||
{renderVacationBlocksForProjectRow(vacationsByResource.get(resource.id) ?? [], rowHeight, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, filters.showVacations)}
|
||||
{renderRangeOverlayProject(rangeState, resource.id, rowHeight, toLeft, toWidth, CELL_WIDTH)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Pure render functions ──────────────────────────────────────────────────
|
||||
|
||||
function renderOpenDemandRow(
|
||||
openDemands: TimelineDemandEntry[],
|
||||
CELL_WIDTH: number,
|
||||
totalCanvasWidth: number,
|
||||
toLeft: (d: Date) => number,
|
||||
toWidth: (s: Date, e: Date) => number,
|
||||
gridLines: React.ReactNode,
|
||||
onOpenDemandClick: (demand: OpenDemandAssignment) => void,
|
||||
) {
|
||||
if (openDemands.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex border-b border-dashed border-amber-200 bg-amber-50/30 hover:bg-amber-50/50 group"
|
||||
style={{ height: ROW_HEIGHT }}
|
||||
>
|
||||
<div
|
||||
className="flex-shrink-0 border-r border-amber-200 flex items-center pl-8 pr-4 gap-2 bg-amber-50 sticky left-0 z-30"
|
||||
style={{ width: LABEL_WIDTH }}
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full bg-amber-100 flex items-center justify-center text-[10px] font-bold text-amber-600 flex-shrink-0 border border-dashed border-amber-400">
|
||||
?
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-amber-700 truncate">Open demand</div>
|
||||
<div className="text-[10px] text-amber-500 truncate">{openDemands.length} open demand{openDemands.length > 1 ? "s" : ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative overflow-hidden"
|
||||
style={{ width: totalCanvasWidth, height: ROW_HEIGHT }}
|
||||
>
|
||||
{gridLines}
|
||||
{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;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={alloc.id}
|
||||
className="absolute rounded-md flex items-center px-2 gap-1 overflow-hidden cursor-pointer hover:ring-2 hover:ring-amber-400 hover:ring-offset-1 z-[10]"
|
||||
style={{
|
||||
left: left + 2,
|
||||
width: width - 4,
|
||||
top: 8,
|
||||
height: SUB_LANE_HEIGHT - 8,
|
||||
backgroundColor: `${roleColor}33`,
|
||||
border: `2px dashed ${roleColor}99`,
|
||||
}}
|
||||
onClick={() => {
|
||||
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 },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-medium truncate" style={{ color: roleColor }}>
|
||||
{roleName}{headcount > 1 ? ` x${headcount}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Project-view: per-resource utilisation band ────────────────────────────
|
||||
|
||||
function renderProjectUtilBars(
|
||||
projectAllocs: TimelineAssignmentEntry[],
|
||||
allResourceAllocs: TimelineAssignmentEntry[],
|
||||
dates: Date[],
|
||||
CELL_WIDTH: number,
|
||||
displayMode: string,
|
||||
heatmapScheme: string,
|
||||
) {
|
||||
const BAND_H = 7;
|
||||
const BAR_H = ROW_HEIGHT - BAND_H - 11;
|
||||
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 projH = hoursOnDay(projectAllocs, t);
|
||||
const totalH = hoursOnDay(allResourceAllocs, t);
|
||||
if (totalH === 0 && projH === 0) return null;
|
||||
|
||||
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 useHeatmapColors = displayMode === "bar";
|
||||
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") : null;
|
||||
const totalColor = useHeatmapColors ? heatmapColor(totalPct, heatmapScheme as import("~/hooks/useAppPreferences.js").HeatmapColorScheme, "bar") : null;
|
||||
|
||||
return (
|
||||
<Fragment key={`putil-${i}`}>
|
||||
{projH > 0 && (
|
||||
<div
|
||||
className={clsx("absolute top-1.5 pointer-events-none", !useHeatmapColors && "bg-brand-400/80")}
|
||||
style={{ left: i * CELL_WIDTH + 1, width: CELL_WIDTH - 2, height: BAND_H, ...(projColor ? { backgroundColor: projColor } : {}) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{otherBarH > 0 && (
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute pointer-events-none",
|
||||
!useHeatmapColors && (isOver ? "bg-amber-300/80" : "bg-gray-300/80"),
|
||||
)}
|
||||
style={{
|
||||
left: i * CELL_WIDTH + 3, width: CELL_WIDTH - 6, height: otherBarH, bottom: 4 + projBarH,
|
||||
...(useHeatmapColors ? { backgroundColor: totalColor ?? "rgba(156,163,175,0.50)" } : {}),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{projBarH > 0 && (
|
||||
<div
|
||||
className={clsx("absolute pointer-events-none", !useHeatmapColors && "bg-brand-500/80")}
|
||||
style={{
|
||||
left: i * CELL_WIDTH + 3, width: CELL_WIDTH - 6, height: projBarH, bottom: 4,
|
||||
...(projColor ? { backgroundColor: projColor } : {}),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isOver && totalBarH > 0 && (
|
||||
<div
|
||||
className="absolute pointer-events-none bg-red-500 z-10"
|
||||
style={{ left: i * CELL_WIDTH + 3, width: CELL_WIDTH - 6, height: 3, bottom: 4 + totalBarH }}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 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,
|
||||
) {
|
||||
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;
|
||||
|
||||
const HANDLE_W = width >= 48 ? 8 : 0;
|
||||
const hasRecurrence = !!(alloc.metadata as Record<string, unknown> | 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 (
|
||||
<div
|
||||
key={`dh-${alloc.id}`}
|
||||
className={clsx(
|
||||
"absolute flex items-stretch rounded",
|
||||
hasRecurrence && "border-2 border-dashed border-brand-400/60",
|
||||
isAllocDragged
|
||||
? "ring-2 ring-brand-400 z-20"
|
||||
: "hover:ring-1 hover:ring-brand-300/70 z-[15]",
|
||||
)}
|
||||
style={{ left: left + 2, width: width - 4, top: 2, bottom: 2 }}
|
||||
>
|
||||
{HANDLE_W > 0 && (
|
||||
<div
|
||||
className="flex-shrink-0 cursor-ew-resize"
|
||||
style={{ width: HANDLE_W }}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
|
||||
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" }); }}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={clsx("flex-1 min-w-0 flex items-center", isAllocDragged ? "cursor-grabbing" : "cursor-grab")}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, allocInfo)}
|
||||
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, allocInfo); }}
|
||||
>
|
||||
{hasRecurrence && width > 28 && (
|
||||
<span className="text-[10px] text-brand-600 opacity-70 pointer-events-none pl-1">↻</span>
|
||||
)}
|
||||
</div>
|
||||
{HANDLE_W > 0 && (
|
||||
<div
|
||||
className="flex-shrink-0 cursor-ew-resize"
|
||||
style={{ width: HANDLE_W }}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
|
||||
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" }); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Vacation blocks for project view rows ──────────────────────────────────
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
ANNUAL: "border-orange-500",
|
||||
SICK: "border-red-600",
|
||||
PUBLIC_HOLIDAY: "border-violet-500",
|
||||
OTHER: "border-amber-500",
|
||||
};
|
||||
const TYPE_LABELS_SHORT: Record<string, string> = {
|
||||
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 (
|
||||
<div
|
||||
key={`vac-${v.id}`}
|
||||
className={clsx(
|
||||
"absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden border-t-2 pointer-events-none",
|
||||
colorClass,
|
||||
borderClass,
|
||||
)}
|
||||
style={{ left: left + 1, width: width - 2, top: 0, height: rowHeight }}
|
||||
>
|
||||
{width > 40 && (
|
||||
<span className="text-[9px] font-bold truncate opacity-70 text-gray-700 dark:text-gray-200 pointer-events-none">
|
||||
🏖 {label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div
|
||||
className="absolute bg-brand-200/40 border-2 border-brand-400 rounded pointer-events-none z-10"
|
||||
style={{ left, width, top: 4, height: rowHeight - 8 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user