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:
@@ -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<string | null>(null);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(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 = (
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
style={{ position: "fixed", top, left, zIndex: 60, width: 240 }}
|
||||||
|
className="bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 p-3 space-y-2.5"
|
||||||
|
>
|
||||||
|
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200">Edit Allocation</div>
|
||||||
|
<form onSubmit={handleSave} className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] text-gray-500 dark:text-gray-400 mb-0.5">Start date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] text-gray-500 dark:text-gray-400 mb-0.5">End date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
min={startDate}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] text-gray-500 dark:text-gray-400 mb-0.5">Hours / day</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={hoursPerDay}
|
||||||
|
min={0.5}
|
||||||
|
max={24}
|
||||||
|
step={0.5}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-[11px] text-red-600 dark:text-red-400">{error}</p>}
|
||||||
|
<div className="flex items-center gap-2 pt-0.5">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
className="flex-1 rounded-md bg-brand-600 px-2 py-1.5 text-xs font-semibold text-white hover:bg-brand-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md border border-gray-300 dark:border-gray-600 px-2 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return typeof document === "undefined" ? panel : createPortal(panel, document.body);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
@@ -80,6 +81,7 @@ interface TimelineResourcePanelProps {
|
|||||||
multiSelectState: MultiSelectState;
|
multiSelectState: MultiSelectState;
|
||||||
optimisticAllocations: TimelineVisualOverrides;
|
optimisticAllocations: TimelineVisualOverrides;
|
||||||
suppressHoverInteractions: boolean;
|
suppressHoverInteractions: boolean;
|
||||||
|
onInlineEdit?: (allocationId: string, initialValues: { startDate: Date; endDate: Date; hoursPerDay: number }, barRect: DOMRect) => void;
|
||||||
// Layout from useTimelineLayout
|
// Layout from useTimelineLayout
|
||||||
CELL_WIDTH: number;
|
CELL_WIDTH: number;
|
||||||
dates: Date[];
|
dates: Date[];
|
||||||
@@ -127,6 +129,7 @@ function TimelineResourcePanelInner({
|
|||||||
multiSelectState,
|
multiSelectState,
|
||||||
optimisticAllocations,
|
optimisticAllocations,
|
||||||
suppressHoverInteractions,
|
suppressHoverInteractions,
|
||||||
|
onInlineEdit,
|
||||||
CELL_WIDTH,
|
CELL_WIDTH,
|
||||||
dates,
|
dates,
|
||||||
totalCanvasWidth,
|
totalCanvasWidth,
|
||||||
@@ -195,9 +198,10 @@ function TimelineResourcePanelInner({
|
|||||||
// (virtualizer handles which subset is visible; this memo just pre-computes
|
// (virtualizer handles which subset is visible; this memo just pre-computes
|
||||||
// per-row data that the render loop needs)
|
// per-row data that the render loop needs)
|
||||||
const resourceRows = useMemo(() => {
|
const resourceRows = useMemo(() => {
|
||||||
|
const contextSet = new Set(contextResourceIds);
|
||||||
return resources.map((resource) => {
|
return resources.map((resource) => {
|
||||||
const allocs = visualAllocsByResource.get(resource.id) ?? [];
|
const allocs = visualAllocsByResource.get(resource.id) ?? [];
|
||||||
const isContextResource = contextResourceIds.includes(resource.id);
|
const isContextResource = contextSet.has(resource.id);
|
||||||
return { resource, allocs, isContextResource };
|
return { resource, allocs, isContextResource };
|
||||||
});
|
});
|
||||||
}, [resources, visualAllocsByResource, contextResourceIds]);
|
}, [resources, visualAllocsByResource, contextResourceIds]);
|
||||||
@@ -212,8 +216,9 @@ function TimelineResourcePanelInner({
|
|||||||
toWidth,
|
toWidth,
|
||||||
CELL_WIDTH,
|
CELL_WIDTH,
|
||||||
totalCanvasWidth,
|
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 ──
|
// ─── Memo 3: assignmentBlocks — pre-computed per resource for strip mode ──
|
||||||
@@ -507,6 +512,7 @@ function TimelineResourcePanelInner({
|
|||||||
onAllocationContextMenu,
|
onAllocationContextMenu,
|
||||||
multiSelectState,
|
multiSelectState,
|
||||||
suppressHoverInteractions,
|
suppressHoverInteractions,
|
||||||
|
onInlineEdit,
|
||||||
)}
|
)}
|
||||||
{filters.showVacations &&
|
{filters.showVacations &&
|
||||||
renderVacationBlocks(
|
renderVacationBlocks(
|
||||||
@@ -612,8 +618,10 @@ function renderAllocBlocksFromData(
|
|||||||
) => void,
|
) => void,
|
||||||
multiSelectState: MultiSelectState,
|
multiSelectState: MultiSelectState,
|
||||||
suppressHoverInteractions: boolean,
|
suppressHoverInteractions: boolean,
|
||||||
|
onInlineEdit?: (allocationId: string, initialValues: { startDate: Date; endDate: Date; hoursPerDay: number }, barRect: DOMRect) => void,
|
||||||
) {
|
) {
|
||||||
const anyDragActive = dragState.isDragging || allocDragState.isActive;
|
const anyDragActive = dragState.isDragging || allocDragState.isActive;
|
||||||
|
const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds);
|
||||||
|
|
||||||
function toUtcDay(value: Date): Date {
|
function toUtcDay(value: Date): Date {
|
||||||
return new Date(Date.UTC(value.getFullYear(), value.getMonth(), value.getDate()));
|
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 rawIndex = Math.floor((clientX - rect.left) / CELL_WIDTH);
|
||||||
const maxIndex = Math.max(
|
const maxIndex = Math.max(
|
||||||
0,
|
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);
|
const dayIndex = Math.min(Math.max(rawIndex, 0), maxIndex);
|
||||||
return addUtcDays(start, dayIndex);
|
return addUtcDays(start, dayIndex);
|
||||||
@@ -662,7 +670,7 @@ function renderAllocBlocksFromData(
|
|||||||
// Multi-drag offset: shift selected allocations visually during multi-drag
|
// Multi-drag offset: shift selected allocations visually during multi-drag
|
||||||
const isMultiDragTarget =
|
const isMultiDragTarget =
|
||||||
multiSelectState.isMultiDragging &&
|
multiSelectState.isMultiDragging &&
|
||||||
multiSelectState.selectedAllocationIds.includes(alloc.id);
|
selectedAllocationSet.has(alloc.id);
|
||||||
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
|
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
|
||||||
const multiDragMode = multiSelectState.multiDragMode;
|
const multiDragMode = multiSelectState.multiDragMode;
|
||||||
|
|
||||||
@@ -783,7 +791,7 @@ function renderAllocBlocksFromData(
|
|||||||
: isOtherDragged
|
: isOtherDragged
|
||||||
? "opacity-30 z-[10]"
|
? "opacity-30 z-[10]"
|
||||||
: "transition-[opacity] duration-75 z-[10]",
|
: "transition-[opacity] duration-75 z-[10]",
|
||||||
multiSelectState.selectedAllocationIds.includes(alloc.id) && "z-20",
|
selectedAllocationSet.has(alloc.id) && "z-20",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
left: segmentLeft + 2,
|
left: segmentLeft + 2,
|
||||||
@@ -806,6 +814,23 @@ function renderAllocBlocksFromData(
|
|||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (e.button === 2) e.stopPropagation();
|
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) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -855,7 +880,7 @@ function renderAllocBlocksFromData(
|
|||||||
isBeingDragged
|
isBeingDragged
|
||||||
? "shadow-2xl ring-2 ring-white ring-offset-1 scale-[1.01]"
|
? "shadow-2xl ring-2 ring-white ring-offset-1 scale-[1.01]"
|
||||||
: "hover:ring-2 hover:ring-white hover:ring-offset-1",
|
: "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={{
|
style={{
|
||||||
backgroundColor: blockBgColor,
|
backgroundColor: blockBgColor,
|
||||||
@@ -1022,6 +1047,7 @@ function renderDailyBars(
|
|||||||
suppressHoverInteractions: boolean,
|
suppressHoverInteractions: boolean,
|
||||||
) {
|
) {
|
||||||
const BAR_AREA = rowHeight - 8;
|
const BAR_AREA = rowHeight - 8;
|
||||||
|
const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds);
|
||||||
|
|
||||||
return dates.flatMap((date, i) => {
|
return dates.flatMap((date, i) => {
|
||||||
const dayTimestamp = date.getTime();
|
const dayTimestamp = date.getTime();
|
||||||
@@ -1119,7 +1145,7 @@ function renderDailyBars(
|
|||||||
isBeingDragged
|
isBeingDragged
|
||||||
? "opacity-90 ring-2 ring-white ring-offset-1 z-20"
|
? "opacity-90 ring-2 ring-white ring-offset-1 z-20"
|
||||||
: "transition-opacity duration-75 hover:opacity-80 z-[10]",
|
: "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={{
|
style={{
|
||||||
left: i * CELL_WIDTH + 2,
|
left: i * CELL_WIDTH + 2,
|
||||||
@@ -1128,13 +1154,13 @@ function renderDailyBars(
|
|||||||
bottom,
|
bottom,
|
||||||
backgroundColor: segBgColor,
|
backgroundColor: segBgColor,
|
||||||
...((multiSelectState.isMultiDragging &&
|
...((multiSelectState.isMultiDragging &&
|
||||||
multiSelectState.selectedAllocationIds.includes(alloc.id)) ||
|
selectedAllocationSet.has(alloc.id)) ||
|
||||||
dragPointerOffset
|
dragPointerOffset
|
||||||
? {
|
? {
|
||||||
transform: [
|
transform: [
|
||||||
dragPointerOffset ? `translateX(${dragPointerOffset}px)` : null,
|
dragPointerOffset ? `translateX(${dragPointerOffset}px)` : null,
|
||||||
multiSelectState.isMultiDragging &&
|
multiSelectState.isMultiDragging &&
|
||||||
multiSelectState.selectedAllocationIds.includes(alloc.id)
|
selectedAllocationSet.has(alloc.id)
|
||||||
? `translateX(${multiSelectState.multiDragDaysDelta * CELL_WIDTH}px)`
|
? `translateX(${multiSelectState.multiDragDaysDelta * CELL_WIDTH}px)`
|
||||||
: null,
|
: null,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import type { TimelineVisualOverrides } from "./allocationVisualState.js";
|
|||||||
import { SuccessToast } from "~/components/ui/SuccessToast.js";
|
import { SuccessToast } from "~/components/ui/SuccessToast.js";
|
||||||
import { useTimelineKeyboard } from "~/hooks/useTimelineKeyboard.js";
|
import { useTimelineKeyboard } from "~/hooks/useTimelineKeyboard.js";
|
||||||
import { KeyboardShortcutOverlay } from "./KeyboardShortcutOverlay.js";
|
import { KeyboardShortcutOverlay } from "./KeyboardShortcutOverlay.js";
|
||||||
|
import { InlineAllocationEditor } from "./InlineAllocationEditor.js";
|
||||||
|
|
||||||
// ─── Entry point ────────────────────────────────────────────────────────────
|
// ─── Entry point ────────────────────────────────────────────────────────────
|
||||||
// Two-layer mount: the outer shell creates drag state + project context,
|
// 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 =
|
const hasActivePointerOverlay =
|
||||||
dragState.isDragging || allocDragState.isActive || rangeState.isSelecting || multiSelectState.isMultiDragging;
|
dragState.isDragging || allocDragState.isActive || rangeState.isSelecting || multiSelectState.isMultiDragging;
|
||||||
|
|
||||||
@@ -799,6 +808,7 @@ function TimelineViewContent({
|
|||||||
multiSelectState={multiSelectState}
|
multiSelectState={multiSelectState}
|
||||||
optimisticAllocations={optimisticAllocations}
|
optimisticAllocations={optimisticAllocations}
|
||||||
suppressHoverInteractions={hasActivePointerOverlay}
|
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}
|
CELL_WIDTH={CELL_WIDTH}
|
||||||
dates={dates}
|
dates={dates}
|
||||||
totalCanvasWidth={totalCanvasWidth}
|
totalCanvasWidth={totalCanvasWidth}
|
||||||
@@ -1096,6 +1106,19 @@ function TimelineViewContent({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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 */}
|
{/* Keyboard shortcut overlay */}
|
||||||
{showShortcuts && <KeyboardShortcutOverlay onClose={() => setShowShortcuts(false)} />}
|
{showShortcuts && <KeyboardShortcutOverlay onClose={() => setShowShortcuts(false)} />}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user