Files
CapaKraken/apps/web/src/components/timeline/NewAllocationPopover.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

228 lines
7.9 KiB
TypeScript

"use client";
import { clsx } from "clsx";
import type { RefObject } from "react";
import { useState } from "react";
import { createPortal } from "react-dom";
import { AllocationStatus } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
interface NewAllocationPopoverProps {
resourceId: string;
startDate: Date;
endDate: Date;
/** Pre-selected project (from project-view sub-row context) */
suggestedProjectId?: string | null;
anchorX: number;
anchorY: number;
onClose: () => void;
onCreated: () => void;
ignoreScrollContainers?: RefObject<HTMLElement | null>[];
}
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}`;
}
export function NewAllocationPopover({
resourceId,
startDate,
endDate,
suggestedProjectId,
anchorX,
anchorY,
onClose,
onCreated,
ignoreScrollContainers,
}: NewAllocationPopoverProps) {
const { ref, style } = useViewportPopover({
anchor: { kind: "point", x: anchorX - 10, y: anchorY },
width: 320,
estimatedHeight: 440,
onClose,
ignoreSelectors: ["[data-entity-combobox-overlay='true']"],
...(ignoreScrollContainers ? { ignoreScrollContainers } : {}),
});
const invalidatePlanningViews = useInvalidatePlanningViews();
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
suggestedProjectId ?? null,
);
const [role, setRole] = useState("Team Member");
const [hoursPerDay, setHoursPerDay] = useState(8);
const [start, setStart] = useState(toDateInput(startDate));
const [end, setEnd] = useState(toDateInput(endDate));
const createMutation = trpc.timeline.quickAssign.useMutation({
onSuccess: () => {
void invalidatePlanningViews();
onCreated();
onClose();
},
});
function handleCreate() {
if (!selectedProjectId) return;
createMutation.mutate({
resourceId,
projectId: selectedProjectId,
startDate: new Date(start),
endDate: new Date(end),
hoursPerDay,
role,
status: AllocationStatus.PROPOSED,
});
}
const canCreate = !!selectedProjectId && !!start && !!end && hoursPerDay > 0;
const popover = (
<div
ref={ref}
style={style}
className="flex max-h-[calc(100vh-32px)] flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-800 dark:shadow-black/40"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">
Assign to Project
</span>
<button
onClick={onClose}
aria-label="Close"
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 text-lg leading-none"
>
&times;
</button>
</div>
<div className="space-y-3 overflow-y-auto p-4">
{/* Date range */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Start
</label>
<DateInput
value={start}
onChange={setStart}
className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-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 dark:text-gray-400 mb-1">
End
</label>
<DateInput
value={end}
onChange={setEnd}
min={start}
className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
</div>
{/* Project picker */}
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Project
</label>
<ProjectCombobox
value={selectedProjectId}
onChange={setSelectedProjectId}
placeholder="Search project…"
className="w-full"
/>
</div>
{/* Role */}
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Role
</label>
<input
type="text"
value={role}
onChange={(e) => setRole(e.target.value)}
className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-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 dark:text-gray-400 mb-1">
Hours / day
</label>
<div className="flex items-center gap-2">
<input
type="number"
min={0.5}
max={24}
step={0.5}
value={hoursPerDay}
onChange={(e) => setHoursPerDay(parseFloat(e.target.value))}
className="w-24 border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
<div className="flex gap-1">
{[4, 6, 8].map((h) => (
<button
key={h}
type="button"
onClick={() => setHoursPerDay(h)}
className={clsx(
"px-2 py-1 rounded text-xs font-medium border transition-colors",
hoursPerDay === h
? "bg-brand-600 text-white border-brand-600"
: "border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700",
)}
>
{h}h
</button>
))}
</div>
</div>
</div>
{/* Overbooking notice */}
<p className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 px-3 py-2 rounded-lg">
Overlapping allocations are allowed resource may be overbooked.
</p>
{/* Error */}
{createMutation.isError && (
<p className="text-xs text-red-600 dark:text-red-400">{createMutation.error.message}</p>
)}
{/* Actions */}
<div className="flex gap-2 pt-1">
<button
onClick={handleCreate}
disabled={!canCreate || createMutation.isPending}
className={clsx(
"flex-1 py-2 rounded-lg text-sm font-medium transition-colors",
"bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-40 disabled:cursor-not-allowed",
)}
>
{createMutation.isPending ? "Creating…" : "Assign"}
</button>
<button
onClick={onClose}
className="flex-1 py-2 rounded-lg text-sm font-medium border border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
</div>
</div>
</div>
);
return typeof document === "undefined" ? popover : createPortal(popover, document.body);
}