feat: timeline UI overhaul with project/resource panel redesign, quick filters, and API improvements
Redesigned timeline project and resource panels with expanded detail views, added quick filter toolbar, improved drag handling, and enhanced vacation/entitlement router logic. Includes e2e test updates and minor API fixes. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -3,18 +3,28 @@
|
||||
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 {
|
||||
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 {
|
||||
DragState,
|
||||
AllocDragState,
|
||||
RangeState,
|
||||
ShiftPreviewData,
|
||||
} from "~/hooks/useTimelineDrag.js";
|
||||
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
|
||||
|
||||
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||
@@ -30,6 +40,11 @@ interface TimelineResourcePanelProps {
|
||||
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[];
|
||||
@@ -70,6 +85,7 @@ export function TimelineResourcePanel({
|
||||
onAllocTouchStart,
|
||||
onRowMouseDown,
|
||||
onRowTouchStart,
|
||||
onAllocationContextMenu,
|
||||
CELL_WIDTH,
|
||||
dates,
|
||||
totalCanvasWidth,
|
||||
@@ -95,17 +111,35 @@ export function TimelineResourcePanel({
|
||||
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 pendingHeatmapRef = useRef<{
|
||||
clientX: number;
|
||||
rect: DOMRect;
|
||||
allocs: TimelineAssignmentEntry[];
|
||||
} | 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: { projectId: string; shortCode: string; projectName: string; orderType: string; hoursPerDay: number; responsiblePerson?: string | null }[];
|
||||
breakdown: {
|
||||
projectId: string;
|
||||
shortCode: string;
|
||||
projectName: string;
|
||||
orderType: string;
|
||||
hoursPerDay: number;
|
||||
responsiblePerson?: string | null;
|
||||
}[];
|
||||
} | null>(null);
|
||||
|
||||
const [vacationHover, setVacationHover] = useState<null | {
|
||||
type: string; startDate: Date | string; endDate: Date | string; note?: string | 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;
|
||||
@@ -157,14 +191,19 @@ export function TimelineResourcePanel({
|
||||
// ─── 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<string, { laneCount: number; blockData: AllocBlockData[] }>();
|
||||
if (displayMode === "bar")
|
||||
return new Map<string, { laneCount: number; blockData: AllocBlockData[] }>();
|
||||
|
||||
const result = new Map<string, { laneCount: number; blockData: AllocBlockData[] }>();
|
||||
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) })),
|
||||
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) => ({
|
||||
@@ -177,89 +216,120 @@ export function TimelineResourcePanel({
|
||||
}, [displayMode, resourceRows]);
|
||||
|
||||
// ─── Heatmap row hover handler ────────────────────────────────────────────
|
||||
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;
|
||||
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;
|
||||
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`;
|
||||
}
|
||||
lastHeatmapDayRef.current = dayIdx;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const dayIndex = Math.floor((e.clientX - rect.left) / CELL_WIDTH);
|
||||
if (dayIndex === lastHeatmapDayRef.current) return;
|
||||
|
||||
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,
|
||||
});
|
||||
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 breakdown = [...projectHours.entries()]
|
||||
.map(([projectId, v]) => ({ projectId, ...v, hoursPerDay: v.hours }))
|
||||
.sort((a, b) => b.hoursPerDay - a.hoursPerDay);
|
||||
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 totalH = breakdown.reduce((sum, b) => sum + b.hoursPerDay, 0);
|
||||
startTransition(() => {
|
||||
setHeatmapHover({ date, totalH, pct: (totalH / REF_H) * 100, breakdown });
|
||||
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]);
|
||||
},
|
||||
[CELL_WIDTH, dates],
|
||||
);
|
||||
|
||||
// ─── Vacation hover ───────────────────────────────────────────────────────
|
||||
const handleRowVacationHover = useCallback((e: React.MouseEvent, resourceId: string) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clientX = e.clientX;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
const nextKey = hit ? `${resourceId}:${hit.id}` : null;
|
||||
if (nextKey === hoveredVacationKeyRef.current) return;
|
||||
|
||||
hoveredVacationKeyRef.current = nextKey;
|
||||
startTransition(() => {
|
||||
setVacationHover(hit);
|
||||
hoveredVacationKeyRef.current = nextKey;
|
||||
startTransition(() => {
|
||||
setVacationHover(hit);
|
||||
});
|
||||
});
|
||||
});
|
||||
}, [vacationsByResource, xToDate]);
|
||||
},
|
||||
[vacationsByResource, xToDate],
|
||||
);
|
||||
|
||||
const clearHoverTooltips = useCallback(() => {
|
||||
if (heatmapRafRef.current !== null) {
|
||||
@@ -286,10 +356,13 @@ export function TimelineResourcePanel({
|
||||
}, []);
|
||||
|
||||
// ─── Cleanup rAF on unmount ───────────────────────────────────────────────
|
||||
useEffect(() => () => {
|
||||
if (heatmapRafRef.current !== null) cancelAnimationFrame(heatmapRafRef.current);
|
||||
if (vacationHoverRafRef.current !== null) cancelAnimationFrame(vacationHoverRafRef.current);
|
||||
}, []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (heatmapRafRef.current !== null) cancelAnimationFrame(heatmapRafRef.current);
|
||||
if (vacationHoverRafRef.current !== null) cancelAnimationFrame(vacationHoverRafRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// ─── Render helpers ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -315,7 +388,9 @@ export function TimelineResourcePanel({
|
||||
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);
|
||||
const rowHeight = inBarMode
|
||||
? ROW_HEIGHT
|
||||
: Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -349,8 +424,12 @@ export function TimelineResourcePanel({
|
||||
{resource.displayName.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">{resource.displayName}</div>
|
||||
<div className="text-xs text-gray-400 truncate">{resource.chapter ?? resource.eid}</div>
|
||||
<div className="text-sm font-medium text-gray-900 truncate">
|
||||
{resource.displayName}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 truncate">
|
||||
{resource.chapter ?? resource.eid}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -368,27 +447,75 @@ export function TimelineResourcePanel({
|
||||
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); }}
|
||||
onMouseMove={(e) => {
|
||||
handleRowHeatmapMove(e, allocs);
|
||||
handleRowVacationHover(e, resource.id);
|
||||
}}
|
||||
onMouseLeave={clearHoverTooltips}
|
||||
>
|
||||
{gridLines}
|
||||
{inBarMode
|
||||
? renderDailyBars(allocs, rowHeight, CELL_WIDTH, dates, allocDragState, onAllocMouseDown, onAllocTouchStart, toLeft, toWidth, totalCanvasWidth)
|
||||
: renderAllocBlocksFromData(precomputed?.blockData ?? [], allocs, dragState, allocDragState, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, onAllocMouseDown, onAllocTouchStart)}
|
||||
{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) && (
|
||||
<ConflictOverlay
|
||||
left={toLeft(dragState.currentStartDate ?? viewStart) + 2}
|
||||
width={toWidth(dragState.currentStartDate ?? viewStart, dragState.currentEndDate ?? viewEnd) - 4}
|
||||
height={rowHeight - 8}
|
||||
type="availability"
|
||||
message={`${shiftPreview.conflictCount} conflict(s)`}
|
||||
/>
|
||||
? 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) && (
|
||||
<ConflictOverlay
|
||||
left={toLeft(dragState.currentStartDate ?? viewStart) + 2}
|
||||
width={
|
||||
toWidth(
|
||||
dragState.currentStartDate ?? viewStart,
|
||||
dragState.currentEndDate ?? viewEnd,
|
||||
) - 4
|
||||
}
|
||||
height={rowHeight - 8}
|
||||
type="availability"
|
||||
message={`${shiftPreview.conflictCount} conflict(s)`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -397,6 +524,10 @@ export function TimelineResourcePanel({
|
||||
|
||||
{/* Tooltips rendered inside the panel so they live near their data source */}
|
||||
<ResourcePanelTooltips
|
||||
heatmapTooltipRef={heatmapTooltipRef}
|
||||
heatmapTooltipPos={heatmapTooltipPosRef.current}
|
||||
vacationTooltipRef={vacationTooltipRef}
|
||||
vacationTooltipPos={vacationTooltipPosRef.current}
|
||||
heatmapHover={heatmapHover}
|
||||
vacationHover={vacationHover}
|
||||
/>
|
||||
@@ -407,33 +538,109 @@ export function TimelineResourcePanel({
|
||||
// ─── Tooltip sub-component (portal-free: positioned fixed) ──────────────────
|
||||
|
||||
function ResourcePanelTooltips({
|
||||
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 }[];
|
||||
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;
|
||||
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;
|
||||
}) {
|
||||
// These tooltips are rendered here but positioned by the parent's native
|
||||
// mousemove handler via ref. The parent passes tooltip refs via the
|
||||
// TimelineView orchestrator. For simplicity, we keep the tooltip DOM
|
||||
// here but expose ref-based positioning from the parent via
|
||||
// data-attributes that the parent's mousemove handler targets.
|
||||
//
|
||||
// NOTE: The actual positioning is still done by the parent TimelineView's
|
||||
// native mousemove event handler using refs. These tooltips are rendered
|
||||
// inside TimelineView's return, not here. This sub-component is a no-op
|
||||
// for tooltip DOM — the parent handles it.
|
||||
return 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helper types ───────────────────────────────────────────────────────────
|
||||
@@ -511,9 +718,7 @@ function renderRangeOverlay(
|
||||
}
|
||||
const end = rangeState.currentDate ?? rangeState.startDate;
|
||||
const [selStart, selEnd] =
|
||||
rangeState.startDate <= end
|
||||
? [rangeState.startDate, end]
|
||||
: [end, rangeState.startDate];
|
||||
rangeState.startDate <= end ? [rangeState.startDate, end] : [end, rangeState.startDate];
|
||||
|
||||
const left = toLeft(selStart);
|
||||
const width = Math.max(CELL_WIDTH, toWidth(selStart, selEnd));
|
||||
@@ -537,6 +742,11 @@ function renderAllocBlocksFromData(
|
||||
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;
|
||||
|
||||
@@ -566,7 +776,11 @@ function renderAllocBlocksFromData(
|
||||
const blockTop = 8 + lane * SUB_LANE_HEIGHT;
|
||||
const blockHeight = SUB_LANE_HEIGHT - 8;
|
||||
|
||||
const colors = ORDER_TYPE_COLORS[alloc.project.orderType] ?? { bg: "bg-gray-400", text: "text-white", light: "" };
|
||||
const colors = ORDER_TYPE_COLORS[alloc.project.orderType] ?? {
|
||||
bg: "bg-gray-400",
|
||||
text: "text-white",
|
||||
light: "",
|
||||
};
|
||||
const HANDLE_W = width >= 48 ? 10 : 0;
|
||||
const hasRecurrence = !!(alloc.metadata as Record<string, unknown> | null)?.recurrence;
|
||||
|
||||
@@ -586,16 +800,25 @@ function renderAllocBlocksFromData(
|
||||
key={alloc.id}
|
||||
className={clsx(
|
||||
"absolute rounded-md flex items-stretch overflow-hidden transition-all duration-75 group/block",
|
||||
colors.bg, colors.text,
|
||||
colors.bg,
|
||||
colors.text,
|
||||
hasRecurrence && "opacity-80 border-2 border-dashed border-white/60",
|
||||
isBeingDragged
|
||||
? "opacity-90 shadow-2xl ring-2 ring-white ring-offset-1 z-20 scale-[1.01]"
|
||||
: isOtherDragged
|
||||
? "opacity-30 z-[10]"
|
||||
: "hover:ring-2 hover:ring-white hover:ring-offset-1 z-[10]",
|
||||
? "opacity-30 z-[10]"
|
||||
: "hover:ring-2 hover:ring-white hover:ring-offset-1 z-[10]",
|
||||
)}
|
||||
style={{ left: left + 2, width: width - 4, top: blockTop, height: blockHeight }}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onAllocationContextMenu(
|
||||
{ allocationId: alloc.id, projectId: alloc.projectId },
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{/* Left resize handle */}
|
||||
{HANDLE_W > 0 && (
|
||||
@@ -603,7 +826,10 @@ function renderAllocBlocksFromData(
|
||||
className="flex-shrink-0 flex items-center justify-center cursor-ew-resize hover:bg-black/20 transition-colors"
|
||||
style={{ width: HANDLE_W }}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
|
||||
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" }); }}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 opacity-60 group-hover/block:opacity-100">
|
||||
<div className="w-px h-2.5 bg-white rounded" />
|
||||
@@ -619,12 +845,19 @@ function renderAllocBlocksFromData(
|
||||
isBeingDragged ? "cursor-grabbing" : "cursor-grab",
|
||||
)}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, allocInfo)}
|
||||
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, allocInfo); }}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, allocInfo);
|
||||
}}
|
||||
>
|
||||
{hasRecurrence && width > 28 && <span className="text-[10px] opacity-80 flex-shrink-0">↻</span>}
|
||||
{hasRecurrence && width > 28 && (
|
||||
<span className="text-[10px] opacity-80 flex-shrink-0">↻</span>
|
||||
)}
|
||||
<span className="text-xs font-semibold truncate">{alloc.project.name}</span>
|
||||
{width > 130 && <span className="text-[10px] opacity-75 truncate">{alloc.role}</span>}
|
||||
{width > 190 && <span className="text-[10px] opacity-60 truncate">{alloc.hoursPerDay}h</span>}
|
||||
{width > 190 && (
|
||||
<span className="text-[10px] opacity-60 truncate">{alloc.hoursPerDay}h</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right resize handle */}
|
||||
@@ -633,7 +866,10 @@ function renderAllocBlocksFromData(
|
||||
className="flex-shrink-0 flex items-center justify-center cursor-ew-resize hover:bg-black/20 transition-colors"
|
||||
style={{ width: HANDLE_W }}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
|
||||
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" }); }}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 opacity-60 group-hover/block:opacity-100">
|
||||
<div className="w-px h-2.5 bg-white rounded" />
|
||||
@@ -654,17 +890,16 @@ function renderLoadGraph(allocs: TimelineAssignmentEntry[], dates: Date[], CELL_
|
||||
|
||||
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);
|
||||
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 (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-1 pointer-events-none"
|
||||
style={{ height: GRAPH_H }}
|
||||
>
|
||||
<div className="absolute inset-x-0 bottom-1 pointer-events-none" style={{ height: GRAPH_H }}>
|
||||
{dates.map((date, i) => {
|
||||
const t = date.getTime();
|
||||
const totalH = hoursOnDay(allocs, t);
|
||||
@@ -677,9 +912,11 @@ function renderLoadGraph(allocs: TimelineAssignmentEntry[], dates: Date[], CELL_
|
||||
key={i}
|
||||
className={clsx(
|
||||
"absolute bottom-0 rounded-t-sm",
|
||||
totalH > 12 ? "bg-red-500 opacity-80"
|
||||
: totalH > 8 ? "bg-amber-400 opacity-80"
|
||||
: "bg-brand-500 opacity-80",
|
||||
totalH > 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 }}
|
||||
/>
|
||||
@@ -691,13 +928,20 @@ function renderLoadGraph(allocs: TimelineAssignmentEntry[], dates: Date[], CELL_
|
||||
|
||||
// ─── Heatmap-mode: utilisation colour overlay ────────────────────────────────
|
||||
|
||||
function renderHeatmapOverlay(allocs: TimelineAssignmentEntry[], dates: Date[], CELL_WIDTH: number, heatmapScheme: HeatmapColorScheme) {
|
||||
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);
|
||||
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);
|
||||
@@ -722,6 +966,11 @@ function renderDailyBars(
|
||||
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,
|
||||
@@ -734,9 +983,16 @@ function renderDailyBars(
|
||||
|
||||
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);
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -747,20 +1003,33 @@ function renderDailyBars(
|
||||
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 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 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 isLastDay = t === dispEnd.getTime();
|
||||
const EDGE_W = CELL_WIDTH >= 16 ? 4 : 0;
|
||||
|
||||
const allocInfo: AllocMouseDownInfo = {
|
||||
@@ -785,27 +1054,53 @@ function renderDailyBars(
|
||||
: "hover:opacity-80 z-[10]",
|
||||
)}
|
||||
style={{ left: i * CELL_WIDTH + 2, width: CELL_WIDTH - 4, height: segH, bottom }}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onAllocationContextMenu(
|
||||
{ allocationId: alloc.id, projectId: alloc.projectId },
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{isFirstDay && EDGE_W > 0 && (
|
||||
<div
|
||||
className="flex-shrink-0 cursor-ew-resize hover:bg-black/20"
|
||||
style={{ width: EDGE_W }}
|
||||
onMouseDown={(e) => { e.stopPropagation(); onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" }); }}
|
||||
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" }); }}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" });
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={clsx("flex-1 min-w-0", "cursor-grab")}
|
||||
onMouseDown={(e) => { e.stopPropagation(); onAllocMouseDown(e, allocInfo); }}
|
||||
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, allocInfo); }}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocMouseDown(e, allocInfo);
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, allocInfo);
|
||||
}}
|
||||
/>
|
||||
{isLastDay && EDGE_W > 0 && (
|
||||
<div
|
||||
className="flex-shrink-0 cursor-ew-resize hover:bg-black/20"
|
||||
style={{ width: EDGE_W }}
|
||||
onMouseDown={(e) => { e.stopPropagation(); onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" }); }}
|
||||
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" }); }}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" });
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user