diff --git a/apps/web/src/components/timeline/InlineAllocationEditor.tsx b/apps/web/src/components/timeline/InlineAllocationEditor.tsx new file mode 100644 index 0000000..f39de8d --- /dev/null +++ b/apps/web/src/components/timeline/InlineAllocationEditor.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import { trpc } from "~/lib/trpc/client.js"; +import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js"; + +interface InlineAllocationEditorProps { + allocationId: string; + initialStartDate: Date; + initialEndDate: Date; + initialHoursPerDay: number; + /** Bounding rect of the allocation bar (used for positioning) */ + barRect: DOMRect; + onClose: () => void; + onSaved: () => void; +} + +function toIso(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +export function InlineAllocationEditor({ + allocationId, + initialStartDate, + initialEndDate, + initialHoursPerDay, + barRect, + onClose, + onSaved, +}: InlineAllocationEditorProps) { + const [startDate, setStartDate] = useState(toIso(initialStartDate)); + const [endDate, setEndDate] = useState(toIso(initialEndDate)); + const [hoursPerDay, setHoursPerDay] = useState(initialHoursPerDay); + const [error, setError] = useState(null); + const panelRef = useRef(null); + const invalidateTimeline = useInvalidateTimeline(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const updateMutation = (trpc.allocation.update.useMutation as any)({ + onSuccess: () => { + invalidateTimeline(); + onSaved(); + }, + onError: (err: { message: string }) => { + setError(err.message || "Save failed"); + }, + }) as { mutate: (input: unknown) => void; isPending: boolean }; + + // Position: below the bar, clamped to viewport + const top = Math.min(barRect.bottom + 6, window.innerHeight - 220); + const left = Math.min(Math.max(8, barRect.left), window.innerWidth - 248); + + // Close on click outside + useEffect(() => { + const handler = (e: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [onClose]); + + // Close on Escape + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onClose]); + + function handleSave(e: React.FormEvent) { + e.preventDefault(); + setError(null); + const s = new Date(startDate); + const en = new Date(endDate); + if (en < s) { + setError("End date must be after start date"); + return; + } + updateMutation.mutate({ + id: allocationId, + data: { startDate: s, endDate: en, hoursPerDay }, + }); + } + + const panel = ( +
+
Edit Allocation
+
+
+ + setStartDate(e.target.value)} + className="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-2 py-1 text-xs text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-brand-500" + autoFocus + /> +
+
+ + setEndDate(e.target.value)} + className="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-2 py-1 text-xs text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-brand-500" + /> +
+
+ + setHoursPerDay(Number(e.target.value))} + className="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-2 py-1 text-xs text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-brand-500" + /> +
+ {error &&

{error}

} +
+ + +
+
+
+ ); + + return typeof document === "undefined" ? panel : createPortal(panel, document.body); +} diff --git a/apps/web/src/components/timeline/TimelineResourcePanel.tsx b/apps/web/src/components/timeline/TimelineResourcePanel.tsx index a6cad4e..d26f30a 100644 --- a/apps/web/src/components/timeline/TimelineResourcePanel.tsx +++ b/apps/web/src/components/timeline/TimelineResourcePanel.tsx @@ -1,5 +1,6 @@ "use client"; +import { MILLISECONDS_PER_DAY } from "@capakraken/shared"; import { clsx } from "clsx"; import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; @@ -80,6 +81,7 @@ interface TimelineResourcePanelProps { multiSelectState: MultiSelectState; optimisticAllocations: TimelineVisualOverrides; suppressHoverInteractions: boolean; + onInlineEdit?: (allocationId: string, initialValues: { startDate: Date; endDate: Date; hoursPerDay: number }, barRect: DOMRect) => void; // Layout from useTimelineLayout CELL_WIDTH: number; dates: Date[]; @@ -127,6 +129,7 @@ function TimelineResourcePanelInner({ multiSelectState, optimisticAllocations, suppressHoverInteractions, + onInlineEdit, CELL_WIDTH, dates, totalCanvasWidth, @@ -195,9 +198,10 @@ function TimelineResourcePanelInner({ // (virtualizer handles which subset is visible; this memo just pre-computes // per-row data that the render loop needs) const resourceRows = useMemo(() => { + const contextSet = new Set(contextResourceIds); return resources.map((resource) => { const allocs = visualAllocsByResource.get(resource.id) ?? []; - const isContextResource = contextResourceIds.includes(resource.id); + const isContextResource = contextSet.has(resource.id); return { resource, allocs, isContextResource }; }); }, [resources, visualAllocsByResource, contextResourceIds]); @@ -212,8 +216,9 @@ function TimelineResourcePanelInner({ toWidth, CELL_WIDTH, totalCanvasWidth, + filters.showWeekends, ), - [vacationsByResource, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, filters.showVacations], + [vacationsByResource, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, filters.showVacations, filters.showWeekends], ); // ─── Memo 3: assignmentBlocks — pre-computed per resource for strip mode ── @@ -507,6 +512,7 @@ function TimelineResourcePanelInner({ onAllocationContextMenu, multiSelectState, suppressHoverInteractions, + onInlineEdit, )} {filters.showVacations && renderVacationBlocks( @@ -612,8 +618,10 @@ function renderAllocBlocksFromData( ) => void, multiSelectState: MultiSelectState, suppressHoverInteractions: boolean, + onInlineEdit?: (allocationId: string, initialValues: { startDate: Date; endDate: Date; hoursPerDay: number }, barRect: DOMRect) => void, ) { const anyDragActive = dragState.isDragging || allocDragState.isActive; + const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds); function toUtcDay(value: Date): Date { return new Date(Date.UTC(value.getFullYear(), value.getMonth(), value.getDate())); @@ -636,7 +644,7 @@ function renderAllocBlocksFromData( const rawIndex = Math.floor((clientX - rect.left) / CELL_WIDTH); const maxIndex = Math.max( 0, - Math.round((end.getTime() - start.getTime()) / 86_400_000), + Math.round((end.getTime() - start.getTime()) / MILLISECONDS_PER_DAY), ); const dayIndex = Math.min(Math.max(rawIndex, 0), maxIndex); return addUtcDays(start, dayIndex); @@ -662,7 +670,7 @@ function renderAllocBlocksFromData( // Multi-drag offset: shift selected allocations visually during multi-drag const isMultiDragTarget = multiSelectState.isMultiDragging && - multiSelectState.selectedAllocationIds.includes(alloc.id); + selectedAllocationSet.has(alloc.id); const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0; const multiDragMode = multiSelectState.multiDragMode; @@ -783,7 +791,7 @@ function renderAllocBlocksFromData( : isOtherDragged ? "opacity-30 z-[10]" : "transition-[opacity] duration-75 z-[10]", - multiSelectState.selectedAllocationIds.includes(alloc.id) && "z-20", + selectedAllocationSet.has(alloc.id) && "z-20", )} style={{ left: segmentLeft + 2, @@ -806,6 +814,23 @@ function renderAllocBlocksFromData( onMouseDown={(e) => { if (e.button === 2) e.stopPropagation(); }} + onDoubleClick={(e) => { + if (suppressHoverInteractions || !onInlineEdit) return; + e.stopPropagation(); + const toUtcDate = (v: Date | string) => { + const d = new Date(v); + return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); + }; + onInlineEdit( + alloc.id, + { + startDate: toUtcDate(alloc.startDate), + endDate: toUtcDate(alloc.endDate), + hoursPerDay: alloc.hoursPerDay, + }, + e.currentTarget.getBoundingClientRect(), + ); + }} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); @@ -855,7 +880,7 @@ function renderAllocBlocksFromData( isBeingDragged ? "shadow-2xl ring-2 ring-white ring-offset-1 scale-[1.01]" : "hover:ring-2 hover:ring-white hover:ring-offset-1", - multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1", + selectedAllocationSet.has(alloc.id) && "ring-2 ring-sky-500 ring-offset-1", )} style={{ backgroundColor: blockBgColor, @@ -1022,6 +1047,7 @@ function renderDailyBars( suppressHoverInteractions: boolean, ) { const BAR_AREA = rowHeight - 8; + const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds); return dates.flatMap((date, i) => { const dayTimestamp = date.getTime(); @@ -1119,7 +1145,7 @@ function renderDailyBars( isBeingDragged ? "opacity-90 ring-2 ring-white ring-offset-1 z-20" : "transition-opacity duration-75 hover:opacity-80 z-[10]", - multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20", + selectedAllocationSet.has(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20", )} style={{ left: i * CELL_WIDTH + 2, @@ -1128,13 +1154,13 @@ function renderDailyBars( bottom, backgroundColor: segBgColor, ...((multiSelectState.isMultiDragging && - multiSelectState.selectedAllocationIds.includes(alloc.id)) || + selectedAllocationSet.has(alloc.id)) || dragPointerOffset ? { transform: [ dragPointerOffset ? `translateX(${dragPointerOffset}px)` : null, multiSelectState.isMultiDragging && - multiSelectState.selectedAllocationIds.includes(alloc.id) + selectedAllocationSet.has(alloc.id) ? `translateX(${multiSelectState.multiDragDaysDelta * CELL_WIDTH}px)` : null, ] diff --git a/apps/web/src/components/timeline/TimelineView.tsx b/apps/web/src/components/timeline/TimelineView.tsx index e6cfe87..e601c8a 100644 --- a/apps/web/src/components/timeline/TimelineView.tsx +++ b/apps/web/src/components/timeline/TimelineView.tsx @@ -39,6 +39,7 @@ import type { TimelineVisualOverrides } from "./allocationVisualState.js"; import { SuccessToast } from "~/components/ui/SuccessToast.js"; import { useTimelineKeyboard } from "~/hooks/useTimelineKeyboard.js"; import { KeyboardShortcutOverlay } from "./KeyboardShortcutOverlay.js"; +import { InlineAllocationEditor } from "./InlineAllocationEditor.js"; // ─── Entry point ──────────────────────────────────────────────────────────── // Two-layer mount: the outer shell creates drag state + project context, @@ -397,6 +398,14 @@ function TimelineViewContent({ }, }); + const [inlineEditTarget, setInlineEditTarget] = useState<{ + allocationId: string; + startDate: Date; + endDate: Date; + hoursPerDay: number; + barRect: DOMRect; + } | null>(null); + const hasActivePointerOverlay = dragState.isDragging || allocDragState.isActive || rangeState.isSelecting || multiSelectState.isMultiDragging; @@ -799,6 +808,7 @@ function TimelineViewContent({ multiSelectState={multiSelectState} optimisticAllocations={optimisticAllocations} suppressHoverInteractions={hasActivePointerOverlay} + {...(!isSelfServiceTimeline ? { onInlineEdit: (id: string, vals: { startDate: Date; endDate: Date; hoursPerDay: number }, rect: DOMRect) => setInlineEditTarget({ allocationId: id, ...vals, barRect: rect }) } : {})} CELL_WIDTH={CELL_WIDTH} dates={dates} totalCanvasWidth={totalCanvasWidth} @@ -1096,6 +1106,19 @@ function TimelineViewContent({ /> )} + {/* Inline allocation editor */} + {inlineEditTarget && ( + setInlineEditTarget(null)} + onSaved={() => setInlineEditTarget(null)} + /> + )} + {/* Keyboard shortcut overlay */} {showShortcuts && setShowShortcuts(false)} />}