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:
2026-04-09 13:24:43 +02:00
parent fa54ef4cbd
commit e75d966b8d
3 changed files with 209 additions and 9 deletions
@@ -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);
}