1df208dbcc
Allocation bars that have active optimistic overrides (post-drag, awaiting server confirmation) now pulse subtly via animate-pulse. The pending set is derived from the existing optimisticAllocations map keys, requiring no additional state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
966 lines
31 KiB
TypeScript
966 lines
31 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useDeferredValue, useEffect, useRef, useState, type MutableRefObject } from "react";
|
|
import { trpc } from "~/lib/trpc/client.js";
|
|
import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js";
|
|
import { pixelsToDays } from "~/components/timeline/dragMath.js";
|
|
import {
|
|
clearLivePreview,
|
|
preserveLivePreview,
|
|
scheduleLivePreview,
|
|
type LivePreviewSession,
|
|
} from "./timelineLivePreview.js";
|
|
import {
|
|
finalizeAllocationMultiDrag,
|
|
isAllocationMultiSelected,
|
|
startAllocationMultiDrag,
|
|
updateAllocationMultiDrag,
|
|
} from "./timelineAllocationMultiDrag.js";
|
|
import { beginAllocationMultiDragSession } from "./timelineAllocationMultiDragSession.js";
|
|
import { createAllocationDragState } from "./timelineAllocationDragState.js";
|
|
import { beginAllocationDragSession } from "./timelineAllocationDragSession.js";
|
|
import { finalizeAllocationReleaseEffects } from "./timelineAllocationReleaseEffects.js";
|
|
import { cancelTransientMultiSelectState, cleanupTimelineDragState } from "./timelineDragCleanup.js";
|
|
import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js";
|
|
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
|
|
import { finalizeProjectDrag } from "./timelineProjectDragFinalize.js";
|
|
import { createProjectDragState } from "./timelineProjectDrag.js";
|
|
import { beginProjectDragSession } from "./timelineProjectDragSession.js";
|
|
import {
|
|
forwardCanvasTouchEnd,
|
|
forwardCanvasTouchMove,
|
|
forwardTouchStartAsMouseDown,
|
|
} from "./timelineTouchEvents.js";
|
|
import {
|
|
completeMultiSelectDraft,
|
|
createMultiSelectState,
|
|
updateMultiSelectDraft,
|
|
} from "./timelineMultiSelect.js";
|
|
import { beginCanvasMultiSelectSession } from "./timelineMultiSelectSession.js";
|
|
import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js";
|
|
import { createAllocationPreviewSession, createProjectPreviewSession } from "./timelinePreviewSession.js";
|
|
import { resolveRangeSelectionCancel, resolveRangeSelectionRelease } from "./timelineRangeRelease.js";
|
|
import { createRangeSelectionState, updateRangeSelectionDraft } from "./timelineRangeSelection.js";
|
|
import { type TouchCanvasPointerEvent, type TouchMouseDownEvent } from "./timelineTouchAdapters.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,
|
|
onMutationError,
|
|
}: {
|
|
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;
|
|
onMutationError?: (message: 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 onMutationErrorRef = useRef(onMutationError);
|
|
onMutationErrorRef.current = onMutationError;
|
|
|
|
const utils = trpc.useUtils();
|
|
const invalidateTimeline = useInvalidateTimeline();
|
|
|
|
const setProjectPreviewTargets = useCallback((projectId: string, currentTarget?: EventTarget | null) => {
|
|
clearLivePreview(projectPreviewRef.current);
|
|
projectPreviewRef.current = createProjectPreviewSession({
|
|
projectId,
|
|
currentTarget,
|
|
cellWidth: cellWidthRef.current,
|
|
});
|
|
}, []);
|
|
|
|
const setAllocationPreviewTarget = useCallback((currentTarget?: EventTarget | null, mode: AllocDragMode = "move") => {
|
|
clearLivePreview(allocPreviewRef.current);
|
|
allocPreviewRef.current = createAllocationPreviewSession({
|
|
currentTarget,
|
|
mode,
|
|
cellWidth: cellWidthRef.current,
|
|
});
|
|
}, []);
|
|
|
|
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 result = resolveProjectDragPosition(dragStateRef.current, clientX, cellWidthRef.current);
|
|
if (!result.handled) return false;
|
|
|
|
updateLivePreview(projectPreviewRef, result.pointerDeltaX, result.daysDelta);
|
|
dragStateRef.current = result.nextState;
|
|
if (result.shouldSyncState) {
|
|
setDragState(result.nextState);
|
|
}
|
|
return true;
|
|
},
|
|
[updateLivePreview],
|
|
);
|
|
|
|
const updateAllocationDragPosition = useCallback(
|
|
(clientX: number) => {
|
|
const result = resolveAllocationDragPosition(allocDragRef.current, clientX, cellWidthRef.current);
|
|
if (!result.handled) return false;
|
|
|
|
updateLivePreview(allocPreviewRef, result.pointerDeltaX, result.daysDelta);
|
|
allocDragRef.current = result.nextState;
|
|
if (result.shouldSyncState) {
|
|
setAllocDragState(result.nextState);
|
|
}
|
|
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);
|
|
}, []);
|
|
|
|
const cancelActiveInteractions = useCallback(() => {
|
|
const hasActiveInteraction =
|
|
dragStateRef.current.isDragging ||
|
|
allocDragRef.current.isActive ||
|
|
rangeStateRef.current.isSelecting ||
|
|
multiSelectRef.current.isSelecting ||
|
|
multiSelectRef.current.isMultiDragging;
|
|
if (!hasActiveInteraction) return;
|
|
|
|
const nextMultiSelectState = cancelTransientMultiSelectState(
|
|
multiSelectRef.current,
|
|
INITIAL_MULTI_SELECT,
|
|
);
|
|
|
|
cleanupTimelineDragState({
|
|
projectDragCleanupRef,
|
|
allocDragCleanupRef,
|
|
multiSelectCleanupRef,
|
|
projectPreviewRef,
|
|
allocPreviewRef,
|
|
dragStateRef,
|
|
allocDragRef,
|
|
rangeStateRef,
|
|
multiSelectRef,
|
|
initialDragState: INITIAL_DRAG_STATE,
|
|
initialAllocDragState: INITIAL_ALLOC_DRAG,
|
|
initialRangeState: INITIAL_RANGE_STATE,
|
|
initialMultiSelectState: nextMultiSelectState,
|
|
clearPreview: clearLivePreview,
|
|
});
|
|
|
|
setDragState(INITIAL_DRAG_STATE);
|
|
setAllocDragState(INITIAL_ALLOC_DRAG);
|
|
setRangeState(INITIAL_RANGE_STATE);
|
|
setMultiSelectState(nextMultiSelectState);
|
|
}, []);
|
|
|
|
// Defer daysDelta to avoid firing the preview query every pixel during drag
|
|
const deferredDaysDelta = useDeferredValue(dragState.daysDelta);
|
|
|
|
// 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 &&
|
|
deferredDaysDelta !== 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 finalizeActiveProjectDrag = useCallback(
|
|
(clientX: number, mode: "mutate" | "mutateAsync" = "mutate") =>
|
|
finalizeProjectDrag({
|
|
clientX,
|
|
mode,
|
|
dragRef: dragStateRef,
|
|
previewRef: projectPreviewRef,
|
|
updatePosition: updateProjectDragPosition,
|
|
clearSession: clearProjectDragSession,
|
|
mutate: applyShiftMutation.mutate,
|
|
mutateAsync: applyShiftMutation.mutateAsync,
|
|
}),
|
|
[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: (error) => {
|
|
console.error("[timeline] updateAllocationInline failed:", error);
|
|
const message = (error as { message?: string }).message ?? "Zuweisung konnte nicht verschoben werden.";
|
|
onMutationErrorRef.current?.(message);
|
|
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;
|
|
multiSelectRef.current = INITIAL_MULTI_SELECT;
|
|
setMultiSelectState(INITIAL_MULTI_SELECT);
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const state = createProjectDragState<DragState>({
|
|
projectId: opts.projectId,
|
|
projectName: opts.projectName,
|
|
startDate: opts.startDate,
|
|
endDate: opts.endDate,
|
|
startMouseX: e.clientX,
|
|
});
|
|
setProjectPreviewTargets(opts.projectId, e.currentTarget);
|
|
beginProjectDragSession({
|
|
state,
|
|
cleanupRef: projectDragCleanupRef,
|
|
stateRef: dragStateRef,
|
|
setState: setDragState,
|
|
documentTarget: document,
|
|
attachDrag: attachDocumentMouseDrag,
|
|
updatePosition: updateProjectDragPosition,
|
|
finalize: (clientX) => {
|
|
void finalizeActiveProjectDrag(clientX);
|
|
},
|
|
});
|
|
},
|
|
[finalizeActiveProjectDrag, 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) {
|
|
beginAllocationMultiDragSession({
|
|
startMouseX: e.clientX,
|
|
dragMode: opts.mode,
|
|
documentTarget: document,
|
|
cleanupRef: multiSelectCleanupRef,
|
|
stateRef: multiSelectRef,
|
|
setState: setMultiSelectState,
|
|
startState: startAllocationMultiDrag,
|
|
updateState: updateAllocationMultiDrag,
|
|
finalizeState: finalizeAllocationMultiDrag,
|
|
toDaysDelta: (deltaX) => pixelsToDays(deltaX, cellWidthRef.current),
|
|
onComplete: (daysDelta, finalized) => {
|
|
onMultiDragCompleteRef.current?.(daysDelta, opts.mode, finalized.selectedAllocationIds);
|
|
},
|
|
attachDrag: attachDocumentMouseDrag,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// ── Single allocation drag ────────────────────────────────────────────
|
|
|
|
multiSelectRef.current = INITIAL_MULTI_SELECT;
|
|
setMultiSelectState(INITIAL_MULTI_SELECT);
|
|
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,
|
|
});
|
|
setAllocationPreviewTarget(e.currentTarget, opts.mode);
|
|
beginAllocationDragSession({
|
|
state: initial,
|
|
cleanupRef: allocDragCleanupRef,
|
|
stateRef: allocDragRef,
|
|
setState: setAllocDragState,
|
|
documentTarget: document,
|
|
attachDrag: attachDocumentMouseDrag,
|
|
updatePosition: updateAllocationDragPosition,
|
|
finalize: (clientX) => {
|
|
void finalizeAllocationReleaseEffects({
|
|
clientX,
|
|
allocRef: allocDragRef,
|
|
previewRef: allocPreviewRef,
|
|
updatePosition: updateAllocationDragPosition,
|
|
clickThresholdPx: DRAG_CLICK_THRESHOLD_PX,
|
|
wasShift,
|
|
onShiftClick: onShiftClickAllocRef.current,
|
|
onBlockClick: onBlockClickRef.current,
|
|
pendingSnapshotRef,
|
|
pendingOptimisticAllocationIdRef,
|
|
setOptimisticAllocations,
|
|
extractAllocationFragment: extractAllocFragmentMutation.mutateAsync,
|
|
updateAllocation: updateAllocMutation.mutate,
|
|
clearPendingOptimisticAllocation,
|
|
onError: (msg) => onMutationErrorRef.current?.(msg),
|
|
});
|
|
|
|
allocDragRef.current = INITIAL_ALLOC_DRAG;
|
|
setAllocDragState(INITIAL_ALLOC_DRAG);
|
|
},
|
|
});
|
|
},
|
|
[
|
|
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 finalizeActiveProjectDrag(e.clientX, "mutateAsync");
|
|
} catch {
|
|
// Validation error — revert visually
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Range select
|
|
const release = resolveRangeSelectionRelease(rangeStateRef.current, e.clientX, e.clientY, INITIAL_RANGE_STATE);
|
|
if (release.kind !== "complete") return;
|
|
|
|
onRangeSelected?.(release.selection);
|
|
rangeStateRef.current = release.nextState;
|
|
setRangeState(release.nextState);
|
|
},
|
|
[finalizeActiveProjectDrag, 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.
|
|
const cancellation = resolveRangeSelectionCancel(rangeStateRef.current, INITIAL_RANGE_STATE);
|
|
if (!cancellation.didReset) return;
|
|
|
|
rangeStateRef.current = cancellation.nextState;
|
|
setRangeState(cancellation.nextState);
|
|
}, []);
|
|
|
|
// ── 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;
|
|
},
|
|
) => {
|
|
forwardTouchStartAsMouseDown({
|
|
event: e,
|
|
touchStartRef,
|
|
decided: true,
|
|
onMouseDown: onProjectBarMouseDown,
|
|
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;
|
|
},
|
|
) => {
|
|
forwardTouchStartAsMouseDown({
|
|
event: e,
|
|
touchStartRef,
|
|
decided: true,
|
|
onMouseDown: onAllocMouseDown,
|
|
opts,
|
|
});
|
|
},
|
|
[onAllocMouseDown],
|
|
);
|
|
|
|
const onRowTouchStart = useCallback(
|
|
(
|
|
e: React.TouchEvent,
|
|
opts: {
|
|
resourceId: string;
|
|
startDate: Date;
|
|
suggestedProjectId?: string;
|
|
},
|
|
) => {
|
|
forwardTouchStartAsMouseDown({
|
|
event: e,
|
|
touchStartRef,
|
|
decided: false,
|
|
onMouseDown: onRowMouseDown,
|
|
opts,
|
|
});
|
|
},
|
|
[onRowMouseDown],
|
|
);
|
|
|
|
const onCanvasTouchMove = useCallback(
|
|
(e: React.TouchEvent) => {
|
|
forwardCanvasTouchMove({
|
|
event: e,
|
|
touchStartRef,
|
|
onCanvasMouseMove,
|
|
});
|
|
},
|
|
[onCanvasMouseMove],
|
|
);
|
|
|
|
const onCanvasTouchEnd = useCallback(
|
|
async (e: React.TouchEvent) => {
|
|
await forwardCanvasTouchEnd({ event: e, onCanvasMouseUp });
|
|
},
|
|
[onCanvasMouseUp],
|
|
);
|
|
|
|
useEffect(() => {
|
|
function handleWindowBlur() {
|
|
cancelActiveInteractions();
|
|
}
|
|
|
|
function handleVisibilityChange() {
|
|
if (document.visibilityState === "hidden") {
|
|
cancelActiveInteractions();
|
|
}
|
|
}
|
|
|
|
window.addEventListener("blur", handleWindowBlur);
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
|
|
return () => {
|
|
window.removeEventListener("blur", handleWindowBlur);
|
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
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,
|
|
});
|
|
};
|
|
}, [cancelActiveInteractions]);
|
|
|
|
// ── 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,
|
|
};
|
|
}
|