c02f453679
- ResourceHoverCard: add isInitialLoading to useEffect deps so mouseover/mouseout listeners attach after canvas mounts - PreferencesModal: lift prefsOpen state to AppShell, render modal outside sidebar's backdrop-blur stacking context - Timeline page: constrain to max-h-[100dvh] overflow-hidden so horizontal scrollbar is accessible without scrolling to bottom - Multi-drag: pass selectedAllocationIds from ref at drag completion to prevent stale closure in onMultiDragComplete callback Co-Authored-By: claude-flow <ruv@ruv.net>
876 lines
28 KiB
TypeScript
876 lines
28 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useRef, useState } from "react";
|
|
import { trpc } from "~/lib/trpc/client.js";
|
|
import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js";
|
|
import { pixelsToDays, computeDragDates } from "~/components/timeline/dragMath.js";
|
|
|
|
// ─── 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;
|
|
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,
|
|
originalLeft: 0,
|
|
blockWidth: 0,
|
|
daysDelta: 0,
|
|
};
|
|
|
|
// ─── Per-allocation drag state ──────────────────────────────────────────────
|
|
|
|
export type AllocDragMode = "move" | "resize-start" | "resize-end";
|
|
|
|
export interface AllocDragState {
|
|
isActive: boolean;
|
|
mode: AllocDragMode;
|
|
allocationId: string | null;
|
|
mutationAllocationId: string | null;
|
|
projectId: string | null;
|
|
projectName: string | null;
|
|
resourceId: string | null;
|
|
originalStartDate: Date | null;
|
|
originalEndDate: Date | null;
|
|
currentStartDate: Date | null;
|
|
currentEndDate: Date | null;
|
|
startMouseX: number;
|
|
daysDelta: number;
|
|
}
|
|
|
|
const INITIAL_ALLOC_DRAG: AllocDragState = {
|
|
isActive: false,
|
|
mode: "move",
|
|
allocationId: null,
|
|
mutationAllocationId: null,
|
|
projectId: null,
|
|
projectName: null,
|
|
resourceId: null,
|
|
originalStartDate: null,
|
|
originalEndDate: null,
|
|
currentStartDate: null,
|
|
currentEndDate: null,
|
|
startMouseX: 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 function useTimelineDrag({
|
|
cellWidth,
|
|
onShiftApplied,
|
|
onBlockClick,
|
|
onRangeSelected,
|
|
onAllocationMoved,
|
|
onShiftClickAlloc,
|
|
onMultiDragComplete,
|
|
}: {
|
|
cellWidth: 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);
|
|
// Keep ref in sync with state so document-level handlers read the latest selection
|
|
multiSelectRef.current = multiSelectState;
|
|
|
|
// Keep always-current refs for values used inside document event handlers
|
|
const cellWidthRef = useRef(cellWidth);
|
|
cellWidthRef.current = cellWidth;
|
|
|
|
// 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();
|
|
|
|
// 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 pendingSnapshotRef = useRef<AllocationMovedSnapshot | null>(null);
|
|
|
|
const updateAllocMutation = trpc.timeline.updateAllocationInline.useMutation({
|
|
onSuccess: () => {
|
|
invalidateTimeline();
|
|
const snap = pendingSnapshotRef.current;
|
|
if (snap) {
|
|
onAllocationMovedRef.current?.(snap);
|
|
pendingSnapshotRef.current = null;
|
|
}
|
|
},
|
|
});
|
|
|
|
// ── Project-bar drag (shifts all allocations) ──────────────────────────────
|
|
|
|
const onProjectBarMouseDown = useCallback(
|
|
(
|
|
e: React.MouseEvent,
|
|
opts: {
|
|
projectId: string;
|
|
projectName: string;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
},
|
|
) => {
|
|
if (e.button !== 0) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const state: DragState = {
|
|
isDragging: true,
|
|
projectId: opts.projectId,
|
|
projectName: opts.projectName,
|
|
allocationId: null,
|
|
originalStartDate: opts.startDate,
|
|
originalEndDate: opts.endDate,
|
|
currentStartDate: opts.startDate,
|
|
currentEndDate: opts.endDate,
|
|
startMouseX: e.clientX,
|
|
originalLeft: 0,
|
|
blockWidth: 0,
|
|
daysDelta: 0,
|
|
};
|
|
dragStateRef.current = state;
|
|
setDragState(state);
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Legacy — kept for backward compat (triggers project shift from allocation block)
|
|
const onBlockMouseDown = useCallback(
|
|
(
|
|
e: React.MouseEvent,
|
|
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: DragState = {
|
|
isDragging: true,
|
|
projectId: opts.projectId,
|
|
projectName: opts.projectName,
|
|
allocationId: opts.allocationId ?? null,
|
|
originalStartDate: opts.startDate,
|
|
originalEndDate: opts.endDate,
|
|
currentStartDate: opts.startDate,
|
|
currentEndDate: opts.endDate,
|
|
startMouseX: e.clientX,
|
|
originalLeft: opts.blockLeft,
|
|
blockWidth: opts.blockWidth,
|
|
daysDelta: 0,
|
|
};
|
|
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: React.MouseEvent,
|
|
opts: {
|
|
mode: AllocDragMode;
|
|
allocationId: string;
|
|
mutationAllocationId?: string;
|
|
projectId: string;
|
|
projectName: string;
|
|
resourceId: string | null;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
},
|
|
) => {
|
|
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 =
|
|
ms.selectedAllocationIds.length > 1 &&
|
|
ms.selectedAllocationIds.includes(opts.allocationId);
|
|
|
|
if (isMultiSelected) {
|
|
// ── Multi-drag: move/resize all selected allocations together ──
|
|
const startMouseX = e.clientX;
|
|
let currentDaysDelta = 0;
|
|
const dragMode = opts.mode;
|
|
|
|
setMultiSelectState((prev) => ({ ...prev, isMultiDragging: true, multiDragDaysDelta: 0, multiDragMode: dragMode }));
|
|
multiSelectRef.current = { ...ms, isMultiDragging: true, multiDragDaysDelta: 0, multiDragMode: dragMode };
|
|
|
|
function handleMultiMove(ev: MouseEvent) {
|
|
const deltaX = ev.clientX - startMouseX;
|
|
const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
|
|
if (daysDelta === currentDaysDelta) return;
|
|
currentDaysDelta = daysDelta;
|
|
|
|
setMultiSelectState((prev) => ({ ...prev, multiDragDaysDelta: daysDelta }));
|
|
multiSelectRef.current = { ...multiSelectRef.current, multiDragDaysDelta: daysDelta };
|
|
}
|
|
|
|
function handleMultiUp() {
|
|
document.removeEventListener("mousemove", handleMultiMove);
|
|
document.removeEventListener("mouseup", handleMultiUp);
|
|
|
|
const finalDelta = currentDaysDelta;
|
|
|
|
setMultiSelectState((prev) => ({ ...prev, isMultiDragging: false, multiDragDaysDelta: 0 }));
|
|
multiSelectRef.current = { ...multiSelectRef.current, isMultiDragging: false, multiDragDaysDelta: 0 };
|
|
|
|
if (finalDelta !== 0) {
|
|
// Pass IDs from ref to avoid stale closure in the callback
|
|
const ids = multiSelectRef.current.selectedAllocationIds;
|
|
onMultiDragCompleteRef.current?.(finalDelta, dragMode, ids);
|
|
}
|
|
}
|
|
|
|
document.addEventListener("mousemove", handleMultiMove);
|
|
document.addEventListener("mouseup", handleMultiUp);
|
|
return;
|
|
}
|
|
|
|
// ── Single allocation drag ────────────────────────────────────────────
|
|
|
|
const initial: AllocDragState = {
|
|
isActive: true,
|
|
mode: opts.mode,
|
|
allocationId: opts.allocationId,
|
|
mutationAllocationId: opts.mutationAllocationId ?? opts.allocationId,
|
|
projectId: opts.projectId,
|
|
projectName: opts.projectName,
|
|
resourceId: opts.resourceId,
|
|
originalStartDate: opts.startDate,
|
|
originalEndDate: opts.endDate,
|
|
currentStartDate: opts.startDate,
|
|
currentEndDate: opts.endDate,
|
|
startMouseX: e.clientX,
|
|
daysDelta: 0,
|
|
};
|
|
allocDragRef.current = initial;
|
|
setAllocDragState(initial);
|
|
|
|
// ── document handlers ────────────────────────────────────────────────
|
|
|
|
function handleMove(ev: MouseEvent) {
|
|
const alloc = allocDragRef.current;
|
|
if (!alloc.isActive || !alloc.originalStartDate || !alloc.originalEndDate) return;
|
|
|
|
const deltaX = ev.clientX - alloc.startMouseX;
|
|
const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
|
|
if (daysDelta === alloc.daysDelta) return;
|
|
|
|
const { start: newStart, end: newEnd } = computeDragDates(
|
|
alloc.mode,
|
|
alloc.originalStartDate,
|
|
alloc.originalEndDate,
|
|
daysDelta,
|
|
);
|
|
|
|
const updated: AllocDragState = {
|
|
...alloc,
|
|
currentStartDate: newStart,
|
|
currentEndDate: newEnd,
|
|
daysDelta,
|
|
};
|
|
allocDragRef.current = updated;
|
|
setAllocDragState(updated);
|
|
}
|
|
|
|
function handleUp() {
|
|
document.removeEventListener("mousemove", handleMove);
|
|
document.removeEventListener("mouseup", handleUp);
|
|
|
|
const alloc = allocDragRef.current;
|
|
if (!alloc.isActive) return;
|
|
|
|
if (alloc.daysDelta === 0 && alloc.allocationId) {
|
|
// No movement → treat as click
|
|
if (wasShift) {
|
|
// Shift+Click → toggle multi-selection for this allocation
|
|
onShiftClickAllocRef.current?.(alloc.allocationId);
|
|
} else {
|
|
// Normal click → open alloc popover
|
|
onBlockClickRef.current?.({
|
|
allocationId: alloc.allocationId,
|
|
projectId: alloc.projectId ?? "",
|
|
projectName: alloc.projectName ?? "",
|
|
startDate: alloc.originalStartDate!,
|
|
endDate: alloc.originalEndDate!,
|
|
});
|
|
}
|
|
} else if (alloc.allocationId && alloc.currentStartDate && alloc.currentEndDate) {
|
|
pendingSnapshotRef.current = {
|
|
allocationId: alloc.allocationId,
|
|
mutationAllocationId: alloc.mutationAllocationId ?? alloc.allocationId,
|
|
projectName: alloc.projectName ?? "",
|
|
before: { startDate: alloc.originalStartDate!, endDate: alloc.originalEndDate! },
|
|
after: { startDate: alloc.currentStartDate, endDate: alloc.currentEndDate },
|
|
};
|
|
updateAllocMutation.mutate({
|
|
allocationId: alloc.mutationAllocationId ?? alloc.allocationId,
|
|
startDate: alloc.currentStartDate,
|
|
endDate: alloc.currentEndDate,
|
|
});
|
|
}
|
|
|
|
allocDragRef.current = INITIAL_ALLOC_DRAG;
|
|
setAllocDragState(INITIAL_ALLOC_DRAG);
|
|
}
|
|
|
|
document.addEventListener("mousemove", handleMove);
|
|
document.addEventListener("mouseup", handleUp);
|
|
},
|
|
[updateAllocMutation.mutate], // mutate is stable across renders (React Query guarantee)
|
|
);
|
|
|
|
// ── Range-select ────────────────────────────────────────────────────────────
|
|
|
|
const onRowMouseDown = useCallback(
|
|
(
|
|
e: React.MouseEvent,
|
|
opts: {
|
|
resourceId: string;
|
|
startDate: Date;
|
|
suggestedProjectId?: string;
|
|
},
|
|
) => {
|
|
if (dragStateRef.current.isDragging || allocDragRef.current.isActive) return;
|
|
if (e.button !== 0) return;
|
|
e.preventDefault();
|
|
const state: RangeState = {
|
|
isSelecting: true,
|
|
resourceId: opts.resourceId,
|
|
startDate: opts.startDate,
|
|
currentDate: opts.startDate,
|
|
suggestedProjectId: opts.suggestedProjectId ?? null,
|
|
startClientX: e.clientX,
|
|
};
|
|
rangeStateRef.current = state;
|
|
setRangeState(state);
|
|
},
|
|
[],
|
|
);
|
|
|
|
// ── Canvas-level handlers (project shift + range select only) ──────────────
|
|
|
|
const onCanvasMouseMove = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
// Project shift
|
|
const drag = dragStateRef.current;
|
|
if (drag.isDragging && drag.originalStartDate && drag.originalEndDate) {
|
|
const deltaX = e.clientX - drag.startMouseX;
|
|
const daysDelta = pixelsToDays(deltaX, cellWidth);
|
|
if (daysDelta !== drag.daysDelta) {
|
|
const { start: newStart, end: newEnd } = computeDragDates(
|
|
"move",
|
|
drag.originalStartDate,
|
|
drag.originalEndDate,
|
|
daysDelta,
|
|
);
|
|
const updated: DragState = {
|
|
...drag,
|
|
currentStartDate: newStart,
|
|
currentEndDate: newEnd,
|
|
daysDelta,
|
|
};
|
|
dragStateRef.current = updated;
|
|
setDragState(updated);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Range select
|
|
const range = rangeStateRef.current;
|
|
if (range.isSelecting && range.startDate) {
|
|
const deltaX = e.clientX - range.startClientX;
|
|
const daysDelta = pixelsToDays(deltaX, cellWidth);
|
|
const currentDate = new Date(range.startDate);
|
|
currentDate.setDate(currentDate.getDate() + daysDelta);
|
|
|
|
const prevDelta = range.currentDate
|
|
? Math.round((range.currentDate.getTime() - range.startDate.getTime()) / 86400000)
|
|
: 0;
|
|
if (daysDelta === prevDelta) return;
|
|
|
|
const updated: RangeState = { ...range, currentDate };
|
|
rangeStateRef.current = updated;
|
|
setRangeState(updated);
|
|
}
|
|
},
|
|
[cellWidth],
|
|
);
|
|
|
|
const onCanvasMouseUp = useCallback(
|
|
async (e: React.MouseEvent) => {
|
|
// Project shift
|
|
const drag = dragStateRef.current;
|
|
if (drag.isDragging) {
|
|
if (drag.daysDelta === 0) {
|
|
if (drag.projectId && drag.originalStartDate && drag.originalEndDate) {
|
|
onBlockClick?.({
|
|
allocationId: drag.allocationId ?? "",
|
|
projectId: drag.projectId,
|
|
projectName: drag.projectName ?? "",
|
|
startDate: drag.originalStartDate,
|
|
endDate: drag.originalEndDate,
|
|
});
|
|
}
|
|
} else if (drag.projectId && drag.currentStartDate && drag.currentEndDate) {
|
|
try {
|
|
await applyShiftMutation.mutateAsync({
|
|
projectId: drag.projectId,
|
|
newStartDate: drag.currentStartDate,
|
|
newEndDate: drag.currentEndDate,
|
|
});
|
|
} catch {
|
|
// Validation error — revert visually
|
|
}
|
|
}
|
|
dragStateRef.current = INITIAL_DRAG_STATE;
|
|
setDragState(INITIAL_DRAG_STATE);
|
|
return;
|
|
}
|
|
|
|
// Range select
|
|
const range = rangeStateRef.current;
|
|
if (range.isSelecting && range.resourceId && range.startDate) {
|
|
const endDate = range.currentDate ?? range.startDate;
|
|
const [startDate, finalEnd] =
|
|
range.startDate <= endDate ? [range.startDate, endDate] : [endDate, range.startDate];
|
|
|
|
onRangeSelected?.({
|
|
resourceId: range.resourceId,
|
|
startDate,
|
|
endDate: finalEnd,
|
|
suggestedProjectId: range.suggestedProjectId,
|
|
anchorX: e.clientX,
|
|
anchorY: e.clientY,
|
|
});
|
|
|
|
rangeStateRef.current = INITIAL_RANGE_STATE;
|
|
setRangeState(INITIAL_RANGE_STATE);
|
|
}
|
|
},
|
|
[applyShiftMutation, onBlockClick, 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 (dragStateRef.current.isDragging) {
|
|
dragStateRef.current = INITIAL_DRAG_STATE;
|
|
setDragState(INITIAL_DRAG_STATE);
|
|
}
|
|
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();
|
|
|
|
const initial: MultiSelectState = {
|
|
isSelecting: true,
|
|
startX: e.clientX,
|
|
startY: e.clientY,
|
|
currentX: e.clientX,
|
|
currentY: e.clientY,
|
|
selectedAllocationIds: [],
|
|
selectedResourceIds: [],
|
|
dateRange: null,
|
|
multiDragDaysDelta: 0,
|
|
isMultiDragging: false,
|
|
multiDragMode: "move",
|
|
};
|
|
multiSelectRef.current = initial;
|
|
setMultiSelectState(initial);
|
|
|
|
function handleMove(ev: MouseEvent) {
|
|
const ms = multiSelectRef.current;
|
|
if (!ms.isSelecting) return;
|
|
|
|
const updated: MultiSelectState = {
|
|
...ms,
|
|
currentX: ev.clientX,
|
|
currentY: ev.clientY,
|
|
};
|
|
multiSelectRef.current = updated;
|
|
setMultiSelectState(updated);
|
|
}
|
|
|
|
function handleUp(ev: MouseEvent) {
|
|
document.removeEventListener("mousemove", handleMove);
|
|
document.removeEventListener("mouseup", handleUp);
|
|
|
|
const ms = multiSelectRef.current;
|
|
if (!ms.isSelecting) return;
|
|
|
|
const distance = Math.hypot(ev.clientX - ms.startX, ev.clientY - ms.startY);
|
|
|
|
if (distance < 5) {
|
|
// Minimal movement → not a drag selection, reset.
|
|
// Let existing onContextMenu handlers on allocation blocks handle right-click.
|
|
multiSelectRef.current = INITIAL_MULTI_SELECT;
|
|
setMultiSelectState(INITIAL_MULTI_SELECT);
|
|
return;
|
|
}
|
|
|
|
// Keep the rectangle coordinates for the parent to compute intersection.
|
|
// isSelecting is set to false to indicate the drag is done, but the
|
|
// rectangle data (startX/Y, currentX/Y) is preserved so TimelineView
|
|
// can resolve which allocations/resources fall within the selection.
|
|
const finished: MultiSelectState = {
|
|
...ms,
|
|
isSelecting: false,
|
|
currentX: ev.clientX,
|
|
currentY: ev.clientY,
|
|
};
|
|
multiSelectRef.current = finished;
|
|
setMultiSelectState(finished);
|
|
}
|
|
|
|
document.addEventListener("mousemove", handleMove);
|
|
document.addEventListener("mouseup", handleUp);
|
|
}, []);
|
|
|
|
const clearMultiSelect = useCallback(() => {
|
|
multiSelectRef.current = INITIAL_MULTI_SELECT;
|
|
setMultiSelectState(INITIAL_MULTI_SELECT);
|
|
}, []);
|
|
|
|
// ── Touch support ───────────────────────────────────────────────────────────
|
|
|
|
// Helper: extract clientX from a touch event (first active touch, then changedTouches as fallback)
|
|
function toClientX(e: React.TouchEvent): number {
|
|
return e.touches[0]?.clientX ?? e.changedTouches[0]?.clientX ?? 0;
|
|
}
|
|
|
|
const onProjectBarTouchStart = useCallback(
|
|
(
|
|
e: React.TouchEvent,
|
|
opts: {
|
|
projectId: string;
|
|
projectName: string;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
},
|
|
) => {
|
|
e.preventDefault();
|
|
touchStartRef.current = { x: toClientX(e), y: e.touches[0]?.clientY ?? 0, decided: true };
|
|
onProjectBarMouseDown(
|
|
{
|
|
clientX: toClientX(e),
|
|
preventDefault: () => {},
|
|
stopPropagation: () => {},
|
|
} as unknown as React.MouseEvent,
|
|
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;
|
|
},
|
|
) => {
|
|
e.preventDefault();
|
|
touchStartRef.current = { x: toClientX(e), y: e.touches[0]?.clientY ?? 0, decided: true };
|
|
onAllocMouseDown(
|
|
{
|
|
clientX: toClientX(e),
|
|
preventDefault: () => {},
|
|
stopPropagation: () => {},
|
|
} as unknown as React.MouseEvent,
|
|
opts,
|
|
);
|
|
},
|
|
[onAllocMouseDown],
|
|
);
|
|
|
|
const onRowTouchStart = useCallback(
|
|
(
|
|
e: React.TouchEvent,
|
|
opts: {
|
|
resourceId: string;
|
|
startDate: Date;
|
|
suggestedProjectId?: string;
|
|
},
|
|
) => {
|
|
e.preventDefault();
|
|
touchStartRef.current = { x: toClientX(e), y: e.touches[0]?.clientY ?? 0, decided: false };
|
|
onRowMouseDown(
|
|
{
|
|
clientX: toClientX(e),
|
|
preventDefault: () => {},
|
|
stopPropagation: () => {},
|
|
} as unknown as React.MouseEvent,
|
|
opts,
|
|
);
|
|
},
|
|
[onRowMouseDown],
|
|
);
|
|
|
|
const onCanvasTouchMove = useCallback(
|
|
(e: React.TouchEvent) => {
|
|
const touch = e.touches[0];
|
|
if (!touch) return;
|
|
|
|
// Scroll vs drag disambiguation: once decided, stick with the decision
|
|
if (!touchStartRef.current.decided) {
|
|
const dx = Math.abs(touch.clientX - touchStartRef.current.x);
|
|
const dy = Math.abs(touch.clientY - touchStartRef.current.y);
|
|
if (dx > 8 || dy > 8) {
|
|
touchStartRef.current.decided = true;
|
|
if (dy > dx) return; // vertical scroll wins — don't intercept
|
|
} else {
|
|
return; // haven't moved enough to decide yet
|
|
}
|
|
}
|
|
|
|
onCanvasMouseMove({ clientX: touch.clientX } as React.MouseEvent);
|
|
},
|
|
[onCanvasMouseMove],
|
|
);
|
|
|
|
const onCanvasTouchEnd = useCallback(
|
|
async (e: React.TouchEvent) => {
|
|
const clientX = e.changedTouches[0]?.clientX ?? 0;
|
|
const clientY = e.changedTouches[0]?.clientY ?? 0;
|
|
await onCanvasMouseUp({ clientX, clientY } as React.MouseEvent);
|
|
},
|
|
[onCanvasMouseUp],
|
|
);
|
|
|
|
// ── 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,
|
|
shiftPreview,
|
|
isPreviewLoading,
|
|
isApplying: applyShiftMutation.isPending,
|
|
isAllocSaving: updateAllocMutation.isPending,
|
|
onProjectBarMouseDown,
|
|
onBlockMouseDown,
|
|
onAllocMouseDown,
|
|
onRowMouseDown,
|
|
onCanvasMouseMove,
|
|
onCanvasMouseUp,
|
|
onCanvasMouseLeave,
|
|
onCanvasRightMouseDown,
|
|
clearMultiSelect,
|
|
// Touch equivalents
|
|
onProjectBarTouchStart,
|
|
onAllocTouchStart,
|
|
onRowTouchStart,
|
|
onCanvasTouchMove,
|
|
onCanvasTouchEnd,
|
|
};
|
|
}
|