Files
Nexus/apps/web/src/components/timeline/BatchAssignPopover.tsx
T

219 lines
6.9 KiB
TypeScript

"use client";
import { clsx } from "clsx";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { AllocationStatus } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
const ENTITY_COMBOBOX_OVERLAY_SELECTOR = "[data-entity-combobox-overlay='true']";
interface BatchAssignPopoverProps {
resourceIds: string[];
startDate: Date;
endDate: Date;
onClose: () => void;
onCreated: () => void;
}
function toDateDisplay(d: Date): string {
return d.toLocaleDateString("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
});
}
export function BatchAssignPopover({
resourceIds,
startDate,
endDate,
onClose,
onCreated,
}: BatchAssignPopoverProps) {
const ref = useRef<HTMLDivElement>(null);
const invalidateTimeline = useInvalidateTimeline();
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null,
);
const [hoursPerDay, setHoursPerDay] = useState(8);
const batchMutation = trpc.timeline.batchQuickAssign.useMutation({
onSuccess: () => {
invalidateTimeline();
onCreated();
onClose();
},
});
// Close on outside click
useEffect(() => {
function handlePointerDown(event: PointerEvent) {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (ref.current?.contains(target)) {
return;
}
if (target instanceof Element && target.closest(ENTITY_COMBOBOX_OVERLAY_SELECTOR)) {
return;
}
if (ref.current) {
onClose();
}
}
document.addEventListener("pointerdown", handlePointerDown, true);
return () => document.removeEventListener("pointerdown", handlePointerDown, true);
}, [onClose]);
// Close on ESC
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [onClose]);
function handleAssign() {
if (!selectedProjectId) return;
batchMutation.mutate({
assignments: resourceIds.map((resourceId) => ({
resourceId,
projectId: selectedProjectId,
startDate,
endDate,
hoursPerDay,
role: "Team Member",
status: AllocationStatus.PROPOSED,
})),
});
}
const canAssign =
!!selectedProjectId && resourceIds.length > 0 && hoursPerDay > 0;
const popover = (
<div
ref={ref}
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[9998] w-[360px] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-2xl dark:shadow-black/40 overflow-hidden"
>
{/* 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">
Batch Assign
</span>
<button
type="button"
onClick={onClose}
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="p-4 space-y-3">
{/* Info line */}
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-0.5">
<p>
Assigning to{" "}
<span className="font-medium text-gray-800 dark:text-gray-200">
{resourceIds.length}
</span>{" "}
resource{resourceIds.length !== 1 ? "s" : ""}
</p>
<p>
{toDateDisplay(startDate)} &ndash; {toDateDisplay(endDate)}
</p>
</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>
{/* 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-sky-400 dark:focus:ring-sky-500"
/>
<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-sky-600 text-white border-sky-600 dark:bg-sky-600 dark:border-sky-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>
{/* Error */}
{batchMutation.isError && (
<p className="text-xs text-red-600 dark:text-red-400">
{batchMutation.error.message}
</p>
)}
{/* Actions */}
<div className="flex gap-2 pt-1">
<button
type="button"
onClick={handleAssign}
disabled={!canAssign || batchMutation.isPending}
className={clsx(
"flex-1 py-2 rounded-lg text-sm font-medium transition-colors",
"bg-sky-600 text-white hover:bg-sky-700 dark:bg-sky-600 dark:hover:bg-sky-700",
"disabled:opacity-40 disabled:cursor-not-allowed",
)}
>
{batchMutation.isPending
? "Assigning\u2026"
: `Assign All (${resourceIds.length})`}
</button>
<button
type="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);
}