8d9e26872b
B-1: useViewportPopover — ignoreScrollContainers option; scroll events originating inside the timeline canvas no longer close point-anchor popovers B-2: AllocationPopover, DemandPopover, NewAllocationPopover — thread scrollContainerRef through so horizontal timeline scroll is ignored B-3: AllocationPopover — staleTime 0 so SSE reconnect triggers immediate refetch B-4: useViewportPopover.test.ts — 6 new tests (scroll close, ignore container, resize close, style clamping) B-5: AllocationPopover.test.tsx — loading state + happy-path tests added Co-Authored-By: claude-flow <ruv@ruv.net>
1067 lines
40 KiB
TypeScript
1067 lines
40 KiB
TypeScript
"use client";
|
||
|
||
import { clsx } from "clsx";
|
||
import { useSession } from "next-auth/react";
|
||
import { useEffect, useMemo, 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 { trpc } from "~/lib/trpc/client.js";
|
||
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
|
||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
|
||
import { AllocationPopover } from "./AllocationPopover.js";
|
||
import { DemandPopover } from "./DemandPopover.js";
|
||
import { ResourceHoverCard } from "./ResourceHoverCard.js";
|
||
import type { TimelineDemandEntry } from "./TimelineContext.js";
|
||
import { BatchAssignPopover } from "./BatchAssignPopover.js";
|
||
import { FloatingActionBar } from "./FloatingActionBar.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";
|
||
import { ProjectColorLegend } from "./ProjectColorLegend.js";
|
||
import { useMultiSelectIntersection } from "~/hooks/useMultiSelectIntersection.js";
|
||
import type { TimelineVisualOverrides } from "./allocationVisualState.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 { data: session, status: sessionStatus } = useSession();
|
||
const mousePosRef = useRef({ x: 0, y: 0 });
|
||
const role = sessionStatus === "authenticated"
|
||
? ((session.user as { role?: string } | undefined)?.role ?? "USER")
|
||
: null;
|
||
const isSelfServiceTimeline = role === "USER" || role === "VIEWER";
|
||
const canManageTimeline = !isSelfServiceTimeline;
|
||
|
||
const { push: pushHistory, pushBatch: pushBatchHistory, undo, redo, canUndo, canRedo } = useAllocationHistory();
|
||
const pushHistoryRef = useRef(pushHistory);
|
||
pushHistoryRef.current = pushHistory;
|
||
const pushBatchHistoryRef = useRef(pushBatchHistory);
|
||
pushBatchHistoryRef.current = pushBatchHistory;
|
||
|
||
const [popover, setPopover] = useState<{
|
||
allocationId: string;
|
||
projectId: string;
|
||
allocation?: TimelineAssignmentEntry | null;
|
||
x: number;
|
||
y: number;
|
||
contextDate?: Date;
|
||
} | null>(null);
|
||
const [newAllocPopover, setNewAllocPopover] = useState<{
|
||
resourceId: string;
|
||
startDate: Date;
|
||
endDate: Date;
|
||
suggestedProjectId: string | null;
|
||
anchorX: number;
|
||
anchorY: number;
|
||
/** Selection coordinates to keep the overlay visible while popover is open */
|
||
selectionResourceId: string;
|
||
selectionStart: Date;
|
||
selectionEnd: Date;
|
||
} | 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 invalidateTimeline = useInvalidateTimeline();
|
||
const batchShiftMutationOuter = trpc.timeline.batchShiftAllocations.useMutation({
|
||
onSuccess: invalidateTimeline,
|
||
});
|
||
|
||
const {
|
||
dragState,
|
||
allocDragState,
|
||
rangeState,
|
||
multiSelectState,
|
||
setMultiSelectState,
|
||
optimisticAllocations,
|
||
reconcileOptimisticAllocations,
|
||
shiftPreview,
|
||
isPreviewLoading,
|
||
isApplying,
|
||
isAllocSaving,
|
||
onProjectBarMouseDown,
|
||
onAllocMouseDown,
|
||
onRowMouseDown,
|
||
onCanvasMouseMove,
|
||
onCanvasMouseUp,
|
||
onCanvasMouseLeave,
|
||
onCanvasRightMouseDown,
|
||
clearMultiSelect,
|
||
onProjectBarTouchStart,
|
||
onAllocTouchStart,
|
||
onRowTouchStart,
|
||
onCanvasTouchMove,
|
||
onCanvasTouchEnd,
|
||
} = useTimelineDrag({
|
||
cellWidthRef,
|
||
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,
|
||
selectionResourceId: info.resourceId,
|
||
selectionStart: info.startDate,
|
||
selectionEnd: info.endDate,
|
||
});
|
||
},
|
||
onAllocationMoved: (snapshot) => {
|
||
pushHistoryRef.current(snapshot);
|
||
},
|
||
onShiftClickAlloc: (allocationId: string) => {
|
||
setMultiSelectState(prev => {
|
||
const ids = new Set(prev.selectedAllocationIds);
|
||
if (ids.has(allocationId)) {
|
||
ids.delete(allocationId);
|
||
} else {
|
||
ids.add(allocationId);
|
||
}
|
||
return { ...prev, isSelecting: false, selectedAllocationIds: [...ids] };
|
||
});
|
||
},
|
||
onMultiDragComplete: (daysDelta, mode, selectedIds) => {
|
||
const ids = selectedIds ?? multiSelectState.selectedAllocationIds;
|
||
if (ids.length > 0 && daysDelta !== 0) {
|
||
pushBatchHistoryRef.current(ids, daysDelta, mode);
|
||
batchShiftMutationOuter.mutate({ allocationIds: ids, daysDelta, mode });
|
||
clearMultiSelect();
|
||
}
|
||
},
|
||
});
|
||
|
||
const [openPanelProjectId, setOpenPanelProjectId] = useState<string | null>(null);
|
||
const dragProjectId = dragState.isDragging ? dragState.projectId : null;
|
||
const contextProjectId = canManageTimeline ? (dragProjectId ?? openPanelProjectId) : null;
|
||
const { contextResourceIds, contextAllocations } = useProjectDragContext(contextProjectId, canManageTimeline);
|
||
|
||
return (
|
||
<TimelineProvider
|
||
isDragging={dragState.isDragging}
|
||
contextAllocations={contextAllocations as TimelineAssignmentEntry[]}
|
||
>
|
||
<TimelineViewContent
|
||
mousePosRef={mousePosRef}
|
||
cellWidthRef={cellWidthRef}
|
||
dragState={dragState}
|
||
allocDragState={allocDragState}
|
||
rangeState={rangeState}
|
||
multiSelectState={multiSelectState}
|
||
setMultiSelectState={setMultiSelectState}
|
||
optimisticAllocations={optimisticAllocations}
|
||
reconcileOptimisticAllocations={reconcileOptimisticAllocations}
|
||
onCanvasRightMouseDown={onCanvasRightMouseDown}
|
||
clearMultiSelect={clearMultiSelect}
|
||
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}
|
||
isSelfServiceTimeline={isSelfServiceTimeline}
|
||
undo={undo}
|
||
redo={redo}
|
||
/>
|
||
</TimelineProvider>
|
||
);
|
||
}
|
||
|
||
// ─── Content (inside TimelineProvider — has context access) ─────────────────
|
||
|
||
function TimelineViewContent({
|
||
mousePosRef,
|
||
cellWidthRef,
|
||
dragState,
|
||
allocDragState,
|
||
rangeState,
|
||
multiSelectState,
|
||
setMultiSelectState,
|
||
optimisticAllocations,
|
||
reconcileOptimisticAllocations,
|
||
onCanvasRightMouseDown,
|
||
clearMultiSelect,
|
||
shiftPreview,
|
||
isPreviewLoading,
|
||
isApplying,
|
||
isAllocSaving,
|
||
onProjectBarMouseDown,
|
||
onAllocMouseDown,
|
||
onRowMouseDown,
|
||
onCanvasMouseMove,
|
||
onCanvasMouseUp,
|
||
onCanvasMouseLeave,
|
||
onProjectBarTouchStart,
|
||
onAllocTouchStart,
|
||
onRowTouchStart,
|
||
onCanvasTouchMove,
|
||
onCanvasTouchEnd,
|
||
contextResourceIds,
|
||
popover,
|
||
setPopover,
|
||
newAllocPopover,
|
||
setNewAllocPopover,
|
||
openPanelProjectId,
|
||
setOpenPanelProjectId,
|
||
canUndo,
|
||
canRedo,
|
||
isSelfServiceTimeline,
|
||
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"];
|
||
multiSelectState: ReturnType<typeof useTimelineDrag>["multiSelectState"];
|
||
setMultiSelectState: ReturnType<typeof useTimelineDrag>["setMultiSelectState"];
|
||
optimisticAllocations: TimelineVisualOverrides;
|
||
reconcileOptimisticAllocations: ReturnType<typeof useTimelineDrag>["reconcileOptimisticAllocations"];
|
||
onCanvasRightMouseDown: ReturnType<typeof useTimelineDrag>["onCanvasRightMouseDown"];
|
||
clearMultiSelect: ReturnType<typeof useTimelineDrag>["clearMultiSelect"];
|
||
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;
|
||
allocation?: TimelineAssignmentEntry | null;
|
||
x: number;
|
||
y: number;
|
||
contextDate?: Date;
|
||
} | null;
|
||
setPopover: React.Dispatch<React.SetStateAction<typeof popover>>;
|
||
newAllocPopover: {
|
||
resourceId: string;
|
||
startDate: Date;
|
||
endDate: Date;
|
||
suggestedProjectId: string | null;
|
||
anchorX: number;
|
||
anchorY: number;
|
||
selectionResourceId: string;
|
||
selectionStart: Date;
|
||
selectionEnd: Date;
|
||
} | null;
|
||
setNewAllocPopover: React.Dispatch<React.SetStateAction<typeof newAllocPopover>>;
|
||
openPanelProjectId: string | null;
|
||
setOpenPanelProjectId: React.Dispatch<React.SetStateAction<string | null>>;
|
||
canUndo: boolean;
|
||
canRedo: boolean;
|
||
isSelfServiceTimeline: boolean;
|
||
undo: () => Promise<void>;
|
||
redo: () => Promise<void>;
|
||
}) {
|
||
const ctx = useTimelineContext();
|
||
const {
|
||
resources,
|
||
projectGroups,
|
||
allocsByResource,
|
||
openDemandsByProject,
|
||
viewStart,
|
||
viewEnd,
|
||
viewDays,
|
||
visibleAssignments,
|
||
visibleDemands,
|
||
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 multiDragTooltipRef = useRef<HTMLDivElement>(null);
|
||
const [openDemandToAssign, setOpenDemandToAssign] = useState<OpenDemandAssignment | null>(null);
|
||
const [demandPopover, setDemandPopover] = useState<{
|
||
demand: TimelineDemandEntry;
|
||
x: number;
|
||
y: number;
|
||
} | null>(null);
|
||
const [showBatchAssign, setShowBatchAssign] = useState(false);
|
||
const [resourceHover, setResourceHover] = useState<{
|
||
resourceId: string;
|
||
anchorEl: HTMLElement;
|
||
} | null>(null);
|
||
const resourceHoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const previousViewModeRef = useRef(viewMode);
|
||
|
||
const invalidateTimelineInner = useInvalidateTimeline();
|
||
const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({
|
||
onSuccess: () => {
|
||
invalidateTimelineInner();
|
||
clearMultiSelect();
|
||
},
|
||
});
|
||
|
||
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 || multiSelectState.isMultiDragging;
|
||
|
||
useEffect(() => {
|
||
if (optimisticAllocations.size === 0) return;
|
||
reconcileOptimisticAllocations([...visibleAssignments, ...visibleDemands]);
|
||
}, [
|
||
optimisticAllocations,
|
||
reconcileOptimisticAllocations,
|
||
visibleAssignments,
|
||
visibleDemands,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!hasActivePointerOverlay) return;
|
||
setPopover(null);
|
||
setDemandPopover(null);
|
||
setResourceHover(null);
|
||
}, [hasActivePointerOverlay]);
|
||
|
||
useEffect(() => {
|
||
if (previousViewModeRef.current === viewMode) {
|
||
return;
|
||
}
|
||
|
||
previousViewModeRef.current = viewMode;
|
||
setPopover(null);
|
||
setDemandPopover(null);
|
||
setNewAllocPopover(null);
|
||
setResourceHover(null);
|
||
}, [viewMode, setNewAllocPopover]);
|
||
|
||
useEffect(() => {
|
||
if (!isInitialLoading) return;
|
||
setPopover(null);
|
||
setDemandPopover(null);
|
||
setNewAllocPopover(null);
|
||
setResourceHover(null);
|
||
}, [isInitialLoading, setNewAllocPopover]);
|
||
|
||
// ─── Keep selection overlay visible while popover is open ───────────────────
|
||
const effectiveRangeState: typeof rangeState = rangeState.isSelecting
|
||
? rangeState
|
||
: newAllocPopover
|
||
? {
|
||
isSelecting: true,
|
||
resourceId: newAllocPopover.selectionResourceId,
|
||
startDate: newAllocPopover.selectionStart,
|
||
currentDate: newAllocPopover.selectionEnd,
|
||
suggestedProjectId: newAllocPopover.suggestedProjectId,
|
||
startClientX: 0,
|
||
}
|
||
: rangeState;
|
||
|
||
// ─── Auto-suggest project for resource-view range select ───────────────────
|
||
const enrichedSuggestedProjectId = useMemo(() => {
|
||
if (!newAllocPopover) return null;
|
||
// Already has a suggestion (e.g. from project view)
|
||
if (newAllocPopover.suggestedProjectId) return newAllocPopover.suggestedProjectId;
|
||
// Resource view: find the project with the most hours in this resource's row
|
||
const allocs = allocsByResource.get(newAllocPopover.resourceId);
|
||
if (!allocs || allocs.length === 0) return null;
|
||
const projectHours = new Map<string, number>();
|
||
for (const alloc of allocs) {
|
||
projectHours.set(alloc.projectId, (projectHours.get(alloc.projectId) ?? 0) + alloc.hoursPerDay);
|
||
}
|
||
let maxPid: string | null = null;
|
||
let maxH = 0;
|
||
for (const [pid, h] of projectHours) {
|
||
if (h > maxH) { maxH = h; maxPid = pid; }
|
||
}
|
||
return maxPid;
|
||
}, [newAllocPopover, allocsByResource]);
|
||
|
||
function openAllocationPopoverAt(
|
||
info: {
|
||
allocationId: string;
|
||
projectId: string;
|
||
contextDate?: Date;
|
||
},
|
||
anchorX: number,
|
||
anchorY: number,
|
||
) {
|
||
if (hasActivePointerOverlay) return;
|
||
// Check if this is a demand (not an assignment) — route to DemandPopover
|
||
const demands = openDemandsByProject.get(info.projectId);
|
||
const demand = demands?.find((d) => d.id === info.allocationId);
|
||
if (demand) {
|
||
setDemandPopover({ demand, x: anchorX, y: anchorY });
|
||
return;
|
||
}
|
||
const allocation = visibleAssignments.find((entry) => (
|
||
entry.id === info.allocationId
|
||
|| entry.entityId === info.allocationId
|
||
|| entry.sourceAllocationId === info.allocationId
|
||
|| getPlanningEntryMutationId(entry) === info.allocationId
|
||
)) ?? null;
|
||
setPopover({
|
||
allocationId: info.allocationId,
|
||
projectId: info.projectId,
|
||
allocation,
|
||
x: anchorX,
|
||
y: anchorY,
|
||
...(info.contextDate ? { contextDate: info.contextDate } : {}),
|
||
});
|
||
}
|
||
|
||
// 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`;
|
||
}
|
||
if (multiDragTooltipRef.current) {
|
||
multiDragTooltipRef.current.style.left = `${x + 14}px`;
|
||
multiDragTooltipRef.current.style.top = `${y - 36}px`;
|
||
}
|
||
};
|
||
// During multi-drag, listen on document (cursor may leave canvas)
|
||
const target: EventTarget = multiSelectState.isMultiDragging ? document : el;
|
||
target.addEventListener("mousemove", handler as EventListener, { passive: true });
|
||
return () => target.removeEventListener("mousemove", handler as EventListener);
|
||
}, [hasActivePointerOverlay, isLoading, mousePosRef, multiSelectState.isMultiDragging]); // 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]);
|
||
|
||
// ─── ESC to close overlays (topmost first) ─────────────────────────────────
|
||
useEffect(() => {
|
||
const handler = (e: KeyboardEvent) => {
|
||
if (e.key !== "Escape") return;
|
||
if (multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0) {
|
||
e.preventDefault();
|
||
clearMultiSelect();
|
||
return;
|
||
}
|
||
if (demandPopover) {
|
||
e.preventDefault();
|
||
setDemandPopover(null);
|
||
} else if (popover) {
|
||
e.preventDefault();
|
||
setPopover(null);
|
||
} else if (newAllocPopover) {
|
||
e.preventDefault();
|
||
setNewAllocPopover(null);
|
||
} else if (openDemandToAssign) {
|
||
e.preventDefault();
|
||
setOpenDemandToAssign(null);
|
||
} else if (openPanelProjectId) {
|
||
e.preventDefault();
|
||
setOpenPanelProjectId(null);
|
||
}
|
||
};
|
||
window.addEventListener("keydown", handler);
|
||
return () => window.removeEventListener("keydown", handler);
|
||
}, [demandPopover, popover, newAllocPopover, openDemandToAssign, openPanelProjectId, setPopover, setNewAllocPopover, setOpenPanelProjectId, multiSelectState.selectedAllocationIds.length, multiSelectState.selectedResourceIds.length, clearMultiSelect]);
|
||
|
||
// ─── Resource hover card — event delegation on label columns ──────────────
|
||
useEffect(() => {
|
||
if (hasActivePointerOverlay) {
|
||
if (resourceHoverTimerRef.current) {
|
||
clearTimeout(resourceHoverTimerRef.current);
|
||
resourceHoverTimerRef.current = null;
|
||
}
|
||
setResourceHover(null);
|
||
return;
|
||
}
|
||
|
||
const canvas = canvasRef.current;
|
||
if (!canvas) return;
|
||
|
||
const HOVER_DELAY = 400;
|
||
|
||
function onMouseOver(e: MouseEvent) {
|
||
if (hasActivePointerOverlay) return;
|
||
const target = (e.target as HTMLElement).closest<HTMLElement>("[data-resource-hover-id]");
|
||
if (!target) return;
|
||
const rid = target.dataset.resourceHoverId;
|
||
if (!rid) return;
|
||
|
||
// Clear any pending hide
|
||
if (resourceHoverTimerRef.current) {
|
||
clearTimeout(resourceHoverTimerRef.current);
|
||
resourceHoverTimerRef.current = null;
|
||
}
|
||
|
||
// If already showing this resource, skip
|
||
if (resourceHover?.resourceId === rid) return;
|
||
|
||
resourceHoverTimerRef.current = setTimeout(() => {
|
||
resourceHoverTimerRef.current = null;
|
||
setResourceHover({ resourceId: rid, anchorEl: target });
|
||
}, HOVER_DELAY);
|
||
}
|
||
|
||
function onMouseOut(e: MouseEvent) {
|
||
if (hasActivePointerOverlay) return;
|
||
const related = e.relatedTarget as HTMLElement | null;
|
||
// Don't close if moving into another resource-hover target or the hover card itself
|
||
if (related?.closest?.("[data-resource-hover-id]") || related?.closest?.("[data-resource-hover-card]")) return;
|
||
|
||
if (resourceHoverTimerRef.current) {
|
||
clearTimeout(resourceHoverTimerRef.current);
|
||
resourceHoverTimerRef.current = null;
|
||
}
|
||
// Small delay before hiding to allow moving into hover card
|
||
resourceHoverTimerRef.current = setTimeout(() => {
|
||
resourceHoverTimerRef.current = null;
|
||
setResourceHover(null);
|
||
}, 150);
|
||
}
|
||
|
||
canvas.addEventListener("mouseover", onMouseOver, { passive: true });
|
||
canvas.addEventListener("mouseout", onMouseOut, { passive: true });
|
||
return () => {
|
||
canvas.removeEventListener("mouseover", onMouseOver);
|
||
canvas.removeEventListener("mouseout", onMouseOut);
|
||
if (resourceHoverTimerRef.current) {
|
||
clearTimeout(resourceHoverTimerRef.current);
|
||
resourceHoverTimerRef.current = null;
|
||
}
|
||
};
|
||
}, [resourceHover?.resourceId, isInitialLoading, hasActivePointerOverlay]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// ─── 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);
|
||
};
|
||
|
||
// ─── Multi-select intersection computation ────────────────────────────────
|
||
useMultiSelectIntersection({
|
||
multiSelectState,
|
||
setMultiSelectState,
|
||
clearMultiSelect,
|
||
canvasRef,
|
||
viewMode,
|
||
resources,
|
||
allocsByResource,
|
||
projectGroups,
|
||
openDemandsByProject,
|
||
dates,
|
||
today,
|
||
CELL_WIDTH,
|
||
toLeft,
|
||
toWidth,
|
||
});
|
||
|
||
return (
|
||
<div className="relative flex flex-1 flex-col gap-4 min-h-0">
|
||
{/* Toolbar */}
|
||
<TimelineToolbar
|
||
viewMode={viewMode}
|
||
onViewModeChange={setViewMode}
|
||
isViewModeSwitchDisabled={isInitialLoading}
|
||
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();
|
||
}}
|
||
/>
|
||
|
||
{/* Project color legend */}
|
||
<ProjectColorLegend />
|
||
|
||
{/* Scrollable canvas */}
|
||
<div
|
||
ref={scrollContainerRef}
|
||
onScroll={handleContainerScroll}
|
||
className="app-surface relative z-0 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}
|
||
onMouseDown={(e) => {
|
||
if (!isSelfServiceTimeline && e.button === 2) {
|
||
onCanvasRightMouseDown(e);
|
||
}
|
||
}}
|
||
onContextMenu={(e) => e.preventDefault()}
|
||
onTouchMove={(e) => {
|
||
if (!hasActivePointerOverlay) return;
|
||
onCanvasTouchMove(e);
|
||
}}
|
||
onTouchEnd={(e) => void onCanvasTouchEnd(e)}
|
||
className={clsx(
|
||
(dragState.isDragging || allocDragState.isActive || multiSelectState.isMultiDragging) && "cursor-grabbing select-none",
|
||
rangeState.isSelecting && "cursor-crosshair select-none",
|
||
multiSelectState.isSelecting && "cursor-crosshair select-none",
|
||
)}
|
||
>
|
||
{viewMode === "resource" && (
|
||
<TimelineResourcePanel
|
||
scrollContainerRef={scrollContainerRef}
|
||
dragState={dragState}
|
||
allocDragState={allocDragState}
|
||
rangeState={effectiveRangeState}
|
||
shiftPreview={shiftPreview}
|
||
contextResourceIds={contextResourceIds}
|
||
onAllocMouseDown={isSelfServiceTimeline ? () => undefined : onAllocMouseDown}
|
||
onAllocTouchStart={isSelfServiceTimeline ? () => undefined : onAllocTouchStart}
|
||
onRowMouseDown={isSelfServiceTimeline ? () => undefined : onRowMouseDown}
|
||
onRowTouchStart={isSelfServiceTimeline ? () => undefined : onRowTouchStart}
|
||
onAllocationContextMenu={isSelfServiceTimeline ? () => undefined : openAllocationPopoverAt}
|
||
multiSelectState={multiSelectState}
|
||
optimisticAllocations={optimisticAllocations}
|
||
suppressHoverInteractions={hasActivePointerOverlay}
|
||
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={effectiveRangeState}
|
||
multiSelectState={multiSelectState}
|
||
onProjectBarMouseDown={isSelfServiceTimeline ? () => undefined : onProjectBarMouseDown}
|
||
onProjectBarTouchStart={isSelfServiceTimeline ? () => undefined : onProjectBarTouchStart}
|
||
onAllocMouseDown={isSelfServiceTimeline ? () => undefined : onAllocMouseDown}
|
||
onAllocTouchStart={isSelfServiceTimeline ? () => undefined : onAllocTouchStart}
|
||
onRowMouseDown={isSelfServiceTimeline ? () => undefined : onRowMouseDown}
|
||
onRowTouchStart={isSelfServiceTimeline ? () => undefined : onRowTouchStart}
|
||
onOpenPanel={isSelfServiceTimeline ? () => undefined : setOpenPanelProjectId}
|
||
onOpenDemandClick={isSelfServiceTimeline ? () => undefined : (demand, anchorX, anchorY) => {
|
||
if (hasActivePointerOverlay) return;
|
||
setDemandPopover({ demand, x: anchorX, y: anchorY });
|
||
}}
|
||
onAllocationContextMenu={isSelfServiceTimeline ? () => undefined : openAllocationPopoverAt}
|
||
optimisticAllocations={optimisticAllocations}
|
||
suppressHoverInteractions={hasActivePointerOverlay}
|
||
CELL_WIDTH={CELL_WIDTH}
|
||
dates={dates}
|
||
totalCanvasWidth={totalCanvasWidth}
|
||
toLeft={toLeft}
|
||
toWidth={toWidth}
|
||
gridLines={gridLines}
|
||
xToDate={xToDate}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Multi-select rectangle overlay */}
|
||
{multiSelectState.isSelecting && (
|
||
<div
|
||
className="fixed border-2 border-sky-500 bg-sky-500/10 pointer-events-none z-30 rounded"
|
||
style={{
|
||
left: Math.min(multiSelectState.startX, multiSelectState.currentX),
|
||
top: Math.min(multiSelectState.startY, multiSelectState.currentY),
|
||
width: Math.abs(multiSelectState.currentX - multiSelectState.startX),
|
||
height: Math.abs(multiSelectState.currentY - multiSelectState.startY),
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* 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>
|
||
)}
|
||
|
||
{/* Multi-drag tooltip */}
|
||
{multiSelectState.isMultiDragging && multiSelectState.multiDragDaysDelta !== 0 && (
|
||
<div
|
||
ref={multiDragTooltipRef}
|
||
className="fixed z-50 bg-sky-700 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg font-medium"
|
||
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
|
||
>
|
||
{multiSelectState.multiDragMode === "resize-start" ? "Start " : multiSelectState.multiDragMode === "resize-end" ? "End " : ""}
|
||
{multiSelectState.multiDragDaysDelta > 0 ? "+" : ""}
|
||
{multiSelectState.multiDragDaysDelta}d
|
||
{" "}
|
||
({multiSelectState.selectedAllocationIds.length} allocations)
|
||
</div>
|
||
)}
|
||
|
||
{/* Allocation / Demand popover (click path) */}
|
||
{!isSelfServiceTimeline && !hasActivePointerOverlay && popover && (() => {
|
||
// Check if clicked allocation is actually a demand
|
||
const clickedDemand = openDemandsByProject.get(popover.projectId)?.find((d) => d.id === popover.allocationId);
|
||
if (clickedDemand) {
|
||
return (
|
||
<DemandPopover
|
||
demand={clickedDemand}
|
||
onClose={() => setPopover(null)}
|
||
onOpenPanel={(pid) => {
|
||
setPopover(null);
|
||
setOpenPanelProjectId(pid);
|
||
}}
|
||
onFillDemand={(d) => {
|
||
setPopover(null);
|
||
setOpenDemandToAssign({
|
||
id: d.id,
|
||
projectId: d.projectId,
|
||
roleId: d.roleId,
|
||
role: d.role,
|
||
headcount: d.requestedHeadcount,
|
||
startDate: new Date(d.startDate),
|
||
endDate: new Date(d.endDate),
|
||
hoursPerDay: d.hoursPerDay,
|
||
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
|
||
...(d.project !== undefined ? { project: d.project } : {}),
|
||
});
|
||
}}
|
||
anchorX={popover.x}
|
||
anchorY={popover.y}
|
||
ignoreScrollContainers={[scrollContainerRef]}
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<AllocationPopover
|
||
allocationId={popover.allocationId}
|
||
projectId={popover.projectId}
|
||
initialAllocation={popover.allocation ?? null}
|
||
onClose={() => setPopover(null)}
|
||
onOpenPanel={(pid) => {
|
||
setPopover(null);
|
||
setOpenPanelProjectId(pid);
|
||
}}
|
||
anchorX={popover.x}
|
||
anchorY={popover.y}
|
||
ignoreScrollContainers={[scrollContainerRef]}
|
||
{...(popover.contextDate ? { contextDate: popover.contextDate } : {})}
|
||
/>
|
||
);
|
||
})()}
|
||
|
||
{/* Demand popover */}
|
||
{!isSelfServiceTimeline && !hasActivePointerOverlay && demandPopover && (
|
||
<DemandPopover
|
||
demand={demandPopover.demand}
|
||
onClose={() => setDemandPopover(null)}
|
||
onOpenPanel={(pid) => {
|
||
setDemandPopover(null);
|
||
setOpenPanelProjectId(pid);
|
||
}}
|
||
onFillDemand={(d) => {
|
||
setDemandPopover(null);
|
||
setOpenDemandToAssign({
|
||
id: d.id,
|
||
projectId: d.projectId,
|
||
roleId: d.roleId,
|
||
role: d.role,
|
||
headcount: d.requestedHeadcount,
|
||
startDate: new Date(d.startDate),
|
||
endDate: new Date(d.endDate),
|
||
hoursPerDay: d.hoursPerDay,
|
||
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
|
||
...(d.project !== undefined ? { project: d.project } : {}),
|
||
});
|
||
}}
|
||
anchorX={demandPopover.x}
|
||
anchorY={demandPopover.y}
|
||
ignoreScrollContainers={[scrollContainerRef]}
|
||
/>
|
||
)}
|
||
|
||
{/* New allocation popover */}
|
||
{!isSelfServiceTimeline && newAllocPopover && (
|
||
<NewAllocationPopover
|
||
resourceId={newAllocPopover.resourceId}
|
||
startDate={newAllocPopover.startDate}
|
||
endDate={newAllocPopover.endDate}
|
||
suggestedProjectId={enrichedSuggestedProjectId}
|
||
anchorX={newAllocPopover.anchorX}
|
||
anchorY={newAllocPopover.anchorY}
|
||
onClose={() => setNewAllocPopover(null)}
|
||
onCreated={() => setNewAllocPopover(null)}
|
||
ignoreScrollContainers={[scrollContainerRef]}
|
||
/>
|
||
)}
|
||
|
||
{/* Project side panel */}
|
||
{!isSelfServiceTimeline && openPanelProjectId && (
|
||
<ProjectPanel projectId={openPanelProjectId} onClose={() => setOpenPanelProjectId(null)} />
|
||
)}
|
||
|
||
{/* Open-demand assignment modal */}
|
||
{!isSelfServiceTimeline && openDemandToAssign && (
|
||
<FillOpenDemandModal
|
||
allocation={openDemandToAssign}
|
||
onClose={() => setOpenDemandToAssign(null)}
|
||
onSuccess={() => setOpenDemandToAssign(null)}
|
||
/>
|
||
)}
|
||
|
||
{/* Multi-select floating action bar */}
|
||
<FloatingActionBar
|
||
selectedAllocationCount={multiSelectState.selectedAllocationIds.length}
|
||
selectedResourceCount={multiSelectState.selectedResourceIds.length}
|
||
onDelete={() => {
|
||
if (multiSelectState.selectedAllocationIds.length === 0) return;
|
||
const msg = `Delete ${multiSelectState.selectedAllocationIds.length} allocation(s)? This cannot be undone.`;
|
||
if (window.confirm(msg)) {
|
||
batchDeleteMutation.mutate({ ids: multiSelectState.selectedAllocationIds });
|
||
}
|
||
}}
|
||
onAssign={() => setShowBatchAssign(true)}
|
||
onClear={clearMultiSelect}
|
||
isDeleting={batchDeleteMutation.isPending}
|
||
/>
|
||
|
||
{/* Batch assign popover */}
|
||
{showBatchAssign && multiSelectState.dateRange && (
|
||
<BatchAssignPopover
|
||
resourceIds={multiSelectState.selectedResourceIds}
|
||
startDate={multiSelectState.dateRange.start}
|
||
endDate={multiSelectState.dateRange.end}
|
||
onClose={() => setShowBatchAssign(false)}
|
||
onCreated={() => {
|
||
setShowBatchAssign(false);
|
||
clearMultiSelect();
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Resource hover card */}
|
||
{!hasActivePointerOverlay && resourceHover && (
|
||
<ResourceHoverCard
|
||
resourceId={resourceHover.resourceId}
|
||
anchorEl={resourceHover.anchorEl}
|
||
onClose={() => setResourceHover(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|