f3fa902773
- useInvalidateTimeline and useInvalidatePlanningViews now return Promise.all instead of fire-and-forget void calls - Timeline mutations now use useInvalidatePlanningViews to also invalidate allocation list views, preventing stale data - AllocationsClient sequential awaits replaced with single invalidatePlanningViews() call (parallel invalidation) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
158 lines
5.7 KiB
TypeScript
158 lines
5.7 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { trpc } from "~/lib/trpc/client.js";
|
|
import { useInvalidatePlanningViews } 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 invalidatePlanningViews = useInvalidatePlanningViews();
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const updateMutation = (trpc.allocation.update.useMutation as any)({
|
|
onSuccess: () => {
|
|
void invalidatePlanningViews();
|
|
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);
|
|
}
|