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:
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user