feat: timeline multi-select, demand popover, resource hover card, merged tooltips, dark mode fixes

Major timeline enhancements:
- Right-click drag multi-selection with floating action bar (batch delete/assign)
- DemandPopover for demand strip details (replaces broken "Loading" modal)
- ResourceHoverCard on name hover showing skills, rates, role, chapter
- Merged heatmap+vacation tooltips into unified TimelineTooltip component
- Fixed overbooking blink animation (date normalization, z-index ordering)
- Fixed dark mode sticky column bleed-through in project view
- System roles admin page, notification task management, performance review docs

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-18 23:43:51 +01:00
parent d0f04f13f8
commit ddec3a927a
67 changed files with 4930 additions and 1166 deletions
@@ -0,0 +1,259 @@
"use client";
import { clsx } from "clsx";
import { useEffect, useRef, useState } from "react";
import { AllocationStatus } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
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 utils = trpc.useUtils();
const [search, setSearch] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null,
);
const [hoursPerDay, setHoursPerDay] = useState(8);
const [dropdownOpen, setDropdownOpen] = useState(true);
const { data: projectsData } = trpc.project.list.useQuery(
{ search, limit: 20 },
{ staleTime: 30_000 },
);
const projects = (projectsData?.projects ?? []) as Array<{
id: string;
name: string;
}>;
const selectedProject = projects.find((p) => p.id === selectedProjectId);
const batchMutation = trpc.timeline.batchQuickAssign.useMutation({
onSuccess: () => {
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
onCreated();
onClose();
},
});
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [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;
return (
<div
ref={ref}
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[60] 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>
{selectedProject && !dropdownOpen ? (
<div
className="flex items-center gap-2 border border-sky-300 dark:border-sky-700 rounded-lg px-3 py-2 cursor-pointer bg-sky-50 dark:bg-sky-950/30"
onClick={() => {
setDropdownOpen(true);
setSearch("");
}}
>
<span className="text-sm text-gray-800 dark:text-gray-200 truncate flex-1">
{selectedProject.name}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">
&#9662;
</span>
</div>
) : (
<div className="relative">
<input
autoFocus
type="text"
placeholder="Search projects\u2026"
value={search}
onChange={(e) => setSearch(e.target.value)}
onFocus={() => setDropdownOpen(true)}
className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-400 dark:focus:ring-sky-500"
/>
{dropdownOpen && projects.length > 0 && (
<div className="absolute top-full left-0 right-0 z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg dark:shadow-black/40 mt-1 max-h-44 overflow-y-auto">
{projects.map((p) => (
<button
key={p.id}
type="button"
onClick={() => {
setSelectedProjectId(p.id);
setDropdownOpen(false);
setSearch("");
}}
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-2 border-b border-gray-50 dark:border-gray-700 last:border-0"
>
<span className="text-sm text-gray-800 dark:text-gray-200 truncate">
{p.name}
</span>
</button>
))}
</div>
)}
</div>
)}
</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>
);
}