Files
Nexus/apps/web/src/components/timeline/TimelineView.tsx
T
Hartmut a83edb2f9d 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>
2026-03-15 09:28:59 +01:00

588 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { clsx } from "clsx";
import { useEffect, useRef, useState } from "react";
import { useAllocationHistory } from "~/hooks/useAllocationHistory.js";
import { useProjectDragContext } from "~/hooks/useProjectDragContext.js";
import { useTimelineDrag } from "~/hooks/useTimelineDrag.js";
import { useTimelineLayout } from "~/hooks/useTimelineLayout.js";
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
import { AllocationPopover } from "./AllocationPopover.js";
import { NewAllocationPopover } from "./NewAllocationPopover.js";
import { ProjectPanel } from "./ProjectPanel.js";
import { ShiftPreviewTooltip } from "./ShiftPreviewTooltip.js";
import { TimelineHeader } from "./TimelineHeader.js";
import { TimelineToolbar } from "./TimelineToolbar.js";
import { addDays } from "./utils.js";
import { HEADER_DAY_HEIGHT, HEADER_MONTH_HEIGHT, LABEL_WIDTH } from "./timelineConstants.js";
import { formatDateShort } from "~/lib/format.js";
import {
TimelineProvider,
useTimelineContext,
type TimelineAssignmentEntry,
} from "./TimelineContext.js";
import { TimelineResourcePanel } from "./TimelineResourcePanel.js";
import { TimelineProjectPanel, type OpenDemandAssignment } from "./TimelineProjectPanel.js";
// ─── Entry point ────────────────────────────────────────────────────────────
// Two-layer mount: the outer shell creates drag state + project context,
// then wraps children with TimelineProvider. The inner content consumes context.
export function TimelineView() {
const mousePosRef = useRef({ x: 0, y: 0 });
const { push: pushHistory, undo, redo, canUndo, canRedo } = useAllocationHistory();
const pushHistoryRef = useRef(pushHistory);
pushHistoryRef.current = pushHistory;
const [popover, setPopover] = useState<{
allocationId: string;
projectId: string;
x: number;
y: number;
} | null>(null);
const [newAllocPopover, setNewAllocPopover] = useState<{
resourceId: string;
startDate: Date;
endDate: Date;
suggestedProjectId: string | null;
anchorX: number;
anchorY: number;
} | null>(null);
// cellWidth placeholder — the real value comes from useTimelineLayout inside the content.
// useTimelineDrag only needs cellWidth for pixel→day conversion during drag.
// We start with 40 (day zoom default) and update via a ref.
const cellWidthRef = useRef(40);
const {
dragState,
allocDragState,
rangeState,
shiftPreview,
isPreviewLoading,
isApplying,
isAllocSaving,
onProjectBarMouseDown,
onAllocMouseDown,
onRowMouseDown,
onCanvasMouseMove,
onCanvasMouseUp,
onCanvasMouseLeave,
onProjectBarTouchStart,
onAllocTouchStart,
onRowTouchStart,
onCanvasTouchMove,
onCanvasTouchEnd,
} = useTimelineDrag({
cellWidth: cellWidthRef.current,
onBlockClick: (info) => {
setPopover({
allocationId: info.allocationId,
projectId: info.projectId,
x: mousePosRef.current.x,
y: mousePosRef.current.y,
});
},
onRangeSelected: (info) => {
setNewAllocPopover({
resourceId: info.resourceId,
startDate: info.startDate,
endDate: info.endDate,
suggestedProjectId: info.suggestedProjectId,
anchorX: info.anchorX,
anchorY: info.anchorY,
});
},
onAllocationMoved: (snapshot) => {
pushHistoryRef.current(snapshot);
},
});
const [openPanelProjectId, setOpenPanelProjectId] = useState<string | null>(null);
const dragProjectId = dragState.isDragging ? dragState.projectId : null;
const contextProjectId = dragProjectId ?? openPanelProjectId;
const { contextResourceIds, contextAllocations } = useProjectDragContext(contextProjectId);
return (
<TimelineProvider
isDragging={dragState.isDragging}
contextAllocations={contextAllocations as TimelineAssignmentEntry[]}
>
<TimelineViewContent
mousePosRef={mousePosRef}
cellWidthRef={cellWidthRef}
dragState={dragState}
allocDragState={allocDragState}
rangeState={rangeState}
shiftPreview={shiftPreview}
isPreviewLoading={isPreviewLoading}
isApplying={isApplying}
isAllocSaving={isAllocSaving}
onProjectBarMouseDown={onProjectBarMouseDown}
onAllocMouseDown={onAllocMouseDown}
onRowMouseDown={onRowMouseDown}
onCanvasMouseMove={onCanvasMouseMove}
onCanvasMouseUp={onCanvasMouseUp}
onCanvasMouseLeave={onCanvasMouseLeave}
onProjectBarTouchStart={onProjectBarTouchStart}
onAllocTouchStart={onAllocTouchStart}
onRowTouchStart={onRowTouchStart}
onCanvasTouchMove={onCanvasTouchMove}
onCanvasTouchEnd={onCanvasTouchEnd}
contextResourceIds={contextResourceIds}
popover={popover}
setPopover={setPopover}
newAllocPopover={newAllocPopover}
setNewAllocPopover={setNewAllocPopover}
openPanelProjectId={openPanelProjectId}
setOpenPanelProjectId={setOpenPanelProjectId}
canUndo={canUndo}
canRedo={canRedo}
undo={undo}
redo={redo}
/>
</TimelineProvider>
);
}
// ─── Content (inside TimelineProvider — has context access) ─────────────────
function TimelineViewContent({
mousePosRef,
cellWidthRef,
dragState,
allocDragState,
rangeState,
shiftPreview,
isPreviewLoading,
isApplying,
isAllocSaving,
onProjectBarMouseDown,
onAllocMouseDown,
onRowMouseDown,
onCanvasMouseMove,
onCanvasMouseUp,
onCanvasMouseLeave,
onProjectBarTouchStart,
onAllocTouchStart,
onRowTouchStart,
onCanvasTouchMove,
onCanvasTouchEnd,
contextResourceIds,
popover,
setPopover,
newAllocPopover,
setNewAllocPopover,
openPanelProjectId,
setOpenPanelProjectId,
canUndo,
canRedo,
undo,
redo,
}: {
mousePosRef: React.RefObject<{ x: number; y: number }>;
cellWidthRef: React.RefObject<number>;
dragState: ReturnType<typeof useTimelineDrag>["dragState"];
allocDragState: ReturnType<typeof useTimelineDrag>["allocDragState"];
rangeState: ReturnType<typeof useTimelineDrag>["rangeState"];
shiftPreview: ReturnType<typeof useTimelineDrag>["shiftPreview"];
isPreviewLoading: boolean;
isApplying: boolean;
isAllocSaving: boolean;
onProjectBarMouseDown: ReturnType<typeof useTimelineDrag>["onProjectBarMouseDown"];
onAllocMouseDown: ReturnType<typeof useTimelineDrag>["onAllocMouseDown"];
onRowMouseDown: ReturnType<typeof useTimelineDrag>["onRowMouseDown"];
onCanvasMouseMove: ReturnType<typeof useTimelineDrag>["onCanvasMouseMove"];
onCanvasMouseUp: ReturnType<typeof useTimelineDrag>["onCanvasMouseUp"];
onCanvasMouseLeave: ReturnType<typeof useTimelineDrag>["onCanvasMouseLeave"];
onProjectBarTouchStart: ReturnType<typeof useTimelineDrag>["onProjectBarTouchStart"];
onAllocTouchStart: ReturnType<typeof useTimelineDrag>["onAllocTouchStart"];
onRowTouchStart: ReturnType<typeof useTimelineDrag>["onRowTouchStart"];
onCanvasTouchMove: ReturnType<typeof useTimelineDrag>["onCanvasTouchMove"];
onCanvasTouchEnd: ReturnType<typeof useTimelineDrag>["onCanvasTouchEnd"];
contextResourceIds: string[];
popover: { allocationId: string; projectId: string; x: number; y: number } | null;
setPopover: React.Dispatch<React.SetStateAction<typeof popover>>;
newAllocPopover: {
resourceId: string;
startDate: Date;
endDate: Date;
suggestedProjectId: string | null;
anchorX: number;
anchorY: number;
} | null;
setNewAllocPopover: React.Dispatch<React.SetStateAction<typeof newAllocPopover>>;
openPanelProjectId: string | null;
setOpenPanelProjectId: React.Dispatch<React.SetStateAction<string | null>>;
canUndo: boolean;
canRedo: boolean;
undo: () => Promise<void>;
redo: () => Promise<void>;
}) {
const ctx = useTimelineContext();
const {
resources,
projectGroups,
viewStart,
viewEnd,
viewDays,
setViewStart,
setViewDays,
filters,
setFilters,
filterOpen,
setFilterOpen,
viewMode,
setViewMode,
today,
isLoading,
isInitialLoading,
totalAllocCount,
} = ctx;
const scrollContainerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
// Tooltip DOM refs
const dragTooltipRef = useRef<HTMLDivElement>(null);
const allocTooltipRef = useRef<HTMLDivElement>(null);
const rangeHintRef = useRef<HTMLDivElement>(null);
const [openDemandToAssign, setOpenDemandToAssign] = useState<OpenDemandAssignment | null>(null);
const { CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate } =
useTimelineLayout(viewStart, viewDays, filters.zoom, filters.showWeekends, today);
const hasActivePointerOverlay =
dragState.isDragging || allocDragState.isActive || rangeState.isSelecting;
function openAllocationPopoverAt(
info: {
allocationId: string;
projectId: string;
},
anchorX: number,
anchorY: number,
) {
setPopover({
allocationId: info.allocationId,
projectId: info.projectId,
x: anchorX,
y: anchorY,
});
}
// Keep cellWidthRef in sync so the drag hook uses the correct value.
cellWidthRef.current = CELL_WIDTH;
// ─── Native mousemove listener — updates tooltips without React state ─────
useEffect(() => {
if (!hasActivePointerOverlay) return;
const el = canvasRef.current;
if (!el) return;
const handler = (e: MouseEvent) => {
const x = e.clientX;
const y = e.clientY;
mousePosRef.current = { x, y };
if (dragTooltipRef.current) {
dragTooltipRef.current.style.left = `${x + 12}px`;
dragTooltipRef.current.style.top = `${y - 8}px`;
}
if (allocTooltipRef.current) {
allocTooltipRef.current.style.left = `${x + 14}px`;
allocTooltipRef.current.style.top = `${y - 36}px`;
}
if (rangeHintRef.current) {
rangeHintRef.current.style.left = `${x + 12}px`;
rangeHintRef.current.style.top = `${y - 28}px`;
}
};
el.addEventListener("mousemove", handler, { passive: true });
return () => el.removeEventListener("mousemove", handler);
}, [hasActivePointerOverlay, isLoading, mousePosRef]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Shift+wheel → horizontal scroll ──────────────────────────────────────
useEffect(() => {
const el = scrollContainerRef.current;
if (!el) return;
const handler = (e: WheelEvent) => {
if (e.shiftKey && e.deltaY !== 0 && e.deltaX === 0) {
e.preventDefault();
el.scrollLeft += e.deltaY;
}
};
el.addEventListener("wheel", handler, { passive: false });
return () => el.removeEventListener("wheel", handler);
}, [isLoading]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Keyboard undo/redo ───────────────────────────────────────────────────
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const isMac = navigator.platform.toUpperCase().includes("MAC");
const modKey = isMac ? e.metaKey : e.ctrlKey;
if (!modKey) return;
if (e.key === "z" && !e.shiftKey) {
e.preventDefault();
void undo();
}
if ((e.key === "z" && e.shiftKey) || e.key === "y") {
e.preventDefault();
void redo();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [undo, redo]);
// ─── Lazy-extend date range on scroll ─────────────────────────────────────
function handleContainerScroll() {
const el = scrollContainerRef.current;
if (!el) return;
const distanceFromRight = el.scrollWidth - el.scrollLeft - el.clientWidth;
if (distanceFromRight < CELL_WIDTH * 40) {
setViewDays((d) => d + 120);
}
}
const handleMouseMove = (e: React.MouseEvent) => {
if (!hasActivePointerOverlay) return;
onCanvasMouseMove(e);
};
return (
<div className="relative flex flex-1 flex-col gap-4 min-h-0">
{/* Toolbar */}
<TimelineToolbar
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={filters}
onFiltersChange={setFilters}
filterOpen={filterOpen}
onFilterOpenChange={setFilterOpen}
resourceCount={resources.length}
projectCount={projectGroups.length}
totalAllocCount={totalAllocCount}
onNavigateBack={() => setViewStart((v) => addDays(v, -28))}
onNavigateToday={() => setViewStart(addDays(today, -30))}
onNavigateForward={() => setViewStart((v) => addDays(v, 28))}
canUndo={canUndo}
canRedo={canRedo}
onUndo={() => {
void undo();
}}
onRedo={() => {
void redo();
}}
/>
{/* Scrollable canvas */}
<div
ref={scrollContainerRef}
onScroll={handleContainerScroll}
className="app-surface relative flex-1 overflow-auto"
>
{isInitialLoading ? (
<div className="flex items-center justify-center py-24 text-sm text-gray-500 dark:text-gray-400">
Loading timeline...
</div>
) : (
<div style={{ minWidth: LABEL_WIDTH + totalCanvasWidth }}>
<TimelineHeader
monthGroups={monthGroups}
dates={dates}
CELL_WIDTH={CELL_WIDTH}
LABEL_WIDTH={LABEL_WIDTH}
HEADER_MONTH_HEIGHT={HEADER_MONTH_HEIGHT}
HEADER_DAY_HEIGHT={HEADER_DAY_HEIGHT}
zoom={filters.zoom}
viewMode={viewMode}
today={today}
/>
{/* Canvas rows */}
<div
ref={canvasRef}
onMouseMove={handleMouseMove}
onMouseUp={(e) => void onCanvasMouseUp(e)}
onMouseLeave={onCanvasMouseLeave}
onTouchMove={(e) => {
if (!hasActivePointerOverlay) return;
onCanvasTouchMove(e);
}}
onTouchEnd={(e) => void onCanvasTouchEnd(e)}
className={clsx(
(dragState.isDragging || allocDragState.isActive) && "cursor-grabbing select-none",
rangeState.isSelecting && "cursor-crosshair select-none",
)}
>
{viewMode === "resource" && (
<TimelineResourcePanel
scrollContainerRef={scrollContainerRef}
dragState={dragState}
allocDragState={allocDragState}
rangeState={rangeState}
shiftPreview={shiftPreview}
contextResourceIds={contextResourceIds}
onAllocMouseDown={onAllocMouseDown}
onAllocTouchStart={onAllocTouchStart}
onRowMouseDown={onRowMouseDown}
onRowTouchStart={onRowTouchStart}
onAllocationContextMenu={openAllocationPopoverAt}
CELL_WIDTH={CELL_WIDTH}
dates={dates}
totalCanvasWidth={totalCanvasWidth}
toLeft={toLeft}
toWidth={toWidth}
gridLines={gridLines}
xToDate={xToDate}
/>
)}
{viewMode === "project" && (
<TimelineProjectPanel
scrollContainerRef={scrollContainerRef}
dragState={dragState}
allocDragState={allocDragState}
rangeState={rangeState}
onProjectBarMouseDown={onProjectBarMouseDown}
onProjectBarTouchStart={onProjectBarTouchStart}
onAllocMouseDown={onAllocMouseDown}
onAllocTouchStart={onAllocTouchStart}
onRowMouseDown={onRowMouseDown}
onRowTouchStart={onRowTouchStart}
onOpenPanel={setOpenPanelProjectId}
onOpenDemandClick={setOpenDemandToAssign}
onAllocationContextMenu={openAllocationPopoverAt}
CELL_WIDTH={CELL_WIDTH}
dates={dates}
totalCanvasWidth={totalCanvasWidth}
toLeft={toLeft}
toWidth={toWidth}
gridLines={gridLines}
xToDate={xToDate}
/>
)}
</div>
</div>
)}
</div>
{/* Saving indicators */}
{(isApplying || isAllocSaving) && (
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center rounded-2xl bg-white/50 dark:bg-gray-950/50">
<div className="app-surface px-5 py-3 text-sm font-medium text-gray-700 dark:text-gray-200">
{isApplying ? "Applying shift…" : "Saving…"}
</div>
</div>
)}
{/* Drag preview tooltip */}
{dragState.isDragging && dragState.daysDelta !== 0 && (
<div
ref={dragTooltipRef}
className="fixed z-50 pointer-events-none"
style={{ left: mousePosRef.current.x + 12, top: mousePosRef.current.y - 8 }}
>
<ShiftPreviewTooltip
preview={
shiftPreview ?? {
valid: true,
deltaCents: 0,
wouldExceedBudget: false,
budgetUtilizationAfter: 0,
conflictCount: 0,
errors: [],
warnings: [],
}
}
projectName={dragState.projectName ?? ""}
newStartDate={dragState.currentStartDate ?? today}
newEndDate={dragState.currentEndDate ?? today}
isLoading={isPreviewLoading}
/>
</div>
)}
{/* Alloc drag tooltip */}
{allocDragState.isActive &&
allocDragState.daysDelta !== 0 &&
allocDragState.currentStartDate &&
allocDragState.currentEndDate && (
<div
ref={allocTooltipRef}
className="fixed z-40 bg-gray-800 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg space-y-0.5"
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
>
<div className="font-semibold">{allocDragState.projectName}</div>
<div className="opacity-80">
{formatDateShort(allocDragState.currentStartDate)}
{" "}
{formatDateShort(allocDragState.currentEndDate)}
</div>
</div>
)}
{/* Range-select hint */}
{rangeState.isSelecting && rangeState.startDate && rangeState.currentDate && (
<div
ref={rangeHintRef}
className="fixed z-40 bg-brand-700 text-white text-xs px-2 py-1 rounded-lg pointer-events-none shadow"
style={{ left: mousePosRef.current.x + 12, top: mousePosRef.current.y - 28 }}
>
{(() => {
const end = rangeState.currentDate;
const [s, e] =
rangeState.startDate <= end
? [rangeState.startDate, end]
: [end, rangeState.startDate];
const days = Math.round((e.getTime() - s.getTime()) / 86400000) + 1;
return `${days} day${days !== 1 ? "s" : ""}`;
})()}
</div>
)}
{/* Allocation popover */}
{popover && (
<AllocationPopover
allocationId={popover.allocationId}
projectId={popover.projectId}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
anchorX={popover.x}
anchorY={popover.y}
/>
)}
{/* New allocation popover */}
{newAllocPopover && (
<NewAllocationPopover
resourceId={newAllocPopover.resourceId}
startDate={newAllocPopover.startDate}
endDate={newAllocPopover.endDate}
suggestedProjectId={newAllocPopover.suggestedProjectId}
anchorX={newAllocPopover.anchorX}
anchorY={newAllocPopover.anchorY}
onClose={() => setNewAllocPopover(null)}
onCreated={() => setNewAllocPopover(null)}
/>
)}
{/* Project side panel */}
{openPanelProjectId && (
<ProjectPanel projectId={openPanelProjectId} onClose={() => setOpenPanelProjectId(null)} />
)}
{/* Open-demand assignment modal */}
{openDemandToAssign && (
<FillOpenDemandModal
allocation={openDemandToAssign}
onClose={() => setOpenDemandToAssign(null)}
onSuccess={() => setOpenDemandToAssign(null)}
/>
)}
</div>
);
}