1060 lines
34 KiB
TypeScript
1060 lines
34 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
|
|
import { trpc } from "~/lib/trpc/client.js";
|
|
import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js";
|
|
import { pixelsToDays, computeDragDates } from "~/components/timeline/dragMath.js";
|
|
import {
|
|
captureLivePreviewTargets,
|
|
clearLivePreview,
|
|
preserveLivePreview,
|
|
scheduleLivePreview,
|
|
type LivePreviewSession,
|
|
} from "./timelineLivePreview.js";
|
|
import { buildAllocationMovedSnapshot } from "./timelineAllocationFinalize.js";
|
|
import {
|
|
finalizeAllocationMultiDrag,
|
|
isAllocationMultiSelected,
|
|
startAllocationMultiDrag,
|
|
updateAllocationMultiDrag,
|
|
} from "./timelineAllocationMultiDrag.js";
|
|
import { resolveAllocationRelease } from "./timelineAllocationRelease.js";
|
|
import { createAllocationDragState } from "./timelineAllocationDragState.js";
|
|
import { cleanupTimelineDragState } from "./timelineDragCleanup.js";
|
|
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
|
|
import { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js";
|
|
import {
|
|
completeMultiSelectDraft,
|
|
createMultiSelectState,
|
|
updateMultiSelectDraft,
|
|
} from "./timelineMultiSelect.js";
|
|
import { beginCanvasMultiSelectSession } from "./timelineMultiSelectSession.js";
|
|
import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js";
|
|
import { createRangeSelectionState, finalizeRangeSelection, updateRangeSelectionDraft } from "./timelineRangeSelection.js";
|
|
import {
|
|
createTouchCanvasPointerEvent,
|
|
createTouchMouseDownEvent,
|
|
type TouchCanvasPointerEvent,
|
|
type TouchMouseDownEvent,
|
|
} from "./timelineTouchAdapters.js";
|
|
import {
|
|
getTouchPoint,
|
|
resolveTouchDragDecision,
|
|
} from "./timelineTouch.js";
|
|
|
|
const DRAG_CLICK_THRESHOLD_PX = 5;
|
|
|
|
// ─── Project-shift drag state ───────────────────────────────────────────────
|
|
|
|
export interface DragState {
|
|
isDragging: boolean;
|
|
projectId: string | null;
|
|
projectName: string | null;
|
|
allocationId: string | null;
|
|
originalStartDate: Date | null;
|
|
originalEndDate: Date | null;
|
|
currentStartDate: Date | null;
|
|
currentEndDate: Date | null;
|
|
startMouseX: number;
|
|
pointerDeltaX: number;
|
|
originalLeft: number;
|
|
blockWidth: number;
|
|
daysDelta: number;
|
|
}
|
|
|
|
export interface BlockClickInfo {
|
|
allocationId: string;
|
|
projectId: string;
|
|
projectName: string;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
}
|
|
|
|
export interface ShiftPreviewData {
|
|
valid: boolean;
|
|
deltaCents: number;
|
|
wouldExceedBudget: boolean;
|
|
budgetUtilizationAfter: number;
|
|
conflictCount: number;
|
|
errors: string[];
|
|
warnings: string[];
|
|
}
|
|
|
|
const INITIAL_DRAG_STATE: DragState = {
|
|
isDragging: false,
|
|
projectId: null,
|
|
projectName: null,
|
|
allocationId: null,
|
|
originalStartDate: null,
|
|
originalEndDate: null,
|
|
currentStartDate: null,
|
|
currentEndDate: null,
|
|
startMouseX: 0,
|
|
pointerDeltaX: 0,
|
|
originalLeft: 0,
|
|
blockWidth: 0,
|
|
daysDelta: 0,
|
|
};
|
|
|
|
// ─── Per-allocation drag state ──────────────────────────────────────────────
|
|
|
|
export type AllocDragMode = "move" | "resize-start" | "resize-end";
|
|
export type AllocDragScope = "allocation" | "segment";
|
|
|
|
export interface AllocDragState {
|
|
isActive: boolean;
|
|
mode: AllocDragMode;
|
|
scope: AllocDragScope;
|
|
allocationId: string | null;
|
|
mutationAllocationId: string | null;
|
|
projectId: string | null;
|
|
projectName: string | null;
|
|
resourceId: string | null;
|
|
allocationStartDate: Date | null;
|
|
allocationEndDate: Date | null;
|
|
originalStartDate: Date | null;
|
|
originalEndDate: Date | null;
|
|
currentStartDate: Date | null;
|
|
currentEndDate: Date | null;
|
|
startMouseX: number;
|
|
pointerDeltaX: number;
|
|
daysDelta: number;
|
|
}
|
|
|
|
const INITIAL_ALLOC_DRAG: AllocDragState = {
|
|
isActive: false,
|
|
mode: "move",
|
|
scope: "allocation",
|
|
allocationId: null,
|
|
mutationAllocationId: null,
|
|
projectId: null,
|
|
projectName: null,
|
|
resourceId: null,
|
|
allocationStartDate: null,
|
|
allocationEndDate: null,
|
|
originalStartDate: null,
|
|
originalEndDate: null,
|
|
currentStartDate: null,
|
|
currentEndDate: null,
|
|
startMouseX: 0,
|
|
pointerDeltaX: 0,
|
|
daysDelta: 0,
|
|
};
|
|
|
|
// ─── Range-select state ─────────────────────────────────────────────────────
|
|
|
|
export interface RangeState {
|
|
isSelecting: boolean;
|
|
resourceId: string | null;
|
|
startDate: Date | null;
|
|
currentDate: Date | null;
|
|
suggestedProjectId: string | null;
|
|
startClientX: number;
|
|
}
|
|
|
|
export interface RangeSelectedInfo {
|
|
resourceId: string;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
suggestedProjectId: string | null;
|
|
anchorX: number;
|
|
anchorY: number;
|
|
}
|
|
|
|
const INITIAL_RANGE_STATE: RangeState = {
|
|
isSelecting: false,
|
|
resourceId: null,
|
|
startDate: null,
|
|
currentDate: null,
|
|
suggestedProjectId: null,
|
|
startClientX: 0,
|
|
};
|
|
|
|
// ─── Multi-select state ────────────────────────────────────────────────────
|
|
|
|
export interface MultiSelectState {
|
|
isSelecting: boolean;
|
|
startX: number;
|
|
startY: number;
|
|
currentX: number;
|
|
currentY: number;
|
|
selectedAllocationIds: string[];
|
|
selectedResourceIds: string[];
|
|
dateRange: { start: Date; end: Date } | null;
|
|
/** When multi-dragging, the number of days all selected allocations are shifted */
|
|
multiDragDaysDelta: number;
|
|
/** Whether a multi-drag is currently in progress */
|
|
isMultiDragging: boolean;
|
|
/** The drag mode during multi-drag (move, resize-start, resize-end) */
|
|
multiDragMode: AllocDragMode;
|
|
}
|
|
|
|
const INITIAL_MULTI_SELECT: MultiSelectState = {
|
|
isSelecting: false,
|
|
startX: 0,
|
|
startY: 0,
|
|
currentX: 0,
|
|
currentY: 0,
|
|
selectedAllocationIds: [],
|
|
selectedResourceIds: [],
|
|
dateRange: null,
|
|
multiDragDaysDelta: 0,
|
|
isMultiDragging: false,
|
|
multiDragMode: "move",
|
|
};
|
|
|
|
// ─── Hook ───────────────────────────────────────────────────────────────────
|
|
|
|
export interface AllocationMovedSnapshot {
|
|
allocationId: string;
|
|
mutationAllocationId: string;
|
|
projectName: string;
|
|
before: { startDate: Date; endDate: Date };
|
|
after: { startDate: Date; endDate: Date };
|
|
}
|
|
|
|
export interface OptimisticTimelineEntry {
|
|
id: string;
|
|
startDate: Date | string;
|
|
endDate: Date | string;
|
|
}
|
|
|
|
export interface OptimisticTimelineOverride {
|
|
startDate: Date;
|
|
endDate: Date;
|
|
}
|
|
|
|
export function useTimelineDrag({
|
|
cellWidthRef,
|
|
onShiftApplied,
|
|
onBlockClick,
|
|
onRangeSelected,
|
|
onAllocationMoved,
|
|
onShiftClickAlloc,
|
|
onMultiDragComplete,
|
|
}: {
|
|
cellWidthRef: MutableRefObject<number>;
|
|
onShiftApplied?: (projectId: string) => void;
|
|
onBlockClick?: (info: BlockClickInfo) => void;
|
|
onRangeSelected?: (info: RangeSelectedInfo) => void;
|
|
onAllocationMoved?: (snapshot: AllocationMovedSnapshot) => void;
|
|
onShiftClickAlloc?: (allocationId: string) => void;
|
|
onMultiDragComplete?: (daysDelta: number, mode: AllocDragMode, selectedIds?: string[]) => void;
|
|
}) {
|
|
const [dragState, setDragState] = useState<DragState>(INITIAL_DRAG_STATE);
|
|
const [allocDragState, setAllocDragState] = useState<AllocDragState>(INITIAL_ALLOC_DRAG);
|
|
const [rangeState, setRangeState] = useState<RangeState>(INITIAL_RANGE_STATE);
|
|
const [multiSelectState, setMultiSelectState] = useState<MultiSelectState>(INITIAL_MULTI_SELECT);
|
|
|
|
const dragStateRef = useRef<DragState>(INITIAL_DRAG_STATE);
|
|
const allocDragRef = useRef<AllocDragState>(INITIAL_ALLOC_DRAG);
|
|
const rangeStateRef = useRef<RangeState>(INITIAL_RANGE_STATE);
|
|
const multiSelectRef = useRef<MultiSelectState>(INITIAL_MULTI_SELECT);
|
|
const projectPreviewRef = useRef<LivePreviewSession | null>(null);
|
|
const allocPreviewRef = useRef<LivePreviewSession | null>(null);
|
|
const projectDragCleanupRef = useRef<(() => void) | null>(null);
|
|
const allocDragCleanupRef = useRef<(() => void) | null>(null);
|
|
const multiSelectCleanupRef = useRef<(() => void) | null>(null);
|
|
// Keep ref in sync with state so document-level handlers read the latest selection
|
|
multiSelectRef.current = multiSelectState;
|
|
|
|
// Touch disambiguation: track initial touch position to distinguish horizontal drag from vertical scroll
|
|
const touchStartRef = useRef<{ x: number; y: number; decided: boolean }>({
|
|
x: 0,
|
|
y: 0,
|
|
decided: false,
|
|
});
|
|
|
|
const onBlockClickRef = useRef(onBlockClick);
|
|
onBlockClickRef.current = onBlockClick;
|
|
|
|
const onAllocationMovedRef = useRef(onAllocationMoved);
|
|
onAllocationMovedRef.current = onAllocationMoved;
|
|
|
|
const onShiftClickAllocRef = useRef(onShiftClickAlloc);
|
|
onShiftClickAllocRef.current = onShiftClickAlloc;
|
|
|
|
const onMultiDragCompleteRef = useRef(onMultiDragComplete);
|
|
onMultiDragCompleteRef.current = onMultiDragComplete;
|
|
|
|
const utils = trpc.useUtils();
|
|
const invalidateTimeline = useInvalidateTimeline();
|
|
|
|
const setProjectPreviewTargets = useCallback((projectId: string, currentTarget?: EventTarget | null) => {
|
|
clearLivePreview(projectPreviewRef.current);
|
|
|
|
const projectTargets = captureLivePreviewTargets(
|
|
document.querySelectorAll<HTMLElement>(
|
|
`[data-timeline-drag-preview~="project-shift"][data-timeline-project-id="${projectId}"]`,
|
|
),
|
|
);
|
|
|
|
if (projectTargets.length === 0 && currentTarget instanceof HTMLElement) {
|
|
projectTargets.push(...captureLivePreviewTargets([currentTarget]));
|
|
}
|
|
|
|
projectPreviewRef.current =
|
|
projectTargets.length > 0
|
|
? {
|
|
mode: "move",
|
|
cellWidth: cellWidthRef.current,
|
|
targets: projectTargets,
|
|
pointerDeltaX: 0,
|
|
daysDelta: 0,
|
|
frame: null,
|
|
}
|
|
: null;
|
|
}, []);
|
|
|
|
const setAllocationPreviewTarget = useCallback((currentTarget?: EventTarget | null, mode: AllocDragMode = "move") => {
|
|
clearLivePreview(allocPreviewRef.current);
|
|
|
|
const root =
|
|
currentTarget instanceof HTMLElement
|
|
? currentTarget.closest<HTMLElement>('[data-timeline-drag-preview~="allocation"]')
|
|
: null;
|
|
const targets = root ? captureLivePreviewTargets([root]) : [];
|
|
|
|
allocPreviewRef.current =
|
|
targets.length > 0
|
|
? {
|
|
mode,
|
|
cellWidth: cellWidthRef.current,
|
|
targets,
|
|
pointerDeltaX: 0,
|
|
daysDelta: 0,
|
|
frame: null,
|
|
}
|
|
: null;
|
|
}, []);
|
|
|
|
const updateLivePreview = useCallback(
|
|
(previewRef: MutableRefObject<LivePreviewSession | null>, pointerDeltaX: number, daysDelta: number) => {
|
|
const preview = previewRef.current;
|
|
if (!preview) return;
|
|
preview.cellWidth = cellWidthRef.current;
|
|
preview.pointerDeltaX = pointerDeltaX;
|
|
preview.daysDelta = daysDelta;
|
|
scheduleLivePreview(preview);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const updateProjectDragPosition = useCallback(
|
|
(clientX: number) => {
|
|
const drag = dragStateRef.current;
|
|
if (!drag.isDragging || !drag.originalStartDate || !drag.originalEndDate) return false;
|
|
|
|
const deltaX = clientX - drag.startMouseX;
|
|
const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
|
|
updateLivePreview(projectPreviewRef, deltaX, daysDelta);
|
|
|
|
if (daysDelta === drag.daysDelta) {
|
|
if (deltaX !== drag.pointerDeltaX) {
|
|
dragStateRef.current = { ...drag, pointerDeltaX: deltaX };
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const { start: newStart, end: newEnd } = computeDragDates(
|
|
"move",
|
|
drag.originalStartDate,
|
|
drag.originalEndDate,
|
|
daysDelta,
|
|
);
|
|
const updated: DragState = {
|
|
...drag,
|
|
currentStartDate: newStart,
|
|
currentEndDate: newEnd,
|
|
pointerDeltaX: deltaX,
|
|
daysDelta,
|
|
};
|
|
dragStateRef.current = updated;
|
|
setDragState(updated);
|
|
return true;
|
|
},
|
|
[updateLivePreview],
|
|
);
|
|
|
|
const updateAllocationDragPosition = useCallback(
|
|
(clientX: number) => {
|
|
const alloc = allocDragRef.current;
|
|
if (!alloc.isActive || !alloc.originalStartDate || !alloc.originalEndDate) return false;
|
|
|
|
const pointerDeltaX = clientX - alloc.startMouseX;
|
|
const daysDelta = pixelsToDays(pointerDeltaX, cellWidthRef.current);
|
|
updateLivePreview(allocPreviewRef, pointerDeltaX, daysDelta);
|
|
|
|
if (daysDelta === alloc.daysDelta) {
|
|
if (pointerDeltaX !== alloc.pointerDeltaX) {
|
|
allocDragRef.current = { ...alloc, pointerDeltaX };
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const { start: newStart, end: newEnd } = computeDragDates(
|
|
alloc.mode,
|
|
alloc.originalStartDate,
|
|
alloc.originalEndDate,
|
|
daysDelta,
|
|
);
|
|
|
|
const updated: AllocDragState = {
|
|
...alloc,
|
|
currentStartDate: newStart,
|
|
currentEndDate: newEnd,
|
|
pointerDeltaX,
|
|
daysDelta,
|
|
};
|
|
allocDragRef.current = updated;
|
|
setAllocDragState(updated);
|
|
return true;
|
|
},
|
|
[updateLivePreview],
|
|
);
|
|
|
|
const clearProjectDragSession = useCallback(() => {
|
|
projectDragCleanupRef.current?.();
|
|
projectDragCleanupRef.current = null;
|
|
clearLivePreview(projectPreviewRef.current);
|
|
projectPreviewRef.current = null;
|
|
dragStateRef.current = INITIAL_DRAG_STATE;
|
|
setDragState(INITIAL_DRAG_STATE);
|
|
}, []);
|
|
|
|
// Project-shift preview
|
|
const { data: previewData, isFetching: isPreviewLoading } = trpc.timeline.previewShift.useQuery(
|
|
{
|
|
projectId: dragState.projectId ?? "",
|
|
newStartDate: dragState.currentStartDate ?? new Date(),
|
|
newEndDate: dragState.currentEndDate ?? new Date(),
|
|
},
|
|
{
|
|
enabled:
|
|
dragState.isDragging &&
|
|
dragState.projectId !== null &&
|
|
dragState.daysDelta !== 0 &&
|
|
dragState.currentStartDate !== null,
|
|
staleTime: 0,
|
|
},
|
|
);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const applyShiftMutation = (trpc.timeline.applyShift.useMutation as any)({
|
|
onSuccess: (data: { project: { id: string } }) => {
|
|
invalidateTimeline();
|
|
void utils.project.list.invalidate();
|
|
onShiftApplied?.(data.project.id);
|
|
},
|
|
}) as {
|
|
isPending: boolean;
|
|
mutate: (...args: unknown[]) => void;
|
|
mutateAsync: (...args: unknown[]) => Promise<unknown>;
|
|
};
|
|
|
|
const finalizeProjectDrag = useCallback(
|
|
(clientX: number, mode: "mutate" | "mutateAsync" = "mutate") => {
|
|
updateProjectDragPosition(clientX);
|
|
const finalDrag = dragStateRef.current;
|
|
if (!finalDrag.isDragging) return null;
|
|
|
|
const mutationInput = buildProjectShiftMutationInput(finalDrag);
|
|
|
|
if (finalDrag.daysDelta !== 0) {
|
|
preserveLivePreview(projectPreviewRef.current);
|
|
}
|
|
|
|
clearProjectDragSession();
|
|
|
|
if (!mutationInput) return null;
|
|
if (mode === "mutateAsync") {
|
|
return applyShiftMutation.mutateAsync(mutationInput);
|
|
}
|
|
|
|
applyShiftMutation.mutate(mutationInput);
|
|
return null;
|
|
},
|
|
[applyShiftMutation, clearProjectDragSession, updateProjectDragPosition],
|
|
);
|
|
|
|
const pendingSnapshotRef = useRef<AllocationMovedSnapshot | null>(null);
|
|
const pendingOptimisticAllocationIdRef = useRef<string | null>(null);
|
|
const [optimisticAllocations, setOptimisticAllocations] = useState<Map<string, OptimisticTimelineOverride>>(
|
|
() => new Map(),
|
|
);
|
|
|
|
const updateAllocMutation = trpc.timeline.updateAllocationInline.useMutation({
|
|
onSuccess: () => {
|
|
invalidateTimeline();
|
|
const snap = pendingSnapshotRef.current;
|
|
if (snap) {
|
|
onAllocationMovedRef.current?.(snap);
|
|
pendingSnapshotRef.current = null;
|
|
}
|
|
},
|
|
onError: () => {
|
|
clearPendingOptimisticAllocation();
|
|
},
|
|
});
|
|
|
|
const extractAllocFragmentMutation = trpc.timeline.extractAllocationFragment.useMutation({
|
|
onSuccess: () => {
|
|
invalidateTimeline();
|
|
},
|
|
});
|
|
|
|
const clearPendingOptimisticAllocation = useCallback((allocationId?: string | null) => {
|
|
pendingSnapshotRef.current = null;
|
|
const optimisticAllocationId = allocationId ?? pendingOptimisticAllocationIdRef.current;
|
|
if (!optimisticAllocationId) {
|
|
pendingOptimisticAllocationIdRef.current = null;
|
|
return;
|
|
}
|
|
|
|
setOptimisticAllocations((prev) => {
|
|
if (!prev.has(optimisticAllocationId)) return prev;
|
|
const next = new Map(prev);
|
|
next.delete(optimisticAllocationId);
|
|
return next;
|
|
});
|
|
pendingOptimisticAllocationIdRef.current = null;
|
|
}, []);
|
|
|
|
const reconcileOptimisticAllocations = useCallback((entries: readonly OptimisticTimelineEntry[]) => {
|
|
setOptimisticAllocations((prev) => {
|
|
const result = reconcileOptimisticEntries(prev, entries, pendingOptimisticAllocationIdRef.current);
|
|
pendingOptimisticAllocationIdRef.current = result.pendingOptimisticAllocationId;
|
|
return result.changed ? result.optimisticAllocations : prev;
|
|
});
|
|
}, []);
|
|
|
|
// ── Project-bar drag (shifts all allocations) ──────────────────────────────
|
|
|
|
const onProjectBarMouseDown = useCallback(
|
|
(
|
|
e: TouchMouseDownEvent,
|
|
opts: {
|
|
projectId: string;
|
|
projectName: string;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
},
|
|
) => {
|
|
if (e.button !== 0) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const state = createProjectDragState<DragState>({
|
|
projectId: opts.projectId,
|
|
projectName: opts.projectName,
|
|
startDate: opts.startDate,
|
|
endDate: opts.endDate,
|
|
startMouseX: e.clientX,
|
|
});
|
|
dragStateRef.current = state;
|
|
setDragState(state);
|
|
|
|
setProjectPreviewTargets(opts.projectId, e.currentTarget);
|
|
projectDragCleanupRef.current?.();
|
|
|
|
function handleMove(ev: MouseEvent) {
|
|
updateProjectDragPosition(ev.clientX);
|
|
}
|
|
|
|
function handleUp(ev: MouseEvent) {
|
|
projectDragCleanupRef.current?.();
|
|
projectDragCleanupRef.current = null;
|
|
void finalizeProjectDrag(ev.clientX);
|
|
ev.preventDefault();
|
|
}
|
|
|
|
projectDragCleanupRef.current = attachDocumentMouseDrag(document, handleMove, handleUp);
|
|
},
|
|
[finalizeProjectDrag, setProjectPreviewTargets, updateProjectDragPosition],
|
|
);
|
|
|
|
// Legacy — kept for backward compat (triggers project shift from allocation block)
|
|
const onBlockMouseDown = useCallback(
|
|
(
|
|
e: TouchMouseDownEvent,
|
|
opts: {
|
|
projectId: string;
|
|
projectName: string;
|
|
allocationId?: string;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
blockLeft: number;
|
|
blockWidth: number;
|
|
},
|
|
) => {
|
|
if (e.button !== 0) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const state = createProjectDragState<DragState>({
|
|
projectId: opts.projectId,
|
|
projectName: opts.projectName,
|
|
allocationId: opts.allocationId ?? null,
|
|
startDate: opts.startDate,
|
|
endDate: opts.endDate,
|
|
startMouseX: e.clientX,
|
|
originalLeft: opts.blockLeft,
|
|
blockWidth: opts.blockWidth,
|
|
});
|
|
dragStateRef.current = state;
|
|
setDragState(state);
|
|
},
|
|
[],
|
|
);
|
|
|
|
// ── Per-allocation drag — document-level listeners ─────────────────────────
|
|
//
|
|
// Uses document.addEventListener instead of React canvas events so the drag
|
|
// works reliably even when the cursor leaves the canvas boundary (e.g. while
|
|
// moving quickly or scrolling into the sticky header area).
|
|
|
|
const onAllocMouseDown = useCallback(
|
|
(
|
|
e: TouchMouseDownEvent,
|
|
opts: {
|
|
mode: AllocDragMode;
|
|
allocationId: string;
|
|
mutationAllocationId?: string;
|
|
projectId: string;
|
|
projectName: string;
|
|
resourceId: string | null;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
allocationStartDate?: Date;
|
|
allocationEndDate?: Date;
|
|
scope?: AllocDragScope;
|
|
},
|
|
) => {
|
|
if (e.button !== 0) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const wasShift = e.shiftKey;
|
|
|
|
// Check if this allocation is part of a multi-selection → multi-drag mode
|
|
const ms = multiSelectRef.current;
|
|
const isMultiSelected = isAllocationMultiSelected(ms, opts.allocationId);
|
|
|
|
if (isMultiSelected) {
|
|
// ── Multi-drag: move/resize all selected allocations together ──
|
|
const startMouseX = e.clientX;
|
|
const dragMode = opts.mode;
|
|
const initialMultiDragState = startAllocationMultiDrag(ms, dragMode);
|
|
|
|
setMultiSelectState(initialMultiDragState);
|
|
multiSelectRef.current = initialMultiDragState;
|
|
multiSelectCleanupRef.current?.();
|
|
|
|
function handleMultiMove(ev: MouseEvent) {
|
|
const deltaX = ev.clientX - startMouseX;
|
|
const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
|
|
const updated = updateAllocationMultiDrag(multiSelectRef.current, daysDelta);
|
|
if (!updated) return;
|
|
|
|
setMultiSelectState(updated);
|
|
multiSelectRef.current = updated;
|
|
}
|
|
|
|
function handleMultiUp(ev: MouseEvent) {
|
|
multiSelectCleanupRef.current?.();
|
|
multiSelectCleanupRef.current = null;
|
|
|
|
const finalDelta = pixelsToDays(ev.clientX - startMouseX, cellWidthRef.current);
|
|
const finalized = finalizeAllocationMultiDrag(multiSelectRef.current);
|
|
|
|
setMultiSelectState(finalized);
|
|
multiSelectRef.current = finalized;
|
|
|
|
if (finalDelta !== 0) {
|
|
// Pass IDs from ref to avoid stale closure in the callback
|
|
const ids = finalized.selectedAllocationIds;
|
|
onMultiDragCompleteRef.current?.(finalDelta, dragMode, ids);
|
|
}
|
|
}
|
|
|
|
multiSelectCleanupRef.current = attachDocumentMouseDrag(document, handleMultiMove, handleMultiUp);
|
|
return;
|
|
}
|
|
|
|
// ── Single allocation drag ────────────────────────────────────────────
|
|
|
|
const initial = createAllocationDragState<AllocDragState, AllocDragMode, AllocDragScope>({
|
|
mode: opts.mode,
|
|
scope: opts.scope,
|
|
allocationId: opts.allocationId,
|
|
mutationAllocationId: opts.mutationAllocationId,
|
|
projectId: opts.projectId,
|
|
projectName: opts.projectName,
|
|
resourceId: opts.resourceId,
|
|
allocationStartDate: opts.allocationStartDate,
|
|
allocationEndDate: opts.allocationEndDate,
|
|
startDate: opts.startDate,
|
|
endDate: opts.endDate,
|
|
startMouseX: e.clientX,
|
|
});
|
|
allocDragRef.current = initial;
|
|
setAllocDragState(initial);
|
|
setAllocationPreviewTarget(e.currentTarget, opts.mode);
|
|
allocDragCleanupRef.current?.();
|
|
|
|
// ── document handlers ────────────────────────────────────────────────
|
|
function handleMove(ev: MouseEvent) {
|
|
updateAllocationDragPosition(ev.clientX);
|
|
}
|
|
|
|
function handleUp(ev: MouseEvent) {
|
|
allocDragCleanupRef.current?.();
|
|
allocDragCleanupRef.current = null;
|
|
updateAllocationDragPosition(ev.clientX);
|
|
const alloc = allocDragRef.current;
|
|
const release = resolveAllocationRelease(alloc, {
|
|
clickThresholdPx: DRAG_CLICK_THRESHOLD_PX,
|
|
wasShift,
|
|
});
|
|
if (release.kind === "ignore") return;
|
|
|
|
if (release.preservePreview) {
|
|
preserveLivePreview(allocPreviewRef.current);
|
|
}
|
|
clearLivePreview(allocPreviewRef.current);
|
|
allocPreviewRef.current = null;
|
|
|
|
if (release.kind === "shift-click") {
|
|
onShiftClickAllocRef.current?.(release.allocationId);
|
|
} else if (release.kind === "click") {
|
|
onBlockClickRef.current?.(release.clickInfo);
|
|
} else if (release.kind === "mutation") {
|
|
const { mutationPlan } = release;
|
|
const {
|
|
activeAllocationId,
|
|
currentStartDate,
|
|
currentEndDate,
|
|
baseMutationAllocationId,
|
|
requiresExtraction,
|
|
pendingSnapshot,
|
|
} = mutationPlan;
|
|
|
|
pendingSnapshotRef.current = pendingSnapshot;
|
|
pendingOptimisticAllocationIdRef.current = activeAllocationId;
|
|
setOptimisticAllocations((prev) => {
|
|
const next = new Map(prev);
|
|
next.set(activeAllocationId, {
|
|
startDate: currentStartDate,
|
|
endDate: currentEndDate,
|
|
});
|
|
return next;
|
|
});
|
|
void (async () => {
|
|
try {
|
|
let mutationAllocationId = baseMutationAllocationId;
|
|
|
|
if (requiresExtraction) {
|
|
const extracted = await extractAllocFragmentMutation.mutateAsync({
|
|
allocationId: mutationAllocationId,
|
|
startDate: alloc.originalStartDate!,
|
|
endDate: alloc.originalEndDate!,
|
|
});
|
|
mutationAllocationId = extracted.extractedAllocationId;
|
|
}
|
|
|
|
pendingSnapshotRef.current = pendingSnapshotRef.current
|
|
? {
|
|
...pendingSnapshotRef.current,
|
|
mutationAllocationId,
|
|
}
|
|
: null;
|
|
|
|
updateAllocMutation.mutate({
|
|
allocationId: mutationAllocationId,
|
|
startDate: currentStartDate,
|
|
endDate: currentEndDate,
|
|
});
|
|
} catch {
|
|
clearPendingOptimisticAllocation(activeAllocationId);
|
|
}
|
|
})();
|
|
}
|
|
|
|
allocDragRef.current = INITIAL_ALLOC_DRAG;
|
|
setAllocDragState(INITIAL_ALLOC_DRAG);
|
|
}
|
|
|
|
allocDragCleanupRef.current = attachDocumentMouseDrag(document, handleMove, handleUp);
|
|
},
|
|
[
|
|
clearPendingOptimisticAllocation,
|
|
extractAllocFragmentMutation,
|
|
setAllocationPreviewTarget,
|
|
updateAllocationDragPosition,
|
|
updateAllocMutation,
|
|
],
|
|
);
|
|
|
|
// ── Range-select ────────────────────────────────────────────────────────────
|
|
|
|
const onRowMouseDown = useCallback(
|
|
(
|
|
e: TouchMouseDownEvent,
|
|
opts: {
|
|
resourceId: string;
|
|
startDate: Date;
|
|
suggestedProjectId?: string;
|
|
},
|
|
) => {
|
|
if (dragStateRef.current.isDragging || allocDragRef.current.isActive) return;
|
|
if (e.button !== 0) return;
|
|
e.preventDefault();
|
|
const state = createRangeSelectionState<RangeState>(
|
|
opts.resourceId,
|
|
opts.startDate,
|
|
e.clientX,
|
|
opts.suggestedProjectId,
|
|
);
|
|
rangeStateRef.current = state;
|
|
setRangeState(state);
|
|
},
|
|
[],
|
|
);
|
|
|
|
// ── Canvas-level handlers (project shift + range select only) ──────────────
|
|
|
|
const onCanvasMouseMove = useCallback(
|
|
(e: TouchCanvasPointerEvent) => {
|
|
if (updateProjectDragPosition(e.clientX)) {
|
|
return;
|
|
}
|
|
|
|
// Range select
|
|
const range = rangeStateRef.current;
|
|
const updated = updateRangeSelectionDraft(range, e.clientX, cellWidthRef.current);
|
|
if (updated) {
|
|
rangeStateRef.current = updated;
|
|
setRangeState(updated);
|
|
}
|
|
},
|
|
[updateProjectDragPosition],
|
|
);
|
|
|
|
const onCanvasMouseUp = useCallback(
|
|
async (e: TouchCanvasPointerEvent) => {
|
|
// Project shift
|
|
const drag = dragStateRef.current;
|
|
if (drag.isDragging) {
|
|
try {
|
|
await finalizeProjectDrag(e.clientX, "mutateAsync");
|
|
} catch {
|
|
// Validation error — revert visually
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Range select
|
|
const range = rangeStateRef.current;
|
|
const selection = finalizeRangeSelection(range, e.clientX, e.clientY);
|
|
if (selection) {
|
|
onRangeSelected?.(selection);
|
|
|
|
rangeStateRef.current = INITIAL_RANGE_STATE;
|
|
setRangeState(INITIAL_RANGE_STATE);
|
|
}
|
|
},
|
|
[finalizeProjectDrag, onRangeSelected],
|
|
);
|
|
|
|
const onCanvasMouseLeave = useCallback(() => {
|
|
// Only cancel project-shift and range-select on canvas leave.
|
|
// Alloc drag is managed by document-level listeners and must NOT be cancelled here.
|
|
if (rangeStateRef.current.isSelecting) {
|
|
rangeStateRef.current = INITIAL_RANGE_STATE;
|
|
setRangeState(INITIAL_RANGE_STATE);
|
|
}
|
|
}, []);
|
|
|
|
// ── Multi-select (right-click drag) ─────────────────────────────────────────
|
|
|
|
const onCanvasRightMouseDown = useCallback((e: React.MouseEvent) => {
|
|
if (e.button !== 2) return;
|
|
e.preventDefault();
|
|
|
|
beginCanvasMultiSelectSession({
|
|
clientX: e.clientX,
|
|
clientY: e.clientY,
|
|
documentTarget: document,
|
|
cleanupRef: multiSelectCleanupRef,
|
|
stateRef: multiSelectRef,
|
|
setState: setMultiSelectState,
|
|
createInitialState: (clientX, clientY) =>
|
|
createMultiSelectState<MultiSelectState>(clientX, clientY, {
|
|
selectedAllocationIds: [],
|
|
selectedResourceIds: [],
|
|
dateRange: null,
|
|
multiDragDaysDelta: 0,
|
|
isMultiDragging: false,
|
|
multiDragMode: "move",
|
|
}),
|
|
updateState: updateMultiSelectDraft,
|
|
completeState: completeMultiSelectDraft,
|
|
initialState: INITIAL_MULTI_SELECT,
|
|
attachDrag: attachDocumentMouseDrag,
|
|
});
|
|
}, []);
|
|
|
|
const clearMultiSelect = useCallback(() => {
|
|
multiSelectRef.current = INITIAL_MULTI_SELECT;
|
|
setMultiSelectState(INITIAL_MULTI_SELECT);
|
|
}, []);
|
|
|
|
// ── Touch support ───────────────────────────────────────────────────────────
|
|
|
|
const onProjectBarTouchStart = useCallback(
|
|
(
|
|
e: React.TouchEvent,
|
|
opts: {
|
|
projectId: string;
|
|
projectName: string;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
},
|
|
) => {
|
|
e.preventDefault();
|
|
const point = getTouchPoint(e);
|
|
touchStartRef.current = { x: point.clientX, y: point.clientY, decided: true };
|
|
onProjectBarMouseDown(createTouchMouseDownEvent(point, e.currentTarget), opts);
|
|
},
|
|
[onProjectBarMouseDown],
|
|
);
|
|
|
|
const onAllocTouchStart = useCallback(
|
|
(
|
|
e: React.TouchEvent,
|
|
opts: {
|
|
mode: AllocDragMode;
|
|
allocationId: string;
|
|
mutationAllocationId?: string;
|
|
projectId: string;
|
|
projectName: string;
|
|
resourceId: string | null;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
allocationStartDate?: Date;
|
|
allocationEndDate?: Date;
|
|
scope?: AllocDragScope;
|
|
},
|
|
) => {
|
|
e.preventDefault();
|
|
const point = getTouchPoint(e);
|
|
touchStartRef.current = { x: point.clientX, y: point.clientY, decided: true };
|
|
onAllocMouseDown(createTouchMouseDownEvent(point, e.currentTarget), opts);
|
|
},
|
|
[onAllocMouseDown],
|
|
);
|
|
|
|
const onRowTouchStart = useCallback(
|
|
(
|
|
e: React.TouchEvent,
|
|
opts: {
|
|
resourceId: string;
|
|
startDate: Date;
|
|
suggestedProjectId?: string;
|
|
},
|
|
) => {
|
|
e.preventDefault();
|
|
const point = getTouchPoint(e);
|
|
touchStartRef.current = { x: point.clientX, y: point.clientY, decided: false };
|
|
onRowMouseDown(createTouchMouseDownEvent(point, e.currentTarget), opts);
|
|
},
|
|
[onRowMouseDown],
|
|
);
|
|
|
|
const onCanvasTouchMove = useCallback(
|
|
(e: React.TouchEvent) => {
|
|
const point = getTouchPoint(e);
|
|
|
|
// Scroll vs drag disambiguation: once decided, stick with the decision
|
|
const decision = resolveTouchDragDecision(touchStartRef.current, point);
|
|
touchStartRef.current = decision.nextState;
|
|
if (!decision.shouldHandleDrag) {
|
|
return;
|
|
}
|
|
|
|
onCanvasMouseMove(createTouchCanvasPointerEvent(point));
|
|
},
|
|
[onCanvasMouseMove],
|
|
);
|
|
|
|
const onCanvasTouchEnd = useCallback(
|
|
async (e: React.TouchEvent) => {
|
|
await onCanvasMouseUp(createTouchCanvasPointerEvent(getTouchPoint(e)));
|
|
},
|
|
[onCanvasMouseUp],
|
|
);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
cleanupTimelineDragState({
|
|
projectDragCleanupRef,
|
|
allocDragCleanupRef,
|
|
multiSelectCleanupRef,
|
|
projectPreviewRef,
|
|
allocPreviewRef,
|
|
dragStateRef,
|
|
allocDragRef,
|
|
rangeStateRef,
|
|
multiSelectRef,
|
|
initialDragState: INITIAL_DRAG_STATE,
|
|
initialAllocDragState: INITIAL_ALLOC_DRAG,
|
|
initialRangeState: INITIAL_RANGE_STATE,
|
|
initialMultiSelectState: INITIAL_MULTI_SELECT,
|
|
clearPreview: clearLivePreview,
|
|
});
|
|
};
|
|
}, []);
|
|
|
|
// ── Derived ─────────────────────────────────────────────────────────────────
|
|
|
|
const shiftPreview: ShiftPreviewData | null =
|
|
dragState.isDragging && dragState.daysDelta !== 0 && previewData
|
|
? {
|
|
valid: previewData.valid,
|
|
deltaCents: previewData.costImpact.deltaCents,
|
|
wouldExceedBudget: previewData.costImpact.wouldExceedBudget,
|
|
budgetUtilizationAfter: previewData.costImpact.budgetUtilizationAfter,
|
|
conflictCount: previewData.conflictDetails.length,
|
|
errors: previewData.errors.map((e) => e.message),
|
|
warnings: previewData.warnings.map((w) => w.message),
|
|
}
|
|
: null;
|
|
|
|
return {
|
|
dragState,
|
|
allocDragState,
|
|
rangeState,
|
|
multiSelectState,
|
|
setMultiSelectState,
|
|
optimisticAllocations,
|
|
reconcileOptimisticAllocations,
|
|
shiftPreview,
|
|
isPreviewLoading,
|
|
isApplying: applyShiftMutation.isPending,
|
|
isAllocSaving: updateAllocMutation.isPending,
|
|
onProjectBarMouseDown,
|
|
onBlockMouseDown,
|
|
onAllocMouseDown,
|
|
onRowMouseDown,
|
|
onCanvasMouseMove,
|
|
onCanvasMouseUp,
|
|
onCanvasMouseLeave,
|
|
onCanvasRightMouseDown,
|
|
clearMultiSelect,
|
|
// Touch equivalents
|
|
onProjectBarTouchStart,
|
|
onAllocTouchStart,
|
|
onRowTouchStart,
|
|
onCanvasTouchMove,
|
|
onCanvasTouchEnd,
|
|
};
|
|
}
|