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:
2026-03-18 23:43:51 +01:00
parent d0f04f13f8
commit ddec3a927a
67 changed files with 4930 additions and 1166 deletions
+75 -18
View File
@@ -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 };
}