4a841d5acb
CI / Architecture Guardrails (pull_request) Successful in 17m31s
CI / Assistant Split Regression (pull_request) Successful in 9m42s
CI / Typecheck (pull_request) Successful in 20m48s
CI / Lint (pull_request) Successful in 8m6s
CI / Unit Tests (pull_request) Failing after 7m32s
CI / Build (pull_request) Successful in 9m12s
CI / E2E Tests (pull_request) Successful in 6m12s
CI / Fresh-Linux Docker Deploy (pull_request) Successful in 6m58s
CI / Release Images (pull_request) Has been skipped
Previously viewStart defaulted to today-30 and the scroll container had no left-edge expansion logic, so users hit a hard wall when scrolling left. This change: - Sets viewStart default to today so the viewport opens with today at the left edge (URL ?startDate= override still respected). - Adds left-edge auto-expansion in handleContainerScroll: when the user scrolls within 40 cells of the left boundary, 120 days are prepended and a useLayoutEffect applies the matching scrollLeft compensation in the same paint frame to prevent a visual jump. - Floors backward navigation at 5 years (minDate) to prevent unbounded viewDays growth. - Updates handleNavigateToday to match: resets to today rather than today-30. Both resource view and project view use the same TimelineContext / TimelineView, so both are fixed by this change. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1075 lines
39 KiB
TypeScript
1075 lines
39 KiB
TypeScript
"use client";
|
|
|
|
import { clsx } from "clsx";
|
|
import { useSession } from "next-auth/react";
|
|
import { useCallback, useEffect, useLayoutEffect, 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 { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
|
|
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
|
import type { TimelineDemandEntry } from "./TimelineContext.js";
|
|
import { FloatingActionBar } from "./FloatingActionBar.js";
|
|
import { TimelineDragOverlays } from "./TimelineDragOverlays.js";
|
|
import { TimelineHeader } from "./TimelineHeader.js";
|
|
import { TimelinePopovers } from "./TimelinePopovers.js";
|
|
import { TimelineToolbar } from "./TimelineToolbar.js";
|
|
import { addDays } from "./utils.js";
|
|
import { HEADER_DAY_HEIGHT, HEADER_MONTH_HEIGHT, LABEL_WIDTH } from "./timelineConstants.js";
|
|
import {
|
|
TimelineProvider,
|
|
useTimelineData,
|
|
useTimelineView,
|
|
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";
|
|
import { SuccessToast } from "~/components/ui/SuccessToast.js";
|
|
import { useTimelineKeyboard } from "~/hooks/useTimelineKeyboard.js";
|
|
import { KeyboardShortcutOverlay } from "./KeyboardShortcutOverlay.js";
|
|
import { InlineAllocationEditor } from "./InlineAllocationEditor.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 invalidatePlanningViews = useInvalidatePlanningViews();
|
|
const batchShiftMutationOuter = trpc.timeline.batchShiftAllocations.useMutation({
|
|
onSuccess: () => void invalidatePlanningViews(),
|
|
});
|
|
|
|
const [dragErrorToast, setDragErrorToast] = useState<string | null>(null);
|
|
|
|
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();
|
|
}
|
|
},
|
|
onMutationError: (message) => setDragErrorToast(message),
|
|
});
|
|
|
|
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 (
|
|
<>
|
|
<SuccessToast
|
|
show={dragErrorToast !== null}
|
|
message={dragErrorToast ?? ""}
|
|
variant="warning"
|
|
onDone={() => setDragErrorToast(null)}
|
|
/>
|
|
<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 {
|
|
resources,
|
|
projectGroups,
|
|
allocsByResource,
|
|
openDemandsByProject,
|
|
visibleAssignments,
|
|
visibleDemands,
|
|
isLoading,
|
|
isInitialLoading,
|
|
isEntriesError,
|
|
totalAllocCount,
|
|
} = useTimelineData();
|
|
const {
|
|
viewStart,
|
|
viewEnd,
|
|
viewDays,
|
|
setViewStart,
|
|
setViewDays,
|
|
filters,
|
|
setFilters,
|
|
filterOpen,
|
|
setFilterOpen,
|
|
viewMode,
|
|
setViewMode,
|
|
today,
|
|
} = useTimelineView();
|
|
|
|
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 invalidatePlanningViewsInner = useInvalidatePlanningViews();
|
|
const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({
|
|
onSuccess: () => {
|
|
void invalidatePlanningViewsInner();
|
|
clearMultiSelect();
|
|
},
|
|
});
|
|
|
|
// ─── Batch-delete handler — shared by keyboard shortcut and action bar ─────
|
|
const handleBatchDelete = useCallback(() => {
|
|
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 });
|
|
}
|
|
}, [batchDeleteMutation, multiSelectState.selectedAllocationIds]);
|
|
|
|
const { CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate } =
|
|
useTimelineLayout(viewStart, viewDays, filters.zoom, filters.showWeekends, today);
|
|
|
|
const { showShortcuts, setShowShortcuts } = useTimelineKeyboard({
|
|
scrollContainerRef,
|
|
cellWidth: CELL_WIDTH,
|
|
selectedAllocationIds: multiSelectState.selectedAllocationIds,
|
|
onDeleteSelected: handleBatchDelete,
|
|
});
|
|
|
|
const [inlineEditTarget, setInlineEditTarget] = useState<{
|
|
allocationId: string;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
hoursPerDay: number;
|
|
barRect: DOMRect;
|
|
} | null>(null);
|
|
|
|
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]);
|
|
|
|
// 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]);
|
|
|
|
// ─── 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]);
|
|
|
|
// ─── 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]);
|
|
|
|
// ─── Scroll-left tracking for horizontal virtualization ────────────────────
|
|
// Updated via RAF so React state only updates after a frame, not on every
|
|
// pixel of scroll. The ref gives instant reads inside event handlers.
|
|
const scrollLeftRef = useRef(0);
|
|
const scrollRafRef = useRef<number | null>(null);
|
|
const [scrollLeft, setScrollLeft] = useState(0);
|
|
|
|
// Pixels to add to scrollLeft after a left-extension re-render (prevents jump).
|
|
const pendingLeftCompensationPx = useRef(0);
|
|
|
|
// Apply scroll compensation synchronously after the canvas grows leftward.
|
|
useLayoutEffect(() => {
|
|
const px = pendingLeftCompensationPx.current;
|
|
if (px === 0) return;
|
|
const el = scrollContainerRef.current;
|
|
if (el) el.scrollLeft += px;
|
|
pendingLeftCompensationPx.current = 0;
|
|
}, [viewStart]);
|
|
|
|
// 5-year floor — no practical data exists further back; prevents runaway growth.
|
|
const minDate = useMemo(() => addDays(today, -(365 * 5)), [today]);
|
|
|
|
// ─── Navigation callbacks for TimelineToolbar ────────────────────────────
|
|
const handleNavigateBack = useCallback(
|
|
() =>
|
|
setViewStart((v) => {
|
|
const candidate = addDays(v, -28);
|
|
return candidate < minDate ? minDate : candidate;
|
|
}),
|
|
[setViewStart, minDate],
|
|
);
|
|
const handleNavigateToday = useCallback(() => setViewStart(today), [setViewStart, today]);
|
|
const handleNavigateForward = useCallback(
|
|
() => setViewStart((v) => addDays(v, 28)),
|
|
[setViewStart],
|
|
);
|
|
const handleUndo = useCallback(() => {
|
|
void undo();
|
|
}, [undo]);
|
|
const handleRedo = useCallback(() => {
|
|
void redo();
|
|
}, [redo]);
|
|
|
|
// ─── Scroll handler — extends date range and tracks scroll offset ─────────
|
|
const handleContainerScroll = useCallback(() => {
|
|
const el = scrollContainerRef.current;
|
|
if (!el) return;
|
|
|
|
// Right-edge: extend future range
|
|
const distanceFromRight = el.scrollWidth - el.scrollLeft - el.clientWidth;
|
|
if (distanceFromRight < CELL_WIDTH * 40) {
|
|
setViewDays((d) => d + 120);
|
|
}
|
|
|
|
// Left-edge: prepend past range and compensate scroll position so viewport doesn't jump
|
|
if (el.scrollLeft < CELL_WIDTH * 40 && viewStart > minDate) {
|
|
const daysToPrepend = 120;
|
|
// Count the exact visible days (respecting showWeekends) being prepended
|
|
let prependedVisible = 0;
|
|
for (let i = 1; i <= daysToPrepend; i++) {
|
|
const d = addDays(viewStart, -i);
|
|
const dow = d.getDay();
|
|
if (filters.showWeekends || (dow !== 0 && dow !== 6)) prependedVisible++;
|
|
}
|
|
pendingLeftCompensationPx.current = prependedVisible * CELL_WIDTH;
|
|
setViewStart((v) => {
|
|
const candidate = addDays(v, -daysToPrepend);
|
|
return candidate < minDate ? minDate : candidate;
|
|
});
|
|
setViewDays((d) => d + daysToPrepend);
|
|
}
|
|
|
|
scrollLeftRef.current = el.scrollLeft;
|
|
if (scrollRafRef.current === null) {
|
|
scrollRafRef.current = requestAnimationFrame(() => {
|
|
scrollRafRef.current = null;
|
|
setScrollLeft(scrollLeftRef.current);
|
|
});
|
|
}
|
|
}, [CELL_WIDTH, setViewDays, viewStart, minDate, setViewStart, filters.showWeekends]);
|
|
|
|
// ─── Canvas mousemove — only forwards event when drag overlay is active ───
|
|
const handleMouseMove = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
if (!hasActivePointerOverlay) return;
|
|
onCanvasMouseMove(e);
|
|
},
|
|
[hasActivePointerOverlay, onCanvasMouseMove],
|
|
);
|
|
|
|
// ─── openAllocationPopoverAt — routed to demand or allocation popover ─────
|
|
const openAllocationPopoverAt = useCallback(
|
|
(
|
|
info: { allocationId: string; projectId: string; contextDate?: Date },
|
|
anchorX: number,
|
|
anchorY: number,
|
|
) => {
|
|
if (hasActivePointerOverlay) return;
|
|
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 } : {}),
|
|
});
|
|
},
|
|
[hasActivePointerOverlay, openDemandsByProject, visibleAssignments],
|
|
);
|
|
|
|
// ─── onOpenDemandClick for project panel — guards against overlay-active ──
|
|
const handleOpenDemandClick = useCallback(
|
|
(demand: TimelineDemandEntry, anchorX: number, anchorY: number) => {
|
|
if (hasActivePointerOverlay) return;
|
|
setDemandPopover({ demand, x: anchorX, y: anchorY });
|
|
},
|
|
[hasActivePointerOverlay],
|
|
);
|
|
|
|
// ─── onInlineEdit for resource panel — opens inline allocation editor ─────
|
|
const handleInlineEdit = useCallback(
|
|
(id: string, vals: { startDate: Date; endDate: Date; hoursPerDay: number }, rect: DOMRect) => {
|
|
setInlineEditTarget({ allocationId: id, ...vals, barRect: rect });
|
|
},
|
|
[],
|
|
);
|
|
|
|
// ─── FloatingActionBar callbacks ────────────────────────────────────────────
|
|
const handleShowBatchAssign = useCallback(() => setShowBatchAssign(true), []);
|
|
|
|
// ─── Stable panel event handlers — self-service gets a typed no-op so the
|
|
// memo() on ResourcePanel/ProjectPanel is not defeated by new fn refs.
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const stableNoop = useCallback((..._args: any[]) => undefined, []);
|
|
const panelOnAllocMouseDown = (
|
|
isSelfServiceTimeline ? stableNoop : onAllocMouseDown
|
|
) as typeof onAllocMouseDown;
|
|
const panelOnAllocTouchStart = (
|
|
isSelfServiceTimeline ? stableNoop : onAllocTouchStart
|
|
) as typeof onAllocTouchStart;
|
|
const panelOnRowMouseDown = (
|
|
isSelfServiceTimeline ? stableNoop : onRowMouseDown
|
|
) as typeof onRowMouseDown;
|
|
const panelOnRowTouchStart = (
|
|
isSelfServiceTimeline ? stableNoop : onRowTouchStart
|
|
) as typeof onRowTouchStart;
|
|
const panelOnAllocationContextMenu = (
|
|
isSelfServiceTimeline ? stableNoop : openAllocationPopoverAt
|
|
) as typeof openAllocationPopoverAt;
|
|
const panelOnProjectBarMouseDown = (
|
|
isSelfServiceTimeline ? stableNoop : onProjectBarMouseDown
|
|
) as typeof onProjectBarMouseDown;
|
|
const panelOnProjectBarTouchStart = (
|
|
isSelfServiceTimeline ? stableNoop : onProjectBarTouchStart
|
|
) as typeof onProjectBarTouchStart;
|
|
const panelOnOpenPanel = (
|
|
isSelfServiceTimeline ? stableNoop : setOpenPanelProjectId
|
|
) as typeof setOpenPanelProjectId;
|
|
const panelOnOpenDemandClick = (
|
|
isSelfServiceTimeline ? stableNoop : handleOpenDemandClick
|
|
) as typeof handleOpenDemandClick;
|
|
|
|
// ─── 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={handleNavigateBack}
|
|
onNavigateToday={handleNavigateToday}
|
|
onNavigateForward={handleNavigateForward}
|
|
canUndo={canUndo}
|
|
canRedo={canRedo}
|
|
onUndo={handleUndo}
|
|
onRedo={handleRedo}
|
|
/>
|
|
|
|
{/* Project color legend */}
|
|
<ProjectColorLegend />
|
|
|
|
{/* Scrollable canvas */}
|
|
<div
|
|
ref={scrollContainerRef}
|
|
onScroll={handleContainerScroll}
|
|
className="app-surface relative z-0 flex-1 overflow-auto"
|
|
>
|
|
{isEntriesError ? (
|
|
<div className="flex flex-col items-center justify-center gap-3 py-24 text-sm text-red-600 dark:text-red-400">
|
|
<span>Failed to load timeline data. Please try refreshing the page.</span>
|
|
</div>
|
|
) : 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={panelOnAllocMouseDown}
|
|
onAllocTouchStart={panelOnAllocTouchStart}
|
|
onRowMouseDown={panelOnRowMouseDown}
|
|
onRowTouchStart={panelOnRowTouchStart}
|
|
onAllocationContextMenu={panelOnAllocationContextMenu}
|
|
multiSelectState={multiSelectState}
|
|
optimisticAllocations={optimisticAllocations}
|
|
suppressHoverInteractions={hasActivePointerOverlay}
|
|
{...(!isSelfServiceTimeline ? { onInlineEdit: handleInlineEdit } : {})}
|
|
scrollLeft={scrollLeft}
|
|
containerWidth={scrollContainerRef.current?.clientWidth ?? 1200}
|
|
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={panelOnProjectBarMouseDown}
|
|
onProjectBarTouchStart={panelOnProjectBarTouchStart}
|
|
onAllocMouseDown={panelOnAllocMouseDown}
|
|
onAllocTouchStart={panelOnAllocTouchStart}
|
|
onRowMouseDown={panelOnRowMouseDown}
|
|
onRowTouchStart={panelOnRowTouchStart}
|
|
onOpenPanel={panelOnOpenPanel}
|
|
onOpenDemandClick={panelOnOpenDemandClick}
|
|
onAllocationContextMenu={panelOnAllocationContextMenu}
|
|
optimisticAllocations={optimisticAllocations}
|
|
suppressHoverInteractions={hasActivePointerOverlay}
|
|
CELL_WIDTH={CELL_WIDTH}
|
|
dates={dates}
|
|
totalCanvasWidth={totalCanvasWidth}
|
|
toLeft={toLeft}
|
|
toWidth={toWidth}
|
|
gridLines={gridLines}
|
|
xToDate={xToDate}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<TimelineDragOverlays
|
|
dragState={dragState}
|
|
allocDragState={allocDragState}
|
|
rangeState={rangeState}
|
|
multiSelectState={multiSelectState}
|
|
shiftPreview={shiftPreview}
|
|
isPreviewLoading={isPreviewLoading}
|
|
isApplying={isApplying}
|
|
isAllocSaving={isAllocSaving}
|
|
mousePosRef={mousePosRef}
|
|
dragTooltipRef={dragTooltipRef}
|
|
allocTooltipRef={allocTooltipRef}
|
|
rangeHintRef={rangeHintRef}
|
|
multiDragTooltipRef={multiDragTooltipRef}
|
|
today={today}
|
|
/>
|
|
|
|
<FloatingActionBar
|
|
selectedAllocationCount={multiSelectState.selectedAllocationIds.length}
|
|
selectedResourceCount={multiSelectState.selectedResourceIds.length}
|
|
onDelete={handleBatchDelete}
|
|
onAssign={handleShowBatchAssign}
|
|
onClear={clearMultiSelect}
|
|
isDeleting={batchDeleteMutation.isPending}
|
|
/>
|
|
|
|
<TimelinePopovers
|
|
isSelfServiceTimeline={isSelfServiceTimeline}
|
|
hasActivePointerOverlay={hasActivePointerOverlay}
|
|
popover={popover}
|
|
setPopover={setPopover}
|
|
demandPopover={demandPopover}
|
|
setDemandPopover={setDemandPopover}
|
|
newAllocPopover={newAllocPopover}
|
|
setNewAllocPopover={setNewAllocPopover}
|
|
enrichedSuggestedProjectId={enrichedSuggestedProjectId}
|
|
openPanelProjectId={openPanelProjectId}
|
|
setOpenPanelProjectId={setOpenPanelProjectId}
|
|
openDemandToAssign={openDemandToAssign}
|
|
setOpenDemandToAssign={setOpenDemandToAssign}
|
|
openDemandsByProject={openDemandsByProject}
|
|
scrollContainerRef={scrollContainerRef}
|
|
multiSelectState={multiSelectState}
|
|
clearMultiSelect={clearMultiSelect}
|
|
handleBatchDelete={handleBatchDelete}
|
|
handleShowBatchAssign={handleShowBatchAssign}
|
|
isDeleting={batchDeleteMutation.isPending}
|
|
showBatchAssign={showBatchAssign}
|
|
setShowBatchAssign={setShowBatchAssign}
|
|
resourceHover={resourceHover}
|
|
setResourceHover={setResourceHover}
|
|
inlineEditTarget={inlineEditTarget}
|
|
setInlineEditTarget={setInlineEditTarget}
|
|
showShortcuts={showShortcuts}
|
|
setShowShortcuts={setShowShortcuts}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|