Files
CapaKraken/apps/web/src/components/timeline/AllocationPopover.tsx
T
Hartmut e08ee94546 fix(web): accessibility pass — add aria-labels, dialog roles, and pressed states
- KeyboardShortcutOverlay: add role="dialog", aria-modal, aria-labelledby, close button aria-label
- Timeline popovers (5 files): add aria-label="Close" to symbol-only close buttons
- TimelineToolbar: add aria-label to navigation and undo/redo icon buttons
- ComputationGraphClient: add aria-pressed to 2D/3D and view mode toggle buttons
- BulkEditModal: fix type mismatch from jsonb field hardening

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 23:27:56 +02:00

390 lines
14 KiB
TypeScript

"use client";
import React, { type RefObject } from "react";
import { clsx } from "clsx";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import type { AllocationLike, Assignment } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { DateInput } from "~/components/ui/DateInput.js";
interface AllocationPopoverProps {
allocationId: string;
projectId: string;
initialAllocation?: AllocationPopoverAssignment | null;
onClose: () => void;
onOpenPanel: (projectId: string) => void;
/** Pixel position relative to the viewport */
anchorX: number;
anchorY: number;
contextDate?: Date;
ignoreScrollContainers?: RefObject<HTMLElement | null>[];
}
type AllocationPopoverAssignment = Assignment<AllocationLike>;
export function AllocationPopover({
allocationId,
projectId,
initialAllocation = null,
onClose,
onOpenPanel,
anchorX,
anchorY,
contextDate,
ignoreScrollContainers,
}: AllocationPopoverProps) {
const utils = trpc.useUtils();
const invalidatePlanningViews = useInvalidatePlanningViews();
const { ref, style } = useViewportPopover({
anchor: { kind: "point", x: anchorX, y: anchorY },
width: 300,
estimatedHeight: 360,
onClose,
...(ignoreScrollContainers ? { ignoreScrollContainers } : {}),
});
const shouldLoadAllocation = !initialAllocation;
const allocationQuery = trpc.allocation.getAssignmentById.useQuery(
{ id: allocationId },
{
staleTime: 10_000,
enabled: shouldLoadAllocation,
retry: false,
},
);
const fetchedAllocation = allocationQuery.data as AllocationPopoverAssignment | undefined;
const allocation = initialAllocation ?? fetchedAllocation;
const isLoading = shouldLoadAllocation && allocationQuery.isLoading;
const allocationError = shouldLoadAllocation ? allocationQuery.error : null;
const [hoursPerDay, setHoursPerDay] = useState<number | null>(null);
const [startDate, setStartDate] = useState<string>("");
const [endDate, setEndDate] = useState<string>("");
const [includeSaturday, setIncludeSaturday] = useState(false);
const [role, setRole] = useState("");
const [carveStartDate, setCarveStartDate] = useState("");
const [carveEndDate, setCarveEndDate] = useState("");
useEffect(() => {
if (allocation) {
setHoursPerDay(allocation.hoursPerDay);
setStartDate(toDateInput(new Date(allocation.startDate)));
setEndDate(toDateInput(new Date(allocation.endDate)));
const meta = allocation.metadata as { includeSaturday?: boolean } | null;
setIncludeSaturday(meta?.includeSaturday ?? false);
setRole(allocation.role ?? "");
const defaultCarveDate = contextDate ? toDateInput(contextDate) : "";
setCarveStartDate(defaultCarveDate);
setCarveEndDate(defaultCarveDate);
}
}, [allocation, contextDate]);
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
onSuccess: () => {
void invalidatePlanningViews();
void utils.allocation.getAssignmentById.invalidate({ id: allocationId });
onClose();
},
});
const carveMutation = trpc.timeline.carveAllocationRange.useMutation({
onSuccess: () => {
void invalidatePlanningViews();
void utils.allocation.getAssignmentById.invalidate({ id: allocationId });
onClose();
},
});
function toDateInput(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function handleSave() {
if (!allocation || hoursPerDay === null) return;
updateMutation.mutate({
allocationId: getPlanningEntryMutationId(allocation),
hoursPerDay,
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined,
includeSaturday,
role: role || undefined,
});
}
function handleCarveRange() {
if (!allocation || !carveStartDate || !carveEndDate) return;
carveMutation.mutate({
allocationId: getPlanningEntryMutationId(allocation),
startDate: new Date(carveStartDate),
endDate: new Date(carveEndDate),
});
}
if (isLoading) {
const loadingPopover = (
<div
ref={ref}
style={style}
data-testid="timeline-allocation-popover-loading"
className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500"
>
Loading...
</div>
);
return typeof document === "undefined"
? loadingPopover
: createPortal(loadingPopover, document.body);
}
if (allocationError) {
const errorPopover = (
<div
ref={ref}
style={style}
data-testid="timeline-allocation-popover-error"
className="flex max-w-[300px] flex-col gap-3 rounded-xl border border-red-200 bg-white p-4 shadow-xl"
>
<div className="text-sm font-medium text-gray-800">Allocation unavailable</div>
<p className="text-xs text-gray-500">The selected booking could not be loaded right now.</p>
<p className="text-xs text-red-600">{allocationError.message}</p>
<button
onClick={() => {
onClose();
onOpenPanel(projectId);
}}
className="w-full rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-700"
>
Open Project Panel
</button>
</div>
);
return typeof document === "undefined"
? errorPopover
: createPortal(errorPopover, document.body);
}
if (!allocation) {
const missingPopover = (
<div
ref={ref}
style={style}
data-testid="timeline-allocation-popover-unavailable"
className="flex max-w-[300px] flex-col gap-3 rounded-xl border border-gray-200 bg-white p-4 shadow-xl"
>
<div className="text-sm font-medium text-gray-800">Allocation unavailable</div>
<p className="text-xs text-gray-500">
The selected booking could not be resolved from the current timeline data.
</p>
<button
onClick={() => {
onClose();
onOpenPanel(projectId);
}}
className="w-full rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-700"
>
Open Project Panel
</button>
</div>
);
return typeof document === "undefined"
? missingPopover
: createPortal(missingPopover, document.body);
}
const dailyCostEUR = (
((hoursPerDay ?? allocation.hoursPerDay) * (allocation.resource?.lcrCents ?? 0)) /
100
).toFixed(2);
const carveDateRangeInvalid =
Boolean(carveStartDate && carveEndDate) && carveEndDate < carveStartDate;
const popover = (
<div
ref={ref}
style={style}
data-testid="timeline-allocation-popover"
data-allocation-id={allocationId}
className="flex max-h-[calc(100vh-32px)] flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-100">
<div>
<span className="text-sm font-semibold text-gray-800">{role}</span>
</div>
<button
onClick={onClose}
aria-label="Close"
className="text-gray-400 hover:text-gray-600 text-lg leading-none"
>
&times;
</button>
</div>
<div className="space-y-3 overflow-y-auto p-4">
{/* Resource */}
<div className="text-xs text-gray-500">
Resource:{" "}
<span className="font-medium text-gray-700">{allocation.resource?.displayName}</span> ·{" "}
<span className="text-gray-400">{allocation.resource?.eid}</span>
</div>
{/* Role */}
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Role</label>
<input
type="text"
value={role}
onChange={(e) => setRole(e.target.value)}
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
{/* Hours per day */}
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Hours / day</label>
<input
type="number"
min={0.5}
max={24}
step={0.5}
value={hoursPerDay ?? ""}
onChange={(e) => setHoursPerDay(parseFloat(e.target.value))}
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
{/* Date range */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Start</label>
<DateInput
value={startDate}
onChange={setStartDate}
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">End</label>
<DateInput
value={endDate}
onChange={setEndDate}
min={startDate}
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
</div>
{/* Include Saturday */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeSaturday}
onChange={(e) => setIncludeSaturday(e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-400"
/>
<span className="text-xs text-gray-700">Include Saturdays</span>
</label>
{/* Error */}
{updateMutation.isError && (
<p className="text-xs text-red-600">{updateMutation.error.message}</p>
)}
{carveMutation.isError && (
<p className="text-xs text-red-600">{carveMutation.error.message}</p>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
<button
onClick={handleSave}
disabled={updateMutation.isPending}
className={clsx(
"flex-1 py-1.5 rounded-lg text-sm font-medium transition-colors",
"bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50",
)}
>
{updateMutation.isPending ? "Saving…" : "Save"}
</button>
<button
onClick={onClose}
className="flex-1 py-1.5 rounded-lg text-sm font-medium border border-gray-200 text-gray-600 hover:bg-gray-50"
>
Cancel
</button>
</div>
<div className="border-t border-gray-100 pt-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-xs font-medium text-gray-700">Remove Date Range</div>
<div className="text-[11px] text-gray-500">
{contextDate
? `Prefilled from ${toDateInput(contextDate)}`
: "Create a gap or split this booking."}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">From</label>
<DateInput
value={carveStartDate}
onChange={setCarveStartDate}
min={startDate}
max={endDate}
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-red-300"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">To</label>
<DateInput
value={carveEndDate}
onChange={setCarveEndDate}
min={carveStartDate || startDate}
max={endDate}
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-red-300"
/>
</div>
</div>
{carveDateRangeInvalid && (
<p className="text-xs text-red-600">End date must be on or after the start date.</p>
)}
<button
onClick={handleCarveRange}
disabled={
carveMutation.isPending || !carveStartDate || !carveEndDate || carveDateRangeInvalid
}
className="w-full py-1.5 rounded-lg text-sm font-medium transition-colors bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
>
{carveMutation.isPending ? "Removing…" : "Remove Selected Range"}
</button>
</div>
{/* Link to full panel */}
<button
onClick={() => {
onClose();
onOpenPanel(projectId);
}}
className="w-full text-xs text-brand-600 hover:text-brand-800 text-center pt-1"
>
Open Project Panel
</button>
</div>
</div>
);
return typeof document === "undefined" ? popover : createPortal(popover, document.body);
}