feat(timeline): add inline allocation editor on double-click
Double-clicking an allocation bar opens an inline editor overlay with start date, end date, and hours/day fields. Saves via trpc.allocation.update, closes on Escape or click outside. Only visible to users with manage permissions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user