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);
|
||||
}
|
||||
Reference in New Issue
Block a user