feat: timeline multi-select, demand popover, resource hover card, merged tooltips, dark mode fixes
Major timeline enhancements: - Right-click drag multi-selection with floating action bar (batch delete/assign) - DemandPopover for demand strip details (replaces broken "Loading" modal) - ResourceHoverCard on name hover showing skills, rates, role, chapter - Merged heatmap+vacation tooltips into unified TimelineTooltip component - Fixed overbooking blink animation (date normalization, z-index ordering) - Fixed dark mode sticky column bleed-through in project view - System roles admin page, notification task management, performance review docs Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -5,16 +5,27 @@ import type { AllocationMovedSnapshot } from "./useTimelineDrag.js";
|
||||
|
||||
export type { AllocationMovedSnapshot };
|
||||
|
||||
const MAX_HISTORY = 20;
|
||||
/** A single allocation move or a batch shift of multiple allocations */
|
||||
export type HistoryEntry =
|
||||
| { type: "single"; snapshot: AllocationMovedSnapshot }
|
||||
| { type: "batch"; allocationIds: string[]; daysDelta: number; mode: "move" | "resize-start" | "resize-end" };
|
||||
|
||||
const DEFAULT_MAX_HISTORY = 50;
|
||||
|
||||
export function useAllocationHistory() {
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
const past = useRef<AllocationMovedSnapshot[]>([]);
|
||||
const future = useRef<AllocationMovedSnapshot[]>([]);
|
||||
const past = useRef<HistoryEntry[]>([]);
|
||||
const future = useRef<HistoryEntry[]>([]);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Configurable max steps from system settings
|
||||
const { data: settings } = trpc.settings.getSystemSettings.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const maxHistory = settings?.timelineUndoMaxSteps ?? DEFAULT_MAX_HISTORY;
|
||||
|
||||
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
@@ -24,12 +35,35 @@ export function useAllocationHistory() {
|
||||
},
|
||||
});
|
||||
|
||||
const batchShiftMutation = trpc.timeline.batchShiftAllocations.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const push = useCallback((snapshot: AllocationMovedSnapshot) => {
|
||||
past.current = [...past.current.slice(-MAX_HISTORY + 1), snapshot];
|
||||
past.current = [...past.current.slice(-(maxHistory - 1)), { type: "single", snapshot }];
|
||||
future.current = [];
|
||||
setCanUndo(true);
|
||||
setCanRedo(false);
|
||||
}, []);
|
||||
}, [maxHistory]);
|
||||
|
||||
const pushBatch = useCallback((allocationIds: string[], daysDelta: number, mode: "move" | "resize-start" | "resize-end" = "move") => {
|
||||
past.current = [...past.current.slice(-(maxHistory - 1)), { type: "batch", allocationIds, daysDelta, mode }];
|
||||
future.current = [];
|
||||
setCanUndo(true);
|
||||
setCanRedo(false);
|
||||
}, [maxHistory]);
|
||||
|
||||
const invalidateAll = useCallback(() => {
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
}, [utils]);
|
||||
|
||||
const undo = useCallback(async () => {
|
||||
const last = past.current[past.current.length - 1];
|
||||
@@ -38,12 +72,25 @@ export function useAllocationHistory() {
|
||||
future.current = [last, ...future.current];
|
||||
setCanUndo(past.current.length > 0);
|
||||
setCanRedo(true);
|
||||
await updateMutation.mutateAsync({
|
||||
allocationId: last.mutationAllocationId,
|
||||
startDate: last.before.startDate,
|
||||
endDate: last.before.endDate,
|
||||
});
|
||||
}, [updateMutation]);
|
||||
|
||||
if (last.type === "single") {
|
||||
await updateMutation.mutateAsync({
|
||||
allocationId: last.snapshot.mutationAllocationId,
|
||||
startDate: last.snapshot.before.startDate,
|
||||
endDate: last.snapshot.before.endDate,
|
||||
});
|
||||
} else {
|
||||
// Batch: reverse the shift (for resize modes, reverse means shifting the same edge back)
|
||||
const reverseMode = last.mode === "resize-start" ? "resize-start"
|
||||
: last.mode === "resize-end" ? "resize-end"
|
||||
: "move";
|
||||
await batchShiftMutation.mutateAsync({
|
||||
allocationIds: last.allocationIds,
|
||||
daysDelta: -last.daysDelta,
|
||||
mode: reverseMode,
|
||||
});
|
||||
}
|
||||
}, [updateMutation, batchShiftMutation]);
|
||||
|
||||
const redo = useCallback(async () => {
|
||||
const next = future.current[0];
|
||||
@@ -52,12 +99,22 @@ export function useAllocationHistory() {
|
||||
past.current = [...past.current, next];
|
||||
setCanUndo(true);
|
||||
setCanRedo(future.current.length > 0);
|
||||
await updateMutation.mutateAsync({
|
||||
allocationId: next.mutationAllocationId,
|
||||
startDate: next.after.startDate,
|
||||
endDate: next.after.endDate,
|
||||
});
|
||||
}, [updateMutation]);
|
||||
|
||||
return { push, undo, redo, canUndo, canRedo };
|
||||
if (next.type === "single") {
|
||||
await updateMutation.mutateAsync({
|
||||
allocationId: next.snapshot.mutationAllocationId,
|
||||
startDate: next.snapshot.after.startDate,
|
||||
endDate: next.snapshot.after.endDate,
|
||||
});
|
||||
} else {
|
||||
// Batch: re-apply the shift
|
||||
await batchShiftMutation.mutateAsync({
|
||||
allocationIds: next.allocationIds,
|
||||
daysDelta: next.daysDelta,
|
||||
mode: next.mode,
|
||||
});
|
||||
}
|
||||
}, [updateMutation, batchShiftMutation]);
|
||||
|
||||
return { push, pushBatch, undo, redo, canUndo, canRedo };
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface AppPreferences {
|
||||
heatmapColorScheme: HeatmapColorScheme;
|
||||
/** Show open demand / placeholder entries by default when loading the timeline. Default: true. */
|
||||
showDemandProjects: boolean;
|
||||
/** Blink overbooked days (>8h) as a warning on the timeline. Default: false. */
|
||||
blinkOverbookedDays: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "planarchy_prefs";
|
||||
@@ -28,6 +30,7 @@ const DEFAULT: AppPreferences = {
|
||||
timelineDisplayMode: "strip",
|
||||
heatmapColorScheme: "green-red",
|
||||
showDemandProjects: true,
|
||||
blinkOverbookedDays: false,
|
||||
};
|
||||
|
||||
export function readAppPreferences(): AppPreferences {
|
||||
@@ -94,5 +97,13 @@ export function useAppPreferences() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { prefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme, setShowDemandProjects };
|
||||
const setBlinkOverbookedDays = useCallback((value: boolean) => {
|
||||
setPrefs((prev) => {
|
||||
const next = { ...prev, blinkOverbookedDays: value };
|
||||
saveAppPreferences(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { prefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme, setShowDemandProjects, setBlinkOverbookedDays };
|
||||
}
|
||||
|
||||
@@ -118,6 +118,39 @@ const INITIAL_RANGE_STATE: RangeState = {
|
||||
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 {
|
||||
@@ -134,20 +167,28 @@ export function useTimelineDrag({
|
||||
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) => 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);
|
||||
@@ -166,6 +207,12 @@ export function useTimelineDrag({
|
||||
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();
|
||||
|
||||
// Project-shift preview
|
||||
@@ -312,6 +359,54 @@ export function useTimelineDrag({
|
||||
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 = Math.round(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) {
|
||||
onMultiDragCompleteRef.current?.(finalDelta, dragMode);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMultiMove);
|
||||
document.addEventListener("mouseup", handleMultiUp);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Single allocation drag ────────────────────────────────────────────
|
||||
|
||||
const initial: AllocDragState = {
|
||||
isActive: true,
|
||||
mode: opts.mode,
|
||||
@@ -375,14 +470,20 @@ export function useTimelineDrag({
|
||||
if (!alloc.isActive) return;
|
||||
|
||||
if (alloc.daysDelta === 0 && alloc.allocationId) {
|
||||
// No movement → treat as click, open alloc popover
|
||||
onBlockClickRef.current?.({
|
||||
allocationId: alloc.allocationId,
|
||||
projectId: alloc.projectId ?? "",
|
||||
projectName: alloc.projectName ?? "",
|
||||
startDate: alloc.originalStartDate!,
|
||||
endDate: alloc.originalEndDate!,
|
||||
});
|
||||
// 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,
|
||||
@@ -550,6 +651,81 @@ export function useTimelineDrag({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── 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)
|
||||
@@ -682,6 +858,8 @@ export function useTimelineDrag({
|
||||
dragState,
|
||||
allocDragState,
|
||||
rangeState,
|
||||
multiSelectState,
|
||||
setMultiSelectState,
|
||||
shiftPreview,
|
||||
isPreviewLoading,
|
||||
isApplying: applyShiftMutation.isPending,
|
||||
@@ -693,6 +871,8 @@ export function useTimelineDrag({
|
||||
onCanvasMouseMove,
|
||||
onCanvasMouseUp,
|
||||
onCanvasMouseLeave,
|
||||
onCanvasRightMouseDown,
|
||||
clearMultiSelect,
|
||||
// Touch equivalents
|
||||
onProjectBarTouchStart,
|
||||
onAllocTouchStart,
|
||||
|
||||
@@ -44,10 +44,10 @@ export function useTimelineLayout(
|
||||
key={i}
|
||||
className={clsx(
|
||||
"absolute top-0 bottom-0 border-r",
|
||||
isToday ? "border-brand-300 border-r-2" :
|
||||
isSaturday ? "border-amber-200 bg-amber-50/40" :
|
||||
isSunday ? "border-gray-200 bg-gray-100/60" :
|
||||
"border-gray-100",
|
||||
isToday ? "border-brand-300 dark:border-brand-700 border-r-2" :
|
||||
isSaturday ? "border-amber-200 dark:border-amber-800 bg-amber-50/40 dark:bg-amber-950/20" :
|
||||
isSunday ? "border-gray-200 dark:border-gray-700 bg-gray-100/60 dark:bg-gray-800/40" :
|
||||
"border-gray-100 dark:border-gray-800",
|
||||
)}
|
||||
style={{ left: i * CELL_WIDTH, width: CELL_WIDTH }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user