Files
Nexus/apps/web/src/components/timeline/TimelinePopovers.tsx
T
HartmutandClaude Opus 4.6 bfcadd2c52 refactor(web): decompose TimelineView, ReportBuilder, and ResourceModal into focused components
Extract overlay/popover JSX from TimelineView (1268→1037 lines) into TimelineDragOverlays and
TimelinePopovers. Extract ResourceMonthConfigSection from ReportBuilder (1132→1018 lines).
Extract ResourceSkillsEditor and ResourceOrgClassification from ResourceModal (1035→714 lines).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 23:16:38 +02:00

263 lines
9.2 KiB
TypeScript

import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
import { AllocationPopover } from "./AllocationPopover.js";
import { BatchAssignPopover } from "./BatchAssignPopover.js";
import { DemandPopover } from "./DemandPopover.js";
import { InlineAllocationEditor } from "./InlineAllocationEditor.js";
import { KeyboardShortcutOverlay } from "./KeyboardShortcutOverlay.js";
import { NewAllocationPopover } from "./NewAllocationPopover.js";
import { ProjectPanel } from "./ProjectPanel.js";
import { ResourceHoverCard } from "./ResourceHoverCard.js";
import type { TimelineDemandEntry, TimelineAssignmentEntry } from "./TimelineContext.js";
import type { OpenDemandAssignment } from "./TimelineProjectPanel.js";
import type { useTimelineDrag } from "~/hooks/useTimelineDrag.js";
interface TimelinePopoversProps {
isSelfServiceTimeline: boolean;
hasActivePointerOverlay: boolean;
popover: {
allocationId: string;
projectId: string;
allocation?: TimelineAssignmentEntry | null;
x: number;
y: number;
contextDate?: Date;
} | null;
setPopover: React.Dispatch<React.SetStateAction<TimelinePopoversProps["popover"]>>;
demandPopover: { demand: TimelineDemandEntry; x: number; y: number } | null;
setDemandPopover: React.Dispatch<React.SetStateAction<TimelinePopoversProps["demandPopover"]>>;
newAllocPopover: {
resourceId: string;
startDate: Date;
endDate: Date;
suggestedProjectId: string | null;
anchorX: number;
anchorY: number;
selectionResourceId: string;
selectionStart: Date;
selectionEnd: Date;
} | null;
setNewAllocPopover: React.Dispatch<
React.SetStateAction<TimelinePopoversProps["newAllocPopover"]>
>;
enrichedSuggestedProjectId: string | null;
openPanelProjectId: string | null;
setOpenPanelProjectId: React.Dispatch<React.SetStateAction<string | null>>;
openDemandToAssign: OpenDemandAssignment | null;
setOpenDemandToAssign: React.Dispatch<React.SetStateAction<OpenDemandAssignment | null>>;
openDemandsByProject: Map<string, TimelineDemandEntry[]>;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
multiSelectState: ReturnType<typeof useTimelineDrag>["multiSelectState"];
clearMultiSelect: ReturnType<typeof useTimelineDrag>["clearMultiSelect"];
handleBatchDelete: () => void;
handleShowBatchAssign: () => void;
isDeleting: boolean;
showBatchAssign: boolean;
setShowBatchAssign: React.Dispatch<React.SetStateAction<boolean>>;
resourceHover: { resourceId: string; anchorEl: HTMLElement } | null;
setResourceHover: React.Dispatch<React.SetStateAction<TimelinePopoversProps["resourceHover"]>>;
inlineEditTarget: {
allocationId: string;
startDate: Date;
endDate: Date;
hoursPerDay: number;
barRect: DOMRect;
} | null;
setInlineEditTarget: React.Dispatch<
React.SetStateAction<TimelinePopoversProps["inlineEditTarget"]>
>;
showShortcuts: boolean;
setShowShortcuts: React.Dispatch<React.SetStateAction<boolean>>;
}
function buildDemandAssignment(d: TimelineDemandEntry): OpenDemandAssignment {
return {
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 } : {}),
};
}
export function TimelinePopovers({
isSelfServiceTimeline,
hasActivePointerOverlay,
popover,
setPopover,
demandPopover,
setDemandPopover,
newAllocPopover,
setNewAllocPopover,
enrichedSuggestedProjectId,
openPanelProjectId,
setOpenPanelProjectId,
openDemandToAssign,
setOpenDemandToAssign,
openDemandsByProject,
scrollContainerRef,
multiSelectState,
clearMultiSelect,
handleBatchDelete,
handleShowBatchAssign,
isDeleting,
showBatchAssign,
setShowBatchAssign,
resourceHover,
setResourceHover,
inlineEditTarget,
setInlineEditTarget,
showShortcuts,
setShowShortcuts,
}: TimelinePopoversProps) {
return (
<>
{/* Allocation / Demand popover (click path) */}
{!isSelfServiceTimeline &&
!hasActivePointerOverlay &&
popover &&
(() => {
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(buildDemandAssignment(d));
}}
anchorX={popover.x}
anchorY={popover.y}
ignoreScrollContainers={[scrollContainerRef]}
/>
);
}
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 (context menu path) */}
{!isSelfServiceTimeline && !hasActivePointerOverlay && demandPopover && (
<DemandPopover
demand={demandPopover.demand}
onClose={() => setDemandPopover(null)}
onOpenPanel={(pid) => {
setDemandPopover(null);
setOpenPanelProjectId(pid);
}}
onFillDemand={(d) => {
setDemandPopover(null);
setOpenDemandToAssign(buildDemandAssignment(d));
}}
anchorX={demandPopover.x}
anchorY={demandPopover.y}
ignoreScrollContainers={[scrollContainerRef]}
/>
)}
{/* New allocation popover */}
{!isSelfServiceTimeline && newAllocPopover && (
<NewAllocationPopover
resourceId={newAllocPopover.resourceId}
startDate={newAllocPopover.startDate}
endDate={newAllocPopover.endDate}
suggestedProjectId={enrichedSuggestedProjectId}
anchorX={newAllocPopover.anchorX}
anchorY={newAllocPopover.anchorY}
onClose={() => setNewAllocPopover(null)}
onCreated={() => setNewAllocPopover(null)}
ignoreScrollContainers={[scrollContainerRef]}
/>
)}
{/* Project side panel */}
{!isSelfServiceTimeline && openPanelProjectId && (
<ProjectPanel projectId={openPanelProjectId} onClose={() => setOpenPanelProjectId(null)} />
)}
{/* Open-demand assignment modal */}
{!isSelfServiceTimeline && openDemandToAssign && (
<FillOpenDemandModal
allocation={openDemandToAssign}
onClose={() => setOpenDemandToAssign(null)}
onSuccess={() => setOpenDemandToAssign(null)}
/>
)}
{/* Multi-select floating action bar + batch assign */}
{showBatchAssign && multiSelectState.dateRange && (
<BatchAssignPopover
resourceIds={multiSelectState.selectedResourceIds}
startDate={multiSelectState.dateRange.start}
endDate={multiSelectState.dateRange.end}
onClose={() => setShowBatchAssign(false)}
onCreated={() => {
setShowBatchAssign(false);
clearMultiSelect();
}}
/>
)}
{/* Resource hover card */}
{!hasActivePointerOverlay && resourceHover && (
<ResourceHoverCard
resourceId={resourceHover.resourceId}
anchorEl={resourceHover.anchorEl}
onClose={() => setResourceHover(null)}
/>
)}
{/* Inline allocation editor */}
{inlineEditTarget && (
<InlineAllocationEditor
allocationId={inlineEditTarget.allocationId}
initialStartDate={inlineEditTarget.startDate}
initialEndDate={inlineEditTarget.endDate}
initialHoursPerDay={inlineEditTarget.hoursPerDay}
barRect={inlineEditTarget.barRect}
onClose={() => setInlineEditTarget(null)}
onSaved={() => setInlineEditTarget(null)}
/>
)}
{/* Keyboard shortcut overlay */}
{showShortcuts && <KeyboardShortcutOverlay onClose={() => setShowShortcuts(false)} />}
{/* Keyboard shortcut hint button */}
<button
type="button"
onClick={() => setShowShortcuts((prev) => !prev)}
title="Keyboard shortcuts (?)"
className="fixed bottom-6 right-6 z-40 rounded-full w-8 h-8 flex items-center justify-center bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 text-sm font-medium"
>
?
</button>
</>
);
}