eb283147d1
- Fix sidebar double-highlight on /vacations/my (Gitea #6): add isNavItemActive() helper - Add project color picker (schema + API + modal + timeline rendering) - Add ProjectCombobox/ResourceCombobox to timeline toolbar - Show PENDING vacations on timeline with dashed/dimmed style - Add "show demand projects" preference with localStorage persistence - Add ProjectAssignmentsTable with total hours/cost columns - Extend vacation API to accept status arrays - Add GitLooper formal YAML agent configuration - Extend user admin with permission overrides UI - Add delete-assignment use case tests - Add status-styles.ts shared badge constants - Centralize formatMoney/formatCents in format.ts Co-Authored-By: claude-flow <ruv@ruv.net>
1343 lines
47 KiB
TypeScript
1343 lines
47 KiB
TypeScript
"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<HTMLDivElement | null>;
|
|
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<ReturnType<typeof useTimelineContext>["projectGroups"]>[number];
|
|
}
|
|
| {
|
|
type: "open-demand";
|
|
key: string;
|
|
projectId: string;
|
|
openDemands: TimelineDemandEntry[];
|
|
}
|
|
| {
|
|
type: "resource";
|
|
key: string;
|
|
project: NonNullable<ReturnType<typeof useTimelineContext>["projectGroups"]>[number];
|
|
resource: NonNullable<
|
|
ReturnType<typeof useTimelineContext>["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<number | null>(null);
|
|
const lastHeatmapDayRef = useRef<number>(-1);
|
|
const lastHeatmapResourceRef = useRef<string | null>(null);
|
|
const vacationHoverRafRef = useRef<number | null>(null);
|
|
const hoveredVacationKeyRef = useRef<string | null>(null);
|
|
const pendingHeatmapRef = useRef<{
|
|
clientX: number;
|
|
rect: DOMRect;
|
|
resourceId: string;
|
|
} | null>(null);
|
|
const heatmapTooltipRef = useRef<HTMLDivElement | null>(null);
|
|
const vacationTooltipRef = useRef<HTMLDivElement | null>(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 | {
|
|
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);
|
|
|
|
const { resourceHeatmapById, resourceTotalHoursById } = useMemo(() => {
|
|
const dateIndexByTime = new Map<number, number>();
|
|
dates.forEach((date, index) => {
|
|
const normalized = new Date(date);
|
|
normalized.setHours(0, 0, 0, 0);
|
|
dateIndexByTime.set(normalized.getTime(), index);
|
|
});
|
|
|
|
const nextHeatmapById = new Map<string, (HeatmapHoverState | null)[]>();
|
|
const nextTotalHoursById = new Map<string, number[]>();
|
|
|
|
for (const [resourceId, allocs] of allocsByResource) {
|
|
if (allocs.length === 0) continue;
|
|
|
|
const totalHours = new Array<number>(dates.length).fill(0);
|
|
const breakdownMaps = Array.from({ length: dates.length }, () => new Map<string, HeatmapBreakdownAccumulator>());
|
|
|
|
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<number, number>();
|
|
dates.forEach((date, index) => {
|
|
const normalized = new Date(date);
|
|
normalized.setHours(0, 0, 0, 0);
|
|
dateIndexByTime.set(normalized.getTime(), index);
|
|
});
|
|
|
|
const nextMetrics = new Map<string, ProjectDayMetric[]>();
|
|
|
|
for (const project of projectGroups) {
|
|
for (const { resource, allocs } of project.resourceRows) {
|
|
const projectHours = new Array<number>(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<string>();
|
|
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 (
|
|
<div className="text-center py-16 text-gray-400">
|
|
No projects in this time range{activeFilterCount > 0 && " (filtered)"}.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
height: totalRowHeight,
|
|
position: "relative",
|
|
}}
|
|
>
|
|
{virtualItems.map((virtualRow) => {
|
|
const row = flatRows[virtualRow.index];
|
|
if (!row) return null;
|
|
|
|
return (
|
|
<div
|
|
key={row.key}
|
|
data-index={virtualRow.index}
|
|
ref={rowVirtualizer.measureElement}
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
width: "100%",
|
|
transform: `translateY(${virtualRow.start}px)`,
|
|
}}
|
|
>
|
|
{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 (
|
|
<div
|
|
data-project-group="true"
|
|
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",
|
|
!customColor && colors.bg,
|
|
customColor ? "text-white" : colors.text,
|
|
)}
|
|
style={{
|
|
left: projLeft + 2,
|
|
width: projWidth - 4,
|
|
top: 8,
|
|
height: 24,
|
|
...(customColor ? { backgroundColor: customColor } : {}),
|
|
}}
|
|
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>
|
|
);
|
|
})()
|
|
) : row.type === "open-demand" ? (
|
|
renderOpenDemandRow(
|
|
row.openDemands,
|
|
CELL_WIDTH,
|
|
totalCanvasWidth,
|
|
toLeft,
|
|
toWidth,
|
|
resourceRowGridStyle,
|
|
onOpenDemandClick,
|
|
)
|
|
) : (
|
|
<div
|
|
data-project-resource-row="true"
|
|
className="flex border-b border-gray-100 hover:bg-blue-50/20 group"
|
|
style={{ height: ROW_HEIGHT }}
|
|
>
|
|
<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">
|
|
{row.resource.displayName.slice(0, 2).toUpperCase()}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="text-xs font-medium text-gray-800 truncate">
|
|
{row.resource.displayName}
|
|
</div>
|
|
<div className="text-[10px] text-gray-400 truncate">{row.resource.eid}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="relative overflow-hidden touch-none"
|
|
style={{
|
|
width: totalCanvasWidth,
|
|
height: ROW_HEIGHT,
|
|
touchAction: "none",
|
|
...resourceRowGridStyle,
|
|
}}
|
|
onMouseDown={(e) => {
|
|
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,
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
<ProjectPanelTooltips
|
|
heatmapTooltipRef={heatmapTooltipRef}
|
|
heatmapTooltipPos={heatmapTooltipPosRef.current}
|
|
vacationTooltipRef={vacationTooltipRef}
|
|
vacationTooltipPos={vacationTooltipPosRef.current}
|
|
heatmapHover={heatmapHover}
|
|
vacationHover={vacationHover}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProjectPanelTooltips({
|
|
heatmapTooltipRef,
|
|
heatmapTooltipPos,
|
|
vacationTooltipRef,
|
|
vacationTooltipPos,
|
|
heatmapHover,
|
|
vacationHover,
|
|
}: {
|
|
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
|
|
heatmapTooltipPos: { left: number; top: number };
|
|
vacationTooltipRef: React.RefObject<HTMLDivElement | null>;
|
|
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 ? (
|
|
<div
|
|
ref={heatmapTooltipRef}
|
|
style={{
|
|
left: heatmapTooltipPos.left,
|
|
top: heatmapTooltipPos.top,
|
|
backgroundColor: "rgba(3, 7, 18, 0.96)",
|
|
}}
|
|
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
|
|
<span className="text-[11px] text-gray-300">
|
|
{heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
|
|
</span>
|
|
</div>
|
|
<div className="mt-2 space-y-1.5">
|
|
{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.responsiblePerson
|
|
? `Lead: ${entry.responsiblePerson}`
|
|
: entry.orderType}
|
|
</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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{vacationHover ? (
|
|
<div
|
|
ref={vacationTooltipRef}
|
|
style={{
|
|
left: vacationTooltipPos.left,
|
|
top: vacationTooltipPos.top,
|
|
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">{vacationHover.type.replaceAll("_", " ")}</div>
|
|
<div className="mt-1 text-[11px] text-amber-100/90">
|
|
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
|
|
</div>
|
|
{vacationHover.note ? (
|
|
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
|
|
) : null}
|
|
</div>
|
|
) : 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<string, number> {
|
|
const laneMap = new Map<string, number>();
|
|
// 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 (
|
|
<div
|
|
className="flex border-b border-dashed border-amber-200 bg-amber-50/30 hover:bg-amber-50/50 group"
|
|
style={{ minHeight: rowHeight }}
|
|
>
|
|
<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, minHeight: rowHeight }}
|
|
>
|
|
<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, minHeight: rowHeight, ...rowGridStyle }}
|
|
>
|
|
{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 (
|
|
<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,
|
|
height: DEMAND_LANE_HEIGHT,
|
|
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 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[] = [
|
|
`<svg xmlns="${SVG_XMLNS}" width="${totalCanvasWidth}" height="${ROW_HEIGHT}" viewBox="0 0 ${totalCanvasWidth} ${ROW_HEIGHT}" preserveAspectRatio="none" shape-rendering="crispEdges">`,
|
|
];
|
|
|
|
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(
|
|
`<rect x="${xBand}" y="6" width="${bandWidth}" height="${BAND_H}" fill="${projColor}" />`,
|
|
);
|
|
}
|
|
|
|
if (otherBarH > 0 && barWidth > 0) {
|
|
svgParts.push(
|
|
`<rect x="${xBar}" y="${ROW_HEIGHT - 4 - projBarH - otherBarH}" width="${barWidth}" height="${otherBarH}" fill="${totalColor}" />`,
|
|
);
|
|
}
|
|
|
|
if (projBarH > 0 && barWidth > 0) {
|
|
svgParts.push(
|
|
`<rect x="${xBar}" y="${ROW_HEIGHT - 4 - projBarH}" width="${barWidth}" height="${projBarH}" fill="${projColor}" />`,
|
|
);
|
|
}
|
|
|
|
if (isOver && totalBarH > 0 && barWidth > 0) {
|
|
svgParts.push(
|
|
`<rect x="${xBar}" y="${ROW_HEIGHT - 4 - totalBarH - 3}" width="${barWidth}" height="3" fill="rgb(239,68,68)" />`,
|
|
);
|
|
}
|
|
});
|
|
|
|
svgParts.push("</svg>");
|
|
const svgDataUri = `url("data:image/svg+xml;utf8,${encodeURIComponent(svgParts.join(""))}")`;
|
|
|
|
return (
|
|
<div
|
|
data-project-util-bar="true"
|
|
className="absolute inset-0 pointer-events-none"
|
|
style={{
|
|
backgroundImage: svgDataUri,
|
|
backgroundPosition: "0 0",
|
|
backgroundRepeat: "no-repeat",
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ─── 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<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 }}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onAllocationContextMenu(
|
|
{ allocationId: alloc.id, projectId: alloc.projectId },
|
|
e.clientX,
|
|
e.clientY,
|
|
);
|
|
}}
|
|
>
|
|
<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>
|
|
<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 }}
|
|
/>
|
|
);
|
|
}
|