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:
@@ -0,0 +1,147 @@
|
||||
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
|
||||
import type { useTimelineDrag } from "~/hooks/useTimelineDrag.js";
|
||||
import { formatDateShort } from "~/lib/format.js";
|
||||
import { ShiftPreviewTooltip } from "./ShiftPreviewTooltip.js";
|
||||
|
||||
interface TimelineDragOverlaysProps {
|
||||
dragState: ReturnType<typeof useTimelineDrag>["dragState"];
|
||||
allocDragState: ReturnType<typeof useTimelineDrag>["allocDragState"];
|
||||
rangeState: ReturnType<typeof useTimelineDrag>["rangeState"];
|
||||
multiSelectState: ReturnType<typeof useTimelineDrag>["multiSelectState"];
|
||||
shiftPreview: ReturnType<typeof useTimelineDrag>["shiftPreview"];
|
||||
isPreviewLoading: boolean;
|
||||
isApplying: boolean;
|
||||
isAllocSaving: boolean;
|
||||
mousePosRef: React.RefObject<{ x: number; y: number }>;
|
||||
dragTooltipRef: React.RefObject<HTMLDivElement | null>;
|
||||
allocTooltipRef: React.RefObject<HTMLDivElement | null>;
|
||||
rangeHintRef: React.RefObject<HTMLDivElement | null>;
|
||||
multiDragTooltipRef: React.RefObject<HTMLDivElement | null>;
|
||||
today: Date;
|
||||
}
|
||||
|
||||
export function TimelineDragOverlays({
|
||||
dragState,
|
||||
allocDragState,
|
||||
rangeState,
|
||||
multiSelectState,
|
||||
shiftPreview,
|
||||
isPreviewLoading,
|
||||
isApplying,
|
||||
isAllocSaving,
|
||||
mousePosRef,
|
||||
dragTooltipRef,
|
||||
allocTooltipRef,
|
||||
rangeHintRef,
|
||||
multiDragTooltipRef,
|
||||
today,
|
||||
}: TimelineDragOverlaysProps) {
|
||||
return (
|
||||
<>
|
||||
{/* 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),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user