chore: add pre-commit hooks, tighten ESLint, activate Sentry DSN, publish CI coverage (Phase 1)

- Install husky v9 + lint-staged: pre-commit runs eslint --fix and prettier on staged files
- Tighten ESLint base config: no-console→error, ban-ts-comment (ts-ignore banned, ts-expect-error with description allowed), reportUnusedDisableDirectives→error
- Migrate web app from deprecated `next lint` to `eslint src/` with flat config and react-hooks plugin
- Convert all 5 @ts-ignore to @ts-expect-error with descriptions, remove stale disable comments
- Add NEXT_PUBLIC_SENTRY_DSN to docker-compose.prod.yml and .env.example
- Add coverage artifact upload step to CI test job
- Pre-existing violations (102 warnings) downgraded to warn in web config for Phase 2 cleanup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 14:49:29 +02:00
parent 605fd7cea1
commit 82acc56b8d
38 changed files with 2901 additions and 1251 deletions
@@ -7,7 +7,15 @@ import {
type Assignment,
type DemandRequirement,
} from "@capakraken/shared";
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useSession } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { useTimelineSSE } from "~/hooks/useTimelineSSE.js";
@@ -77,7 +85,9 @@ export type TimelineProjectEntry = TimelineAssignmentEntry | TimelineDemandEntry
export type ViewMode = "resource" | "project";
function buildTimelineFiltersFromSearchParams(searchParams: ReturnType<typeof useSearchParams>): TimelineFilters {
function buildTimelineFiltersFromSearchParams(
searchParams: ReturnType<typeof useSearchParams>,
): TimelineFilters {
const savedPrefs = readAppPreferences();
const next: TimelineFilters = {
...DEFAULT_FILTERS,
@@ -236,9 +246,10 @@ export function TimelineProvider({
}: TimelineProviderProps) {
const { data: session, status: sessionStatus } = useSession();
const searchParams = useSearchParams();
const role = sessionStatus === "authenticated"
? ((session.user as { role?: string } | undefined)?.role ?? "USER")
: null;
const role =
sessionStatus === "authenticated"
? ((session.user as { role?: string } | undefined)?.role ?? "USER")
: null;
const isSelfServiceTimeline = role === "USER" || role === "VIEWER";
const isRoleLoading = sessionStatus === "loading";
@@ -268,7 +279,9 @@ export function TimelineProvider({
const viewEnd = addDays(viewStart, viewDays);
// Support URL params: ?eids=EMP-001,EMP-002&projectIds=id1,id2&chapters=ch1
const [filters, setFilters] = useState<TimelineFilters>(() => buildTimelineFiltersFromSearchParams(searchParams));
const [filters, setFilters] = useState<TimelineFilters>(() =>
buildTimelineFiltersFromSearchParams(searchParams),
);
// Sync filters/viewStart/viewDays from URL params on mount and after later changes
// (e.g. direct nav from another page or router.push("/timeline?eids=...") while already on /timeline)
@@ -318,14 +331,13 @@ export function TimelineProvider({
const staffEntriesViewQuery = trpc.timeline.getEntriesView.useQuery(
timelineQueryInput,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{
enabled: !isRoleLoading && !isSelfServiceTimeline,
placeholderData: (prev: any) => prev,
refetchOnWindowFocus: false,
staleTime: 90_000,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as {
data: TimelineEntriesView | undefined;
isLoading: boolean;
@@ -335,14 +347,13 @@ export function TimelineProvider({
const selfEntriesViewQuery = trpc.timeline.getMyEntriesView.useQuery(
timelineQueryInput,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{
enabled: !isRoleLoading && isSelfServiceTimeline,
placeholderData: (prev: any) => prev,
refetchOnWindowFocus: false,
staleTime: 90_000,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as {
data: TimelineEntriesView | undefined;
isLoading: boolean;
@@ -351,7 +362,12 @@ export function TimelineProvider({
};
const entriesViewQuery = isSelfServiceTimeline ? selfEntriesViewQuery : staffEntriesViewQuery;
const { data: entriesView, isLoading, isError: isEntriesError, refetch: refetchEntriesView } = entriesViewQuery;
const {
data: entriesView,
isLoading,
isError: isEntriesError,
refetch: refetchEntriesView,
} = entriesViewQuery;
const assignments = entriesView?.assignments ?? [];
const demands = entriesView?.demands ?? [];
@@ -374,37 +390,33 @@ export function TimelineProvider({
refetch: () => Promise<unknown>;
};
const vacationEntriesQuery = vacationListQuery(
{ startDate: viewStart, endDate: viewEnd, status: [VacationStatus.APPROVED, VacationStatus.PENDING], limit: 500 },
{
startDate: viewStart,
endDate: viewEnd,
status: [VacationStatus.APPROVED, VacationStatus.PENDING],
limit: 500,
},
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
);
const {
data: vacationEntries = [],
refetch: refetchVacations,
} = vacationEntriesQuery;
const { data: vacationEntries = [], refetch: refetchVacations } = vacationEntriesQuery;
const staffHolidayOverlayQuery = trpc.timeline.getHolidayOverlays.useQuery(
timelineQueryInput,
{
enabled: !isRoleLoading && !isSelfServiceTimeline,
placeholderData: (prev) => prev,
refetchOnWindowFocus: false,
staleTime: 90_000,
},
);
const selfHolidayOverlayQuery = trpc.timeline.getMyHolidayOverlays.useQuery(
timelineQueryInput,
{
enabled: !isRoleLoading && isSelfServiceTimeline,
placeholderData: (prev) => prev,
refetchOnWindowFocus: false,
staleTime: 90_000,
},
);
const activeHolidayOverlayQuery = isSelfServiceTimeline ? selfHolidayOverlayQuery : staffHolidayOverlayQuery;
const {
data: holidayOverlayEntries = [],
refetch: refetchHolidayOverlays,
} = activeHolidayOverlayQuery;
const staffHolidayOverlayQuery = trpc.timeline.getHolidayOverlays.useQuery(timelineQueryInput, {
enabled: !isRoleLoading && !isSelfServiceTimeline,
placeholderData: (prev) => prev,
refetchOnWindowFocus: false,
staleTime: 90_000,
});
const selfHolidayOverlayQuery = trpc.timeline.getMyHolidayOverlays.useQuery(timelineQueryInput, {
enabled: !isRoleLoading && isSelfServiceTimeline,
placeholderData: (prev) => prev,
refetchOnWindowFocus: false,
staleTime: 90_000,
});
const activeHolidayOverlayQuery = isSelfServiceTimeline
? selfHolidayOverlayQuery
: staffHolidayOverlayQuery;
const { data: holidayOverlayEntries = [], refetch: refetchHolidayOverlays } =
activeHolidayOverlayQuery;
const initialRefreshKey = useMemo(
() =>
@@ -510,7 +522,8 @@ export function TimelineProvider({
// Hide fully-filled demands (status COMPLETED or unfilledHeadcount <= 0)
const demandEntry = entry as { status?: string; unfilledHeadcount?: number };
if (demandEntry.status === "COMPLETED") return false;
if (typeof demandEntry.unfilledHeadcount === "number" && demandEntry.unfilledHeadcount <= 0) return false;
if (typeof demandEntry.unfilledHeadcount === "number" && demandEntry.unfilledHeadcount <= 0)
return false;
return true;
}),
[demands, filters.hideCompletedProjects, filters.showDrafts, filters.showPlaceholders],
@@ -642,7 +655,7 @@ export function TimelineProvider({
filters.eids,
filters.projectIds,
filters.clientIds,
]); // eslint-disable-line react-hooks/exhaustive-deps
]);
// ─── Project groups (for project view) ────────────────────────────────────
const projectGroups = useMemo(() => {
@@ -714,18 +727,9 @@ export function TimelineProvider({
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
.filter((pg) => {
if (projectFilter.size > 0 && !projectFilter.has(pg.id)) return false;
if (
clientFilter.size > 0 &&
(!pg.clientId || !clientFilter.has(pg.clientId))
)
return false;
if (
chapterFilter.size > 0 &&
pg.resourceRows.length === 0
)
return false;
if (eidFilter.size > 0 && pg.resourceRows.length === 0)
return false;
if (clientFilter.size > 0 && (!pg.clientId || !clientFilter.has(pg.clientId))) return false;
if (chapterFilter.size > 0 && pg.resourceRows.length === 0) return false;
if (eidFilter.size > 0 && pg.resourceRows.length === 0) return false;
return true;
});
}, [
@@ -736,7 +740,7 @@ export function TimelineProvider({
filters.clientIds,
filters.chapters,
filters.eids,
]); // eslint-disable-line react-hooks/exhaustive-deps
]);
// ─── Derived counts ───────────────────────────────────────────────────────
const isInitialLoading = (isRoleLoading || isLoading) && !entriesView;
+204 -134
View File
@@ -48,13 +48,21 @@ import { InlineAllocationEditor } from "./InlineAllocationEditor.js";
export function TimelineView() {
const { data: session, status: sessionStatus } = useSession();
const mousePosRef = useRef({ x: 0, y: 0 });
const role = sessionStatus === "authenticated"
? ((session.user as { role?: string } | undefined)?.role ?? "USER")
: null;
const role =
sessionStatus === "authenticated"
? ((session.user as { role?: string } | undefined)?.role ?? "USER")
: null;
const isSelfServiceTimeline = role === "USER" || role === "VIEWER";
const canManageTimeline = !isSelfServiceTimeline;
const { push: pushHistory, pushBatch: pushBatchHistory, undo, redo, canUndo, canRedo } = useAllocationHistory();
const {
push: pushHistory,
pushBatch: pushBatchHistory,
undo,
redo,
canUndo,
canRedo,
} = useAllocationHistory();
const pushHistoryRef = useRef(pushHistory);
pushHistoryRef.current = pushHistory;
const pushBatchHistoryRef = useRef(pushBatchHistory);
@@ -145,7 +153,7 @@ export function TimelineView() {
pushHistoryRef.current(snapshot);
},
onShiftClickAlloc: (allocationId: string) => {
setMultiSelectState(prev => {
setMultiSelectState((prev) => {
const ids = new Set(prev.selectedAllocationIds);
if (ids.has(allocationId)) {
ids.delete(allocationId);
@@ -169,61 +177,64 @@ export function TimelineView() {
const [openPanelProjectId, setOpenPanelProjectId] = useState<string | null>(null);
const dragProjectId = dragState.isDragging ? dragState.projectId : null;
const contextProjectId = canManageTimeline ? (dragProjectId ?? openPanelProjectId) : null;
const { contextResourceIds, contextAllocations } = useProjectDragContext(contextProjectId, canManageTimeline);
const { contextResourceIds, contextAllocations } = useProjectDragContext(
contextProjectId,
canManageTimeline,
);
return (
<>
<SuccessToast
show={dragErrorToast !== null}
message={dragErrorToast ?? ""}
variant="warning"
onDone={() => setDragErrorToast(null)}
/>
<TimelineProvider
isDragging={dragState.isDragging}
contextAllocations={contextAllocations as TimelineAssignmentEntry[]}
>
<TimelineViewContent
mousePosRef={mousePosRef}
cellWidthRef={cellWidthRef}
dragState={dragState}
allocDragState={allocDragState}
rangeState={rangeState}
multiSelectState={multiSelectState}
setMultiSelectState={setMultiSelectState}
optimisticAllocations={optimisticAllocations}
reconcileOptimisticAllocations={reconcileOptimisticAllocations}
onCanvasRightMouseDown={onCanvasRightMouseDown}
clearMultiSelect={clearMultiSelect}
shiftPreview={shiftPreview}
isPreviewLoading={isPreviewLoading}
isApplying={isApplying}
isAllocSaving={isAllocSaving}
onProjectBarMouseDown={onProjectBarMouseDown}
onAllocMouseDown={onAllocMouseDown}
onRowMouseDown={onRowMouseDown}
onCanvasMouseMove={onCanvasMouseMove}
onCanvasMouseUp={onCanvasMouseUp}
onCanvasMouseLeave={onCanvasMouseLeave}
onProjectBarTouchStart={onProjectBarTouchStart}
onAllocTouchStart={onAllocTouchStart}
onRowTouchStart={onRowTouchStart}
onCanvasTouchMove={onCanvasTouchMove}
onCanvasTouchEnd={onCanvasTouchEnd}
contextResourceIds={contextResourceIds}
popover={popover}
setPopover={setPopover}
newAllocPopover={newAllocPopover}
setNewAllocPopover={setNewAllocPopover}
openPanelProjectId={openPanelProjectId}
setOpenPanelProjectId={setOpenPanelProjectId}
canUndo={canUndo}
canRedo={canRedo}
isSelfServiceTimeline={isSelfServiceTimeline}
undo={undo}
redo={redo}
<SuccessToast
show={dragErrorToast !== null}
message={dragErrorToast ?? ""}
variant="warning"
onDone={() => setDragErrorToast(null)}
/>
</TimelineProvider>
<TimelineProvider
isDragging={dragState.isDragging}
contextAllocations={contextAllocations as TimelineAssignmentEntry[]}
>
<TimelineViewContent
mousePosRef={mousePosRef}
cellWidthRef={cellWidthRef}
dragState={dragState}
allocDragState={allocDragState}
rangeState={rangeState}
multiSelectState={multiSelectState}
setMultiSelectState={setMultiSelectState}
optimisticAllocations={optimisticAllocations}
reconcileOptimisticAllocations={reconcileOptimisticAllocations}
onCanvasRightMouseDown={onCanvasRightMouseDown}
clearMultiSelect={clearMultiSelect}
shiftPreview={shiftPreview}
isPreviewLoading={isPreviewLoading}
isApplying={isApplying}
isAllocSaving={isAllocSaving}
onProjectBarMouseDown={onProjectBarMouseDown}
onAllocMouseDown={onAllocMouseDown}
onRowMouseDown={onRowMouseDown}
onCanvasMouseMove={onCanvasMouseMove}
onCanvasMouseUp={onCanvasMouseUp}
onCanvasMouseLeave={onCanvasMouseLeave}
onProjectBarTouchStart={onProjectBarTouchStart}
onAllocTouchStart={onAllocTouchStart}
onRowTouchStart={onRowTouchStart}
onCanvasTouchMove={onCanvasTouchMove}
onCanvasTouchEnd={onCanvasTouchEnd}
contextResourceIds={contextResourceIds}
popover={popover}
setPopover={setPopover}
newAllocPopover={newAllocPopover}
setNewAllocPopover={setNewAllocPopover}
openPanelProjectId={openPanelProjectId}
setOpenPanelProjectId={setOpenPanelProjectId}
canUndo={canUndo}
canRedo={canRedo}
isSelfServiceTimeline={isSelfServiceTimeline}
undo={undo}
redo={redo}
/>
</TimelineProvider>
</>
);
}
@@ -278,7 +289,9 @@ function TimelineViewContent({
multiSelectState: ReturnType<typeof useTimelineDrag>["multiSelectState"];
setMultiSelectState: ReturnType<typeof useTimelineDrag>["setMultiSelectState"];
optimisticAllocations: TimelineVisualOverrides;
reconcileOptimisticAllocations: ReturnType<typeof useTimelineDrag>["reconcileOptimisticAllocations"];
reconcileOptimisticAllocations: ReturnType<
typeof useTimelineDrag
>["reconcileOptimisticAllocations"];
onCanvasRightMouseDown: ReturnType<typeof useTimelineDrag>["onCanvasRightMouseDown"];
clearMultiSelect: ReturnType<typeof useTimelineDrag>["clearMultiSelect"];
shiftPreview: ReturnType<typeof useTimelineDrag>["shiftPreview"];
@@ -410,17 +423,15 @@ function TimelineViewContent({
} | null>(null);
const hasActivePointerOverlay =
dragState.isDragging || allocDragState.isActive || rangeState.isSelecting || multiSelectState.isMultiDragging;
dragState.isDragging ||
allocDragState.isActive ||
rangeState.isSelecting ||
multiSelectState.isMultiDragging;
useEffect(() => {
if (optimisticAllocations.size === 0) return;
reconcileOptimisticAllocations([...visibleAssignments, ...visibleDemands]);
}, [
optimisticAllocations,
reconcileOptimisticAllocations,
visibleAssignments,
visibleDemands,
]);
}, [optimisticAllocations, reconcileOptimisticAllocations, visibleAssignments, visibleDemands]);
useEffect(() => {
if (!hasActivePointerOverlay) return;
@@ -473,12 +484,18 @@ function TimelineViewContent({
if (!allocs || allocs.length === 0) return null;
const projectHours = new Map<string, number>();
for (const alloc of allocs) {
projectHours.set(alloc.projectId, (projectHours.get(alloc.projectId) ?? 0) + alloc.hoursPerDay);
projectHours.set(
alloc.projectId,
(projectHours.get(alloc.projectId) ?? 0) + alloc.hoursPerDay,
);
}
let maxPid: string | null = null;
let maxH = 0;
for (const [pid, h] of projectHours) {
if (h > maxH) { maxH = h; maxPid = pid; }
if (h > maxH) {
maxH = h;
maxPid = pid;
}
}
return maxPid;
}, [newAllocPopover, allocsByResource]);
@@ -516,7 +533,7 @@ function TimelineViewContent({
const target: EventTarget = multiSelectState.isMultiDragging ? document : el;
target.addEventListener("mousemove", handler as EventListener, { passive: true });
return () => target.removeEventListener("mousemove", handler as EventListener);
}, [hasActivePointerOverlay, isLoading, mousePosRef, multiSelectState.isMultiDragging]); // eslint-disable-line react-hooks/exhaustive-deps
}, [hasActivePointerOverlay, isLoading, mousePosRef, multiSelectState.isMultiDragging]);
// ─── Shift+wheel → horizontal scroll ──────────────────────────────────────
useEffect(() => {
@@ -530,7 +547,7 @@ function TimelineViewContent({
};
el.addEventListener("wheel", handler, { passive: false });
return () => el.removeEventListener("wheel", handler);
}, [isLoading]); // eslint-disable-line react-hooks/exhaustive-deps
}, [isLoading]);
// ─── Keyboard undo/redo ───────────────────────────────────────────────────
useEffect(() => {
@@ -555,7 +572,10 @@ function TimelineViewContent({
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0) {
if (
multiSelectState.selectedAllocationIds.length > 0 ||
multiSelectState.selectedResourceIds.length > 0
) {
e.preventDefault();
clearMultiSelect();
return;
@@ -579,7 +599,19 @@ function TimelineViewContent({
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [demandPopover, popover, newAllocPopover, openDemandToAssign, openPanelProjectId, setPopover, setNewAllocPopover, setOpenPanelProjectId, multiSelectState.selectedAllocationIds.length, multiSelectState.selectedResourceIds.length, clearMultiSelect]);
}, [
demandPopover,
popover,
newAllocPopover,
openDemandToAssign,
openPanelProjectId,
setPopover,
setNewAllocPopover,
setOpenPanelProjectId,
multiSelectState.selectedAllocationIds.length,
multiSelectState.selectedResourceIds.length,
clearMultiSelect,
]);
// ─── Resource hover card — event delegation on label columns ──────────────
useEffect(() => {
@@ -623,7 +655,11 @@ function TimelineViewContent({
if (hasActivePointerOverlay) return;
const related = e.relatedTarget as HTMLElement | null;
// Don't close if moving into another resource-hover target or the hover card itself
if (related?.closest?.("[data-resource-hover-id]") || related?.closest?.("[data-resource-hover-card]")) return;
if (
related?.closest?.("[data-resource-hover-id]") ||
related?.closest?.("[data-resource-hover-card]")
)
return;
if (resourceHoverTimerRef.current) {
clearTimeout(resourceHoverTimerRef.current);
@@ -646,7 +682,7 @@ function TimelineViewContent({
resourceHoverTimerRef.current = null;
}
};
}, [resourceHover?.resourceId, isInitialLoading, hasActivePointerOverlay]); // eslint-disable-line react-hooks/exhaustive-deps
}, [resourceHover?.resourceId, isInitialLoading, hasActivePointerOverlay]);
// ─── Scroll-left tracking for horizontal virtualization ────────────────────
// Updated via RAF so React state only updates after a frame, not on every
@@ -655,7 +691,6 @@ function TimelineViewContent({
const scrollRafRef = useRef<number | null>(null);
const [scrollLeft, setScrollLeft] = useState(0);
// ─── Navigation callbacks for TimelineToolbar ────────────────────────────
const handleNavigateBack = useCallback(
() => setViewStart((v) => addDays(v, -28)),
@@ -669,8 +704,12 @@ function TimelineViewContent({
() => setViewStart((v) => addDays(v, 28)),
[setViewStart],
);
const handleUndo = useCallback(() => { void undo(); }, [undo]);
const handleRedo = useCallback(() => { void redo(); }, [redo]);
const handleUndo = useCallback(() => {
void undo();
}, [undo]);
const handleRedo = useCallback(() => {
void redo();
}, [redo]);
// ─── Scroll handler — extends date range and tracks scroll offset ─────────
const handleContainerScroll = useCallback(() => {
@@ -712,12 +751,14 @@ function TimelineViewContent({
setDemandPopover({ demand, x: anchorX, y: anchorY });
return;
}
const allocation = visibleAssignments.find((entry) => (
entry.id === info.allocationId
|| entry.entityId === info.allocationId
|| entry.sourceAllocationId === info.allocationId
|| getPlanningEntryMutationId(entry) === info.allocationId
)) ?? null;
const allocation =
visibleAssignments.find(
(entry) =>
entry.id === info.allocationId ||
entry.entityId === info.allocationId ||
entry.sourceAllocationId === info.allocationId ||
getPlanningEntryMutationId(entry) === info.allocationId,
) ?? null;
setPopover({
allocationId: info.allocationId,
projectId: info.projectId,
@@ -754,15 +795,33 @@ function TimelineViewContent({
// memo() on ResourcePanel/ProjectPanel is not defeated by new fn refs.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stableNoop = useCallback((..._args: any[]) => undefined, []);
const panelOnAllocMouseDown = (isSelfServiceTimeline ? stableNoop : onAllocMouseDown) as typeof onAllocMouseDown;
const panelOnAllocTouchStart = (isSelfServiceTimeline ? stableNoop : onAllocTouchStart) as typeof onAllocTouchStart;
const panelOnRowMouseDown = (isSelfServiceTimeline ? stableNoop : onRowMouseDown) as typeof onRowMouseDown;
const panelOnRowTouchStart = (isSelfServiceTimeline ? stableNoop : onRowTouchStart) as typeof onRowTouchStart;
const panelOnAllocationContextMenu = (isSelfServiceTimeline ? stableNoop : openAllocationPopoverAt) as typeof openAllocationPopoverAt;
const panelOnProjectBarMouseDown = (isSelfServiceTimeline ? stableNoop : onProjectBarMouseDown) as typeof onProjectBarMouseDown;
const panelOnProjectBarTouchStart = (isSelfServiceTimeline ? stableNoop : onProjectBarTouchStart) as typeof onProjectBarTouchStart;
const panelOnOpenPanel = (isSelfServiceTimeline ? stableNoop : setOpenPanelProjectId) as typeof setOpenPanelProjectId;
const panelOnOpenDemandClick = (isSelfServiceTimeline ? stableNoop : handleOpenDemandClick) as typeof handleOpenDemandClick;
const panelOnAllocMouseDown = (
isSelfServiceTimeline ? stableNoop : onAllocMouseDown
) as typeof onAllocMouseDown;
const panelOnAllocTouchStart = (
isSelfServiceTimeline ? stableNoop : onAllocTouchStart
) as typeof onAllocTouchStart;
const panelOnRowMouseDown = (
isSelfServiceTimeline ? stableNoop : onRowMouseDown
) as typeof onRowMouseDown;
const panelOnRowTouchStart = (
isSelfServiceTimeline ? stableNoop : onRowTouchStart
) as typeof onRowTouchStart;
const panelOnAllocationContextMenu = (
isSelfServiceTimeline ? stableNoop : openAllocationPopoverAt
) as typeof openAllocationPopoverAt;
const panelOnProjectBarMouseDown = (
isSelfServiceTimeline ? stableNoop : onProjectBarMouseDown
) as typeof onProjectBarMouseDown;
const panelOnProjectBarTouchStart = (
isSelfServiceTimeline ? stableNoop : onProjectBarTouchStart
) as typeof onProjectBarTouchStart;
const panelOnOpenPanel = (
isSelfServiceTimeline ? stableNoop : setOpenPanelProjectId
) as typeof setOpenPanelProjectId;
const panelOnOpenDemandClick = (
isSelfServiceTimeline ? stableNoop : handleOpenDemandClick
) as typeof handleOpenDemandClick;
// ─── Multi-select intersection computation ────────────────────────────────
useMultiSelectIntersection({
@@ -854,7 +913,10 @@ function TimelineViewContent({
}}
onTouchEnd={(e) => void onCanvasTouchEnd(e)}
className={clsx(
(dragState.isDragging || allocDragState.isActive || multiSelectState.isMultiDragging) && "cursor-grabbing select-none",
(dragState.isDragging ||
allocDragState.isActive ||
multiSelectState.isMultiDragging) &&
"cursor-grabbing select-none",
rangeState.isSelecting && "cursor-crosshair select-none",
multiSelectState.isSelecting && "cursor-crosshair select-none",
)}
@@ -1014,65 +1076,73 @@ function TimelineViewContent({
className="fixed z-50 bg-sky-700 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg font-medium"
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
>
{multiSelectState.multiDragMode === "resize-start" ? "Start " : multiSelectState.multiDragMode === "resize-end" ? "End " : ""}
{multiSelectState.multiDragMode === "resize-start"
? "Start "
: multiSelectState.multiDragMode === "resize-end"
? "End "
: ""}
{multiSelectState.multiDragDaysDelta > 0 ? "+" : ""}
{multiSelectState.multiDragDaysDelta}d
{" "}
({multiSelectState.selectedAllocationIds.length} allocations)
{multiSelectState.multiDragDaysDelta}d ({multiSelectState.selectedAllocationIds.length}{" "}
allocations)
</div>
)}
{/* Allocation / Demand popover (click path) */}
{!isSelfServiceTimeline && !hasActivePointerOverlay && popover && (() => {
// Check if clicked allocation is actually a demand
const clickedDemand = openDemandsByProject.get(popover.projectId)?.find((d) => d.id === popover.allocationId);
if (clickedDemand) {
{!isSelfServiceTimeline &&
!hasActivePointerOverlay &&
popover &&
(() => {
// Check if clicked allocation is actually a demand
const clickedDemand = openDemandsByProject
.get(popover.projectId)
?.find((d) => d.id === popover.allocationId);
if (clickedDemand) {
return (
<DemandPopover
demand={clickedDemand}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
onFillDemand={(d) => {
setPopover(null);
setOpenDemandToAssign({
id: d.id,
projectId: d.projectId,
roleId: d.roleId,
role: d.role,
headcount: d.requestedHeadcount,
startDate: new Date(d.startDate),
endDate: new Date(d.endDate),
hoursPerDay: d.hoursPerDay,
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
...(d.project !== undefined ? { project: d.project } : {}),
});
}}
anchorX={popover.x}
anchorY={popover.y}
ignoreScrollContainers={[scrollContainerRef]}
/>
);
}
return (
<DemandPopover
demand={clickedDemand}
<AllocationPopover
allocationId={popover.allocationId}
projectId={popover.projectId}
initialAllocation={popover.allocation ?? null}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
onFillDemand={(d) => {
setPopover(null);
setOpenDemandToAssign({
id: d.id,
projectId: d.projectId,
roleId: d.roleId,
role: d.role,
headcount: d.requestedHeadcount,
startDate: new Date(d.startDate),
endDate: new Date(d.endDate),
hoursPerDay: d.hoursPerDay,
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
...(d.project !== undefined ? { project: d.project } : {}),
});
}}
anchorX={popover.x}
anchorY={popover.y}
ignoreScrollContainers={[scrollContainerRef]}
{...(popover.contextDate ? { contextDate: popover.contextDate } : {})}
/>
);
}
return (
<AllocationPopover
allocationId={popover.allocationId}
projectId={popover.projectId}
initialAllocation={popover.allocation ?? null}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
anchorX={popover.x}
anchorY={popover.y}
ignoreScrollContainers={[scrollContainerRef]}
{...(popover.contextDate ? { contextDate: popover.contextDate } : {})}
/>
);
})()}
})()}
{/* Demand popover */}
{!isSelfServiceTimeline && !hasActivePointerOverlay && demandPopover && (