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>
This commit is contained in:
2026-04-11 23:16:38 +02:00
parent 5a4836d292
commit bfcadd2c52
8 changed files with 1224 additions and 785 deletions
+48 -279
View File
@@ -1,6 +1,5 @@
"use client";
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
import { clsx } from "clsx";
import { useSession } from "next-auth/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -11,21 +10,14 @@ import { useTimelineLayout } from "~/hooks/useTimelineLayout.js";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
import { AllocationPopover } from "./AllocationPopover.js";
import { DemandPopover } from "./DemandPopover.js";
import { ResourceHoverCard } from "./ResourceHoverCard.js";
import type { TimelineDemandEntry } from "./TimelineContext.js";
import { BatchAssignPopover } from "./BatchAssignPopover.js";
import { FloatingActionBar } from "./FloatingActionBar.js";
import { NewAllocationPopover } from "./NewAllocationPopover.js";
import { ProjectPanel } from "./ProjectPanel.js";
import { ShiftPreviewTooltip } from "./ShiftPreviewTooltip.js";
import { TimelineDragOverlays } from "./TimelineDragOverlays.js";
import { TimelineHeader } from "./TimelineHeader.js";
import { TimelinePopovers } from "./TimelinePopovers.js";
import { TimelineToolbar } from "./TimelineToolbar.js";
import { addDays } from "./utils.js";
import { HEADER_DAY_HEIGHT, HEADER_MONTH_HEIGHT, LABEL_WIDTH } from "./timelineConstants.js";
import { formatDateShort } from "~/lib/format.js";
import {
TimelineProvider,
useTimelineData,
@@ -984,228 +976,23 @@ function TimelineViewContent({
)}
</div>
{/* Multi-select rectangle overlay */}
{multiSelectState.isSelecting && (
<div
className="fixed border-2 border-sky-500 bg-sky-500/10 pointer-events-none z-30 rounded"
style={{
left: Math.min(multiSelectState.startX, multiSelectState.currentX),
top: Math.min(multiSelectState.startY, multiSelectState.currentY),
width: Math.abs(multiSelectState.currentX - multiSelectState.startX),
height: Math.abs(multiSelectState.currentY - multiSelectState.startY),
}}
/>
)}
<TimelineDragOverlays
dragState={dragState}
allocDragState={allocDragState}
rangeState={rangeState}
multiSelectState={multiSelectState}
shiftPreview={shiftPreview}
isPreviewLoading={isPreviewLoading}
isApplying={isApplying}
isAllocSaving={isAllocSaving}
mousePosRef={mousePosRef}
dragTooltipRef={dragTooltipRef}
allocTooltipRef={allocTooltipRef}
rangeHintRef={rangeHintRef}
multiDragTooltipRef={multiDragTooltipRef}
today={today}
/>
{/* Saving indicators */}
{(isApplying || isAllocSaving) && (
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center rounded-2xl bg-white/50 dark:bg-gray-950/50">
<div className="app-surface px-5 py-3 text-sm font-medium text-gray-700 dark:text-gray-200">
{isApplying ? "Applying shift…" : "Saving…"}
</div>
</div>
)}
{/* Drag preview tooltip */}
{dragState.isDragging && dragState.daysDelta !== 0 && (
<div
ref={dragTooltipRef}
className="fixed z-50 pointer-events-none"
style={{ left: mousePosRef.current.x + 12, top: mousePosRef.current.y - 8 }}
>
<ShiftPreviewTooltip
preview={
shiftPreview ?? {
valid: true,
deltaCents: 0,
wouldExceedBudget: false,
budgetUtilizationAfter: 0,
conflictCount: 0,
errors: [],
warnings: [],
}
}
projectName={dragState.projectName ?? ""}
newStartDate={dragState.currentStartDate ?? today}
newEndDate={dragState.currentEndDate ?? today}
isLoading={isPreviewLoading}
/>
</div>
)}
{/* Alloc drag tooltip */}
{allocDragState.isActive &&
allocDragState.daysDelta !== 0 &&
allocDragState.currentStartDate &&
allocDragState.currentEndDate && (
<div
ref={allocTooltipRef}
className="fixed z-40 bg-gray-800 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg space-y-0.5"
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
>
<div className="font-semibold">{allocDragState.projectName}</div>
<div className="opacity-80">
{formatDateShort(allocDragState.currentStartDate)}
{" "}
{formatDateShort(allocDragState.currentEndDate)}
</div>
</div>
)}
{/* Range-select hint */}
{rangeState.isSelecting && rangeState.startDate && rangeState.currentDate && (
<div
ref={rangeHintRef}
className="fixed z-40 bg-brand-700 text-white text-xs px-2 py-1 rounded-lg pointer-events-none shadow"
style={{ left: mousePosRef.current.x + 12, top: mousePosRef.current.y - 28 }}
>
{(() => {
const end = rangeState.currentDate;
const [s, e] =
rangeState.startDate <= end
? [rangeState.startDate, end]
: [end, rangeState.startDate];
const days = Math.round((e.getTime() - s.getTime()) / MILLISECONDS_PER_DAY) + 1;
return `${days} day${days !== 1 ? "s" : ""}`;
})()}
</div>
)}
{/* Multi-drag tooltip */}
{multiSelectState.isMultiDragging && multiSelectState.multiDragDaysDelta !== 0 && (
<div
ref={multiDragTooltipRef}
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.multiDragDaysDelta > 0 ? "+" : ""}
{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) {
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 (
<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 && (
<DemandPopover
demand={demandPopover.demand}
onClose={() => setDemandPopover(null)}
onOpenPanel={(pid) => {
setDemandPopover(null);
setOpenPanelProjectId(pid);
}}
onFillDemand={(d) => {
setDemandPopover(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={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 */}
<FloatingActionBar
selectedAllocationCount={multiSelectState.selectedAllocationIds.length}
selectedResourceCount={multiSelectState.selectedResourceIds.length}
@@ -1215,54 +1002,36 @@ function TimelineViewContent({
isDeleting={batchDeleteMutation.isPending}
/>
{/* Batch assign popover */}
{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>
<TimelinePopovers
isSelfServiceTimeline={isSelfServiceTimeline}
hasActivePointerOverlay={hasActivePointerOverlay}
popover={popover}
setPopover={setPopover}
demandPopover={demandPopover}
setDemandPopover={setDemandPopover}
newAllocPopover={newAllocPopover}
setNewAllocPopover={setNewAllocPopover}
enrichedSuggestedProjectId={enrichedSuggestedProjectId}
openPanelProjectId={openPanelProjectId}
setOpenPanelProjectId={setOpenPanelProjectId}
openDemandToAssign={openDemandToAssign}
setOpenDemandToAssign={setOpenDemandToAssign}
openDemandsByProject={openDemandsByProject}
scrollContainerRef={scrollContainerRef}
multiSelectState={multiSelectState}
clearMultiSelect={clearMultiSelect}
handleBatchDelete={handleBatchDelete}
handleShowBatchAssign={handleShowBatchAssign}
isDeleting={batchDeleteMutation.isPending}
showBatchAssign={showBatchAssign}
setShowBatchAssign={setShowBatchAssign}
resourceHover={resourceHover}
setResourceHover={setResourceHover}
inlineEditTarget={inlineEditTarget}
setInlineEditTarget={setInlineEditTarget}
showShortcuts={showShortcuts}
setShowShortcuts={setShowShortcuts}
/>
</div>
);
}