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
@@ -76,7 +76,10 @@ export function AllocationPopover({
}, [onClose]);
function toDateInput(d: Date): string {
return d.toISOString().split("T")[0] ?? "";
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() {
@@ -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>
);
}
@@ -0,0 +1,187 @@
"use client";
import { useEffect, useRef } from "react";
import type { TimelineDemandEntry } from "./TimelineContext.js";
import { formatDateLong } from "~/lib/format.js";
interface DemandPopoverProps {
demand: TimelineDemandEntry;
onClose: () => void;
onOpenPanel: (projectId: string) => void;
onFillDemand: (demand: TimelineDemandEntry) => void;
anchorX: number;
anchorY: number;
}
export function DemandPopover({
demand,
onClose,
onOpenPanel,
onFillDemand,
anchorX,
anchorY,
}: DemandPopoverProps) {
const ref = useRef<HTMLDivElement>(null);
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]);
const roleName = demand.roleEntity?.name ?? demand.role ?? "Unspecified";
const roleColor = demand.roleEntity?.color ?? "#f59e0b";
const startDate = new Date(demand.startDate);
const endDate = new Date(demand.endDate);
const days = Math.max(1, Math.round((endDate.getTime() - startDate.getTime()) / 86_400_000) + 1);
const totalHours = demand.hoursPerDay * days;
const budgetCents = demand.dailyCostCents * days;
const popoverStyle: React.CSSProperties = {
position: "fixed",
left: Math.min(anchorX, window.innerWidth - 320),
top: Math.min(anchorY + 8, window.innerHeight - 340),
zIndex: 50,
width: 300,
};
return (
<div
ref={ref}
style={popoverStyle}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden"
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-700"
style={{ backgroundColor: `${roleColor}18` }}
>
<div className="flex items-center gap-2 min-w-0">
<div
className="w-3 h-3 rounded-full flex-shrink-0 border-2 border-dashed"
style={{ borderColor: roleColor, backgroundColor: `${roleColor}33` }}
/>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">
{roleName}
</span>
</div>
<button
onClick={onClose}
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 text-lg leading-none ml-2"
>
&times;
</button>
</div>
<div className="p-4 space-y-3">
{/* Project */}
<div className="text-xs text-gray-500 dark:text-gray-400">
Project:{" "}
<span className="font-medium text-gray-700 dark:text-gray-200">
{demand.project.name}
</span>
{" "}
<span className="text-gray-400 dark:text-gray-500">({demand.project.shortCode})</span>
</div>
{/* Status badge */}
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400 border border-dashed border-amber-300 dark:border-amber-700">
Open Demand
</span>
<span className="text-[11px] text-gray-400 dark:text-gray-500">
{demand.status}
</span>
</div>
{/* Headcount */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Requested</div>
<div className="font-medium text-gray-800 dark:text-gray-200">
{demand.requestedHeadcount} {demand.requestedHeadcount === 1 ? "person" : "people"}
</div>
</div>
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Unfilled</div>
<div className="font-medium text-amber-600 dark:text-amber-400">
{demand.unfilledHeadcount} remaining
</div>
</div>
{/* Date range */}
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Start</div>
<div className="font-medium text-gray-800 dark:text-gray-200">
{formatDateLong(startDate)}
</div>
</div>
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">End</div>
<div className="font-medium text-gray-800 dark:text-gray-200">
{formatDateLong(endDate)}
</div>
</div>
{/* Hours */}
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Hours / day</div>
<div className="font-medium text-gray-800 dark:text-gray-200">{demand.hoursPerDay}h</div>
</div>
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Total hours</div>
<div className="font-medium text-gray-800 dark:text-gray-200">{totalHours}h ({days}d)</div>
</div>
{/* Budget */}
{budgetCents > 0 && (
<>
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Daily cost</div>
<div className="font-medium text-gray-800 dark:text-gray-200">
{(demand.dailyCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR
</div>
</div>
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Total cost</div>
<div className="font-medium text-gray-800 dark:text-gray-200">
{(budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR
</div>
</div>
</>
)}
{/* Percentage */}
{demand.percentage > 0 && (
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Percentage</div>
<div className="font-medium text-gray-800 dark:text-gray-200">{demand.percentage}%</div>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-2 border-t border-gray-100 dark:border-gray-700">
{demand.unfilledHeadcount > 0 && (
<button
onClick={() => { onClose(); onFillDemand(demand); }}
className="flex-1 py-1.5 rounded-lg text-sm font-medium bg-amber-500 text-white hover:bg-amber-600 transition-colors"
>
Fill Demand
</button>
)}
<button
onClick={() => { onClose(); onOpenPanel(demand.projectId); }}
className="flex-1 py-1.5 rounded-lg text-sm font-medium border border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Open Project
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,88 @@
"use client";
import { clsx } from "clsx";
interface FloatingActionBarProps {
selectedAllocationCount: number;
selectedResourceCount: number;
onDelete: () => void;
onAssign: () => void;
onClear: () => void;
isDeleting: boolean;
}
export function FloatingActionBar({
selectedAllocationCount,
selectedResourceCount,
onDelete,
onAssign,
onClear,
isDeleting,
}: FloatingActionBarProps) {
const totalCount = selectedAllocationCount + selectedResourceCount;
if (totalCount === 0) return null;
const label =
selectedAllocationCount > 0 && selectedResourceCount > 0
? `${selectedAllocationCount} allocation${selectedAllocationCount !== 1 ? "s" : ""} + ${selectedResourceCount} resource${selectedResourceCount !== 1 ? "s" : ""} selected`
: selectedAllocationCount > 0
? `${selectedAllocationCount} allocation${selectedAllocationCount !== 1 ? "s" : ""} selected`
: `${selectedResourceCount} resource${selectedResourceCount !== 1 ? "s" : ""} selected`;
return (
<div
className={clsx(
"fixed bottom-6 left-1/2 -translate-x-1/2 z-50",
"flex items-center gap-3 rounded-full px-5 py-2.5",
"bg-white dark:bg-gray-800",
"border border-gray-200 dark:border-gray-700",
"shadow-xl dark:shadow-black/40",
)}
>
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
{label}
</span>
{selectedAllocationCount > 0 && (
<button
type="button"
onClick={onDelete}
disabled={isDeleting}
className={clsx(
"text-xs font-medium px-3 py-1.5 rounded-full transition-colors",
"bg-red-600 hover:bg-red-700 text-white",
"disabled:opacity-40 disabled:cursor-not-allowed",
)}
>
{isDeleting ? "Deleting\u2026" : "Delete"}
</button>
)}
{selectedResourceCount > 0 && (
<button
type="button"
onClick={onAssign}
className={clsx(
"text-xs font-medium px-3 py-1.5 rounded-full transition-colors",
"bg-sky-600 hover:bg-sky-700 dark:bg-sky-600 dark:hover:bg-sky-700 text-white",
)}
>
Assign
</button>
)}
<button
type="button"
onClick={onClear}
className={clsx(
"text-xs font-medium px-2 py-1.5 transition-colors",
"text-gray-500 dark:text-gray-400",
"hover:text-gray-700 dark:hover:text-gray-300",
)}
>
Clear{" "}
<span className="text-gray-400 dark:text-gray-500">(ESC)</span>
</button>
</div>
);
}
@@ -19,7 +19,10 @@ interface NewAllocationPopoverProps {
}
function toDateInput(d: Date): string {
return d.toISOString().split("T")[0] ?? "";
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({
@@ -50,7 +53,8 @@ export function NewAllocationPopover({
{ staleTime: 30_000 },
);
const projects = projectsData?.projects ?? [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const projects = (projectsData?.projects ?? []) as Array<{ id: string; name: string; orderType?: string }>;
const selectedProject = projects.find((p) => p.id === selectedProjectId)
?? (suggestedProjectId ? projects.find((p) => p.id === suggestedProjectId) : null);
@@ -94,57 +98,50 @@ export function NewAllocationPopover({
const left = Math.min(anchorX - 10, typeof window !== "undefined" ? window.innerWidth - 340 : anchorX);
const top = Math.min(anchorY + 8, typeof window !== "undefined" ? window.innerHeight - 440 : anchorY);
const ORDER_COLORS: Record<string, string> = {
CHARGEABLE: "bg-emerald-100 text-emerald-700",
INTERNAL: "bg-blue-100 text-blue-700",
BD: "bg-violet-100 text-violet-700",
OVERHEAD: "bg-gray-100 text-gray-600",
};
return (
<div
ref={ref}
style={{ position: "fixed", left, top, zIndex: 60, width: 320 }}
className="bg-white border border-gray-200 rounded-xl shadow-2xl overflow-hidden"
className="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 border-b border-gray-100">
<span className="text-sm font-semibold text-gray-700">Assign to Project</span>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">&times;</button>
<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} 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">
{/* 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>
<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 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
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 mb-1">End</label>
<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 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
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 mb-1">Project</label>
<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-brand-300 rounded-lg px-3 py-2 cursor-pointer bg-brand-50"
className="flex items-center gap-2 border border-brand-300 dark:border-sky-700 rounded-lg px-3 py-2 cursor-pointer bg-brand-50 dark:bg-sky-950/30"
onClick={() => { setDropdownOpen(true); setSearch(""); }}
>
<span className="text-sm text-gray-800 truncate flex-1">{selectedProject.name}</span>
<span className="text-xs text-gray-400"></span>
<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"></span>
</div>
) : (
<div className="relative">
@@ -155,18 +152,18 @@ export function NewAllocationPopover({
value={search}
onChange={(e) => setSearch(e.target.value)}
onFocus={() => setDropdownOpen(true)}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
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-brand-400"
/>
{dropdownOpen && projects.length > 0 && (
<div className="absolute top-full left-0 right-0 z-50 bg-white border border-gray-200 rounded-xl shadow-lg mt-1 max-h-44 overflow-y-auto">
<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 flex items-center gap-2 border-b border-gray-50 last:border-0"
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 truncate">{p.name}</span>
<span className="text-sm text-gray-800 dark:text-gray-200 truncate">{p.name}</span>
</button>
))}
</div>
@@ -177,18 +174,18 @@ export function NewAllocationPopover({
{/* Role */}
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Role</label>
<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 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
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 mb-1">Hours / day</label>
<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"
@@ -197,7 +194,7 @@ export function NewAllocationPopover({
step={0.5}
value={hoursPerDay}
onChange={(e) => setHoursPerDay(parseFloat(e.target.value))}
className="w-24 border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
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) => (
@@ -209,7 +206,7 @@ export function NewAllocationPopover({
"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 text-gray-600 hover:bg-gray-50",
: "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
@@ -220,13 +217,13 @@ export function NewAllocationPopover({
</div>
{/* Overbooking notice */}
<p className="text-xs text-amber-600 bg-amber-50 px-3 py-2 rounded-lg">
<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">{createMutation.error.message}</p>
<p className="text-xs text-red-600 dark:text-red-400">{createMutation.error.message}</p>
)}
{/* Actions */}
@@ -243,7 +240,7 @@ export function NewAllocationPopover({
</button>
<button
onClick={onClose}
className="flex-1 py-2 rounded-lg text-sm font-medium border border-gray-200 text-gray-600 hover:bg-gray-50"
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>
@@ -1,7 +1,7 @@
"use client";
import { clsx } from "clsx";
import { useState } from "react";
import { useEffect, useState } from "react";
import { AllocationStatus, type StaffingRequirement } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
@@ -77,7 +77,11 @@ const STATUS_COLORS = {
};
function toDateInput(d: Date | string): string {
return new Date(d).toISOString().split("T")[0] ?? "";
const dt = new Date(d);
const y = dt.getFullYear();
const m = String(dt.getMonth() + 1).padStart(2, "0");
const day = String(dt.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function normalizeRole(value: string | null | undefined): string {
@@ -518,6 +522,17 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
}
function PanelShell({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
return (
<div className="fixed inset-y-0 right-0 w-[420px] bg-white border-l border-gray-200 shadow-2xl z-40 flex flex-col">
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100">
@@ -0,0 +1,184 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import type { SkillEntry } from "@planarchy/shared";
interface ResourceHoverCardProps {
resourceId: string;
anchorEl: HTMLElement;
onClose: () => void;
}
export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHoverCardProps) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ left: 0, top: 0 });
const { data, isLoading } = trpc.resource.getHoverCard.useQuery(
{ id: resourceId },
{ staleTime: 60_000 },
);
// Position relative to anchor element
useEffect(() => {
const rect = anchorEl.getBoundingClientRect();
setPos({
left: rect.right + 8,
top: Math.min(rect.top, window.innerHeight - 320),
});
}, [anchorEl]);
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node) && !anchorEl.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [onClose, anchorEl]);
const skills = (data?.skills ?? []) as unknown as SkillEntry[];
const mainSkills = skills.filter((s) => s.isMainSkill);
const topSkills = skills
.filter((s) => !s.isMainSkill && s.proficiency >= 4)
.sort((a, b) => b.proficiency - a.proficiency)
.slice(0, 6);
const popoverStyle: React.CSSProperties = {
position: "fixed",
left: Math.min(pos.left, window.innerWidth - 300),
top: pos.top,
zIndex: 50,
width: 280,
};
return (
<div
ref={ref}
data-resource-hover-card="true"
style={popoverStyle}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden"
onMouseLeave={onClose}
>
{isLoading || !data ? (
<div className="p-4 text-xs text-gray-400 dark:text-gray-500">Loading...</div>
) : (
<>
{/* Header */}
<div className="px-4 py-3 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-750">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/40 flex items-center justify-center text-xs font-bold text-brand-700 dark:text-brand-300 flex-shrink-0">
{data.displayName.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">
{data.displayName}
</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">{data.eid}</div>
</div>
</div>
</div>
<div className="p-3 space-y-2.5 text-xs">
{/* Role & Chapter */}
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
{data.areaRole && (
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Role</div>
<div className="font-medium text-gray-700 dark:text-gray-200 flex items-center gap-1">
{data.areaRole.color && (
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: data.areaRole.color }} />
)}
{data.areaRole.name}
</div>
</div>
)}
{data.chapter && (
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Chapter</div>
<div className="font-medium text-gray-700 dark:text-gray-200">{data.chapter}</div>
</div>
)}
{data.country && (
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Location</div>
<div className="font-medium text-gray-700 dark:text-gray-200">{data.country.name}</div>
</div>
)}
{data.managementLevel && (
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Level</div>
<div className="font-medium text-gray-700 dark:text-gray-200">{data.managementLevel.name}</div>
</div>
)}
</div>
{/* Rates */}
<div className="flex items-center gap-3 py-1.5 px-2 rounded-lg bg-gray-50 dark:bg-gray-750">
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">LCR</div>
<div className="font-semibold text-gray-700 dark:text-gray-200">
{(data.lcrCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} {data.currency}/h
</div>
</div>
<div className="w-px h-6 bg-gray-200 dark:bg-gray-600" />
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">UCR</div>
<div className="font-semibold text-gray-700 dark:text-gray-200">
{(data.ucrCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} {data.currency}/h
</div>
</div>
<div className="w-px h-6 bg-gray-200 dark:bg-gray-600" />
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Chg%</div>
<div className="font-semibold text-gray-700 dark:text-gray-200">{data.chargeabilityTarget}%</div>
</div>
</div>
{/* Main Skills */}
{mainSkills.length > 0 && (
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider mb-1">Main Skills</div>
<div className="flex flex-wrap gap-1">
{mainSkills.map((s) => (
<span
key={s.skill}
className="inline-flex items-center px-1.5 py-0.5 rounded-md text-[11px] font-medium bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-300"
>
{s.skill}
</span>
))}
</div>
</div>
)}
{/* Top Skills */}
{topSkills.length > 0 && (
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider mb-1">Top Skills</div>
<div className="flex flex-wrap gap-1">
{topSkills.map((s) => (
<span
key={s.skill}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-md text-[11px] bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
>
{s.skill}
<span className="text-[9px] text-gray-400 dark:text-gray-500">L{s.proficiency}</span>
</span>
))}
</div>
</div>
)}
{/* No skills */}
{skills.length === 0 && (
<div className="text-[11px] text-gray-400 dark:text-gray-500 italic">No skills imported yet.</div>
)}
</div>
</>
)}
</div>
);
}
@@ -145,6 +145,7 @@ export interface TimelineContextValue {
// ─ Display preferences
displayMode: TimelineDisplayMode;
heatmapScheme: HeatmapColorScheme;
blinkOverbookedDays: boolean;
// ─ Loading
isLoading: boolean;
@@ -287,6 +288,7 @@ export function TimelineProvider({
const { prefs: appPrefs } = useAppPreferences();
const displayMode = appPrefs.timelineDisplayMode;
const heatmapScheme = appPrefs.heatmapColorScheme;
const blinkOverbookedDays = appPrefs.blinkOverbookedDays;
// ─── Data queries ──────────────────────────────────────────────────────────
const { data: entriesView, isLoading } = trpc.timeline.getEntriesView.useQuery(
@@ -300,7 +302,7 @@ export function TimelineProvider({
...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ placeholderData: (prev: any) => prev },
{ placeholderData: (prev: any) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as { data: TimelineEntriesView | undefined; isLoading: boolean };
@@ -309,7 +311,7 @@ export function TimelineProvider({
const { data: vacationEntries = [] } = trpc.vacation.list.useQuery(
{ startDate: viewStart, endDate: viewEnd, status: [VacationStatus.APPROVED, VacationStatus.PENDING], limit: 500 },
{ placeholderData: (prev) => prev },
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
);
const vacationsByResource = useMemo(() => {
@@ -593,6 +595,7 @@ export function TimelineProvider({
today,
displayMode,
heatmapScheme,
blinkOverbookedDays,
isLoading,
isInitialLoading,
totalAllocCount,
@@ -618,6 +621,7 @@ export function TimelineProvider({
today,
displayMode,
heatmapScheme,
blinkOverbookedDays,
isLoading,
isInitialLoading,
totalAllocCount,
@@ -30,15 +30,15 @@ export function TimelineHeader({
<>
{/* Month header */}
<div
className="sticky top-0 z-40 flex bg-white border-b border-gray-100"
className="sticky top-0 z-40 flex bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800"
style={{ height: HEADER_MONTH_HEIGHT }}
>
<div className="flex-shrink-0 border-r border-gray-200" style={{ width: LABEL_WIDTH }} />
<div className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700" style={{ width: LABEL_WIDTH }} />
<div className="flex">
{monthGroups.map((m, i) => (
<div
key={i}
className="text-xs font-semibold text-gray-500 border-r border-gray-200 px-2 flex items-center bg-gray-50"
className="text-xs font-semibold text-gray-500 dark:text-gray-400 border-r border-gray-200 dark:border-gray-700 px-2 flex items-center bg-gray-50 dark:bg-gray-800"
style={{ width: m.colCount * CELL_WIDTH }}
>
{m.label}
@@ -50,11 +50,11 @@ export function TimelineHeader({
{/* Day header — hidden at month zoom (cells too narrow for labels) */}
{zoom !== "month" && (
<div
className="sticky z-40 flex bg-gray-50 border-b border-gray-200 select-none"
className="sticky z-40 flex bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 select-none"
style={{ top: HEADER_MONTH_HEIGHT, height: HEADER_DAY_HEIGHT }}
>
<div
className="flex-shrink-0 border-r border-gray-200 flex items-center px-4 text-xs font-medium text-gray-400 uppercase tracking-wider"
className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700 flex items-center px-4 text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider"
style={{ width: LABEL_WIDTH }}
>
{viewMode === "resource" ? "Resource" : "Project / Resource"}
@@ -72,10 +72,10 @@ export function TimelineHeader({
key={i}
className={clsx(
"flex-shrink-0 border-r flex flex-col items-center justify-center text-xs overflow-hidden",
isToday ? "bg-brand-50 border-brand-200" :
isSaturday ? "bg-amber-50/60 border-amber-200" :
isSunday ? "bg-gray-100/80 border-gray-200" :
isMonday ? "border-gray-200" : "border-gray-100",
isToday ? "bg-brand-50 dark:bg-brand-950/40 border-brand-200 dark:border-brand-800" :
isSaturday ? "bg-amber-50/60 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800" :
isSunday ? "bg-gray-100/80 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700" :
isMonday ? "border-gray-200 dark:border-gray-700" : "border-gray-100 dark:border-gray-800",
)}
style={{ width: CELL_WIDTH, height: HEADER_DAY_HEIGHT }}
>
@@ -83,7 +83,7 @@ export function TimelineHeader({
<>
<span className={clsx(
"font-medium leading-none",
isToday ? "text-brand-600" : isSaturday ? "text-amber-600" : "text-gray-600",
isToday ? "text-brand-600" : isSaturday ? "text-amber-600 dark:text-amber-400" : "text-gray-600 dark:text-gray-300",
)}>
{zoom === "week"
? `${date.getDate()} ${MONTHS_SHORT[date.getMonth()]}`
@@ -92,7 +92,7 @@ export function TimelineHeader({
{zoom === "day" && (
<span className={clsx(
"text-[9px] leading-none mt-0.5",
isSaturday ? "text-amber-400" : "text-gray-300",
isSaturday ? "text-amber-400 dark:text-amber-500" : "text-gray-300 dark:text-gray-600",
)}>
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][date.getDay()]}
</span>
@@ -12,6 +12,7 @@ import {
import { heatmapColor } from "./heatmapUtils.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { formatDateLong } from "~/lib/format.js";
import { TimelineTooltip } from "./TimelineTooltip.js";
import {
ROW_HEIGHT,
SUB_LANE_HEIGHT,
@@ -19,7 +20,7 @@ import {
PROJECT_HEADER_HEIGHT,
ORDER_TYPE_COLORS,
} from "./timelineConstants.js";
import type { DragState, AllocDragState, RangeState } from "~/hooks/useTimelineDrag.js";
import type { DragState, AllocDragState, RangeState, MultiSelectState } from "~/hooks/useTimelineDrag.js";
import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js";
// ─── Props ──────────────────────────────────────────────────────────────────
@@ -42,6 +43,7 @@ interface TimelineProjectPanelProps {
anchorX: number,
anchorY: number,
) => void;
multiSelectState: MultiSelectState;
// Layout from useTimelineLayout
CELL_WIDTH: number;
dates: Date[];
@@ -185,6 +187,7 @@ export function TimelineProjectPanel({
onOpenPanel,
onOpenDemandClick,
onAllocationContextMenu,
multiSelectState,
CELL_WIDTH,
dates,
totalCanvasWidth,
@@ -201,6 +204,7 @@ export function TimelineProjectPanel({
filters,
displayMode,
heatmapScheme,
blinkOverbookedDays,
activeFilterCount,
today,
} = useTimelineContext();
@@ -411,7 +415,7 @@ export function TimelineProjectPanel({
const laneCount = assignDemandLanes(row.openDemands).size > 0
? Math.max(...assignDemandLanes(row.openDemands).values()) + 1
: 1;
return Math.max(ROW_HEIGHT, laneCount * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP) + 8);
return Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
}
return ROW_HEIGHT;
},
@@ -602,7 +606,7 @@ export function TimelineProjectPanel({
const colors = ORDER_TYPE_COLORS[project.orderType] ?? {
bg: "bg-gray-400",
text: "text-white",
light: "bg-gray-50 border-gray-200",
light: "bg-gray-50 border-gray-200 dark:bg-gray-800 dark:border-gray-700",
};
const isThisProjectShifting =
dragState.isDragging && dragState.projectId === project.id;
@@ -620,12 +624,12 @@ export function TimelineProjectPanel({
return (
<div
data-project-group="true"
className={clsx("flex border-b border-gray-200 group/proj", colors.light)}
className={clsx("flex border-b border-gray-200 dark:border-gray-700 group/proj", colors.light)}
style={{ height: PROJECT_HEADER_HEIGHT }}
>
<div
className={clsx(
"flex-shrink-0 border-r border-gray-300 flex items-center px-4 gap-2.5 sticky left-0 z-30 cursor-pointer",
"flex-shrink-0 border-r border-gray-300 dark:border-gray-600 flex items-center px-4 gap-2.5 sticky left-0 z-30 cursor-pointer",
colors.light,
)}
style={{ width: LABEL_WIDTH }}
@@ -694,31 +698,39 @@ export function TimelineProjectPanel({
) : row.type === "open-demand" ? (
renderOpenDemandRow(
row.openDemands,
row.projectId,
CELL_WIDTH,
totalCanvasWidth,
toLeft,
toWidth,
resourceRowGridStyle,
onOpenDemandClick,
onAllocMouseDown,
onAllocTouchStart,
onAllocationContextMenu,
multiSelectState,
allocDragState,
)
) : (
<div
data-project-resource-row="true"
className="flex border-b border-gray-100 hover:bg-blue-50/20 group"
data-project-id={row.project.id}
data-resource-id={row.resource.id}
className="flex border-b border-gray-100 dark:border-gray-800 hover:bg-blue-50/20 dark:hover:bg-gray-800/30 group"
style={{ height: ROW_HEIGHT }}
>
<div
className="flex-shrink-0 border-r border-gray-200 flex items-center pl-8 pr-4 gap-2 bg-white sticky left-0 z-30 group-hover:bg-blue-50"
className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700 flex items-center pl-8 pr-4 gap-2 bg-white dark:bg-gray-900 sticky left-0 z-30 group-hover:bg-blue-50 dark:group-hover:bg-gray-800"
style={{ width: LABEL_WIDTH }}
>
<div className="w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center text-[10px] font-bold text-gray-600 flex-shrink-0">
<div className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-[10px] font-bold text-gray-600 dark:text-gray-300 flex-shrink-0">
{row.resource.displayName.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0">
<div className="text-xs font-medium text-gray-800 truncate">
<div className="min-w-0" data-resource-hover-id={row.resource.id}>
<div className="text-xs font-medium text-gray-800 dark:text-gray-200 truncate cursor-pointer hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
{row.resource.displayName}
</div>
<div className="text-[10px] text-gray-400 truncate">{row.resource.eid}</div>
<div className="text-[10px] text-gray-400 dark:text-gray-500 truncate">{row.resource.eid}</div>
</div>
</div>
@@ -771,6 +783,7 @@ export function TimelineProjectPanel({
onAllocMouseDown,
onAllocTouchStart,
onAllocationContextMenu,
multiSelectState,
)}
{renderVacationBlocksForProjectRow(
vacationsByResource.get(row.resource.id) ?? [],
@@ -781,6 +794,12 @@ export function TimelineProjectPanel({
totalCanvasWidth,
filters.showVacations,
)}
{blinkOverbookedDays &&
renderOverbookingBlinkProject(
allocsByResource.get(row.resource.id) ?? [],
dates,
CELL_WIDTH,
)}
{renderRangeOverlayProject(
rangeState,
row.resource.id,
@@ -796,7 +815,7 @@ export function TimelineProjectPanel({
);
})}
<ProjectPanelTooltips
<TimelineTooltip
heatmapTooltipRef={heatmapTooltipRef}
heatmapTooltipPos={heatmapTooltipPosRef.current}
vacationTooltipRef={vacationTooltipRef}
@@ -808,111 +827,7 @@ export function TimelineProjectPanel({
);
}
function ProjectPanelTooltips({
heatmapTooltipRef,
heatmapTooltipPos,
vacationTooltipRef,
vacationTooltipPos,
heatmapHover,
vacationHover,
}: {
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
heatmapTooltipPos: { left: number; top: number };
vacationTooltipRef: React.RefObject<HTMLDivElement | null>;
vacationTooltipPos: { left: number; top: number };
heatmapHover: {
date: Date;
totalH: number;
pct: number;
breakdown: {
projectId: string;
shortCode: string;
projectName: string;
orderType: string;
hoursPerDay: number;
responsiblePerson?: string | null;
}[];
} | null;
vacationHover: {
type: string;
startDate: Date | string;
endDate: Date | string;
note?: string | null;
requestedBy?: { name?: string | null; email: string } | null;
approvedBy?: { name?: string | null; email: string } | null;
approvedAt?: Date | string | null;
} | null;
}) {
return (
<>
{heatmapHover ? (
<div
ref={heatmapTooltipRef}
style={{
left: heatmapTooltipPos.left,
top: heatmapTooltipPos.top,
backgroundColor: "rgba(3, 7, 18, 0.96)",
}}
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
>
<div className="flex items-center justify-between gap-3">
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
<span className="text-[11px] text-gray-300">
{heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
</span>
</div>
<div className="mt-2 space-y-1.5">
{heatmapHover.breakdown.length > 0 ? (
heatmapHover.breakdown.slice(0, 6).map((entry) => (
<div
key={`${entry.projectId}-${entry.shortCode}`}
className="flex items-start justify-between gap-3"
>
<div className="min-w-0">
<div className="truncate font-medium text-white">
{entry.shortCode ? `${entry.shortCode} · ` : ""}
{entry.projectName}
</div>
<div className="truncate text-[11px] text-gray-400">
{entry.responsiblePerson
? `Lead: ${entry.responsiblePerson}`
: entry.orderType}
</div>
</div>
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
{entry.hoursPerDay}h
</span>
</div>
))
) : (
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
)}
</div>
</div>
) : null}
{vacationHover ? (
<div
ref={vacationTooltipRef}
style={{
left: vacationTooltipPos.left,
top: vacationTooltipPos.top,
backgroundColor: "rgba(120, 53, 15, 0.95)",
}}
className="fixed z-40 max-w-xs pointer-events-none rounded-xl border border-amber-700/50 bg-amber-950/95 px-3 py-2 text-xs text-amber-50 shadow-2xl"
>
<div className="font-semibold">{vacationHover.type.replaceAll("_", " ")}</div>
<div className="mt-1 text-[11px] text-amber-100/90">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div>
{vacationHover.note ? (
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
) : null}
</div>
) : null}
</>
);
}
// ProjectPanelTooltips removed — now uses shared TimelineTooltip component
// ─── Pure render functions ──────────────────────────────────────────────────
@@ -949,55 +864,97 @@ function assignDemandLanes(
return laneMap;
}
const DEMAND_LANE_HEIGHT = 30;
const DEMAND_LANE_GAP = 2;
function renderOpenDemandRow(
openDemands: TimelineDemandEntry[],
projectId: string,
CELL_WIDTH: number,
totalCanvasWidth: number,
toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number,
rowGridStyle: CSSProperties,
onOpenDemandClick: (demand: OpenDemandAssignment) => void,
_onOpenDemandClick: (demand: OpenDemandAssignment) => void,
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
onAllocationContextMenu: (
info: { allocationId: string; projectId: string },
anchorX: number,
anchorY: number,
) => void,
multiSelectState: MultiSelectState,
allocDragState: AllocDragState,
) {
if (openDemands.length === 0) return null;
const laneMap = assignDemandLanes(openDemands);
const laneCount = laneMap.size > 0 ? Math.max(...laneMap.values()) + 1 : 1;
const rowHeight = Math.max(ROW_HEIGHT, laneCount * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP) + 8);
const rowHeight = Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
return (
<div
className="flex border-b border-dashed border-amber-200 bg-amber-50/30 hover:bg-amber-50/50 group"
style={{ minHeight: rowHeight }}
data-project-demand-row="true"
data-project-id={projectId}
className="group relative isolate flex border-b border-dashed border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-slate-950 hover:bg-amber-100/80 dark:hover:bg-slate-900"
style={{ height: rowHeight }}
>
<div
className="flex-shrink-0 border-r border-amber-200 flex items-center pl-8 pr-4 gap-2 bg-amber-50 sticky left-0 z-30"
style={{ width: LABEL_WIDTH, minHeight: rowHeight }}
className="sticky left-0 z-30 flex flex-shrink-0 items-center gap-2 border-r border-amber-200 bg-amber-50 pl-8 pr-4 dark:border-amber-800 dark:bg-slate-950"
style={{ width: LABEL_WIDTH, height: rowHeight }}
>
<div className="w-6 h-6 rounded-full bg-amber-100 flex items-center justify-center text-[10px] font-bold text-amber-600 flex-shrink-0 border border-dashed border-amber-400">
?
</div>
<div className="min-w-0">
<div className="text-xs font-medium text-amber-700 truncate">Open demand</div>
<div className="text-[10px] text-amber-500 truncate">
{openDemands.length} open demand{openDemands.length > 1 ? "s" : ""}
<div className="pointer-events-none absolute inset-0 bg-amber-50 dark:bg-slate-950" />
<div className="relative z-10 flex items-center gap-2 min-w-0">
<div className="w-6 h-6 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center text-[10px] font-bold text-amber-600 dark:text-amber-400 flex-shrink-0 border border-dashed border-amber-400 dark:border-amber-600">
?
</div>
<div className="min-w-0">
<div className="text-xs font-medium text-amber-700 dark:text-amber-400 truncate">Open demand</div>
<div className="text-[10px] text-amber-500 dark:text-amber-600 truncate">
{openDemands.length} open demand{openDemands.length > 1 ? "s" : ""}
</div>
</div>
</div>
</div>
<div
className="relative overflow-hidden"
style={{ width: totalCanvasWidth, minHeight: rowHeight, ...rowGridStyle }}
className="relative overflow-hidden bg-amber-50 touch-none dark:bg-slate-950"
style={{ width: totalCanvasWidth, height: rowHeight, ...rowGridStyle }}
>
<div className="pointer-events-none absolute inset-x-0 inset-y-0 border-y border-dashed border-amber-200/70 dark:border-amber-800/80" />
<div className="pointer-events-none absolute inset-x-0 inset-y-1 rounded-md bg-amber-100/25 dark:bg-amber-950/35" />
{openDemands.map((alloc) => {
const allocStart = new Date(alloc.startDate);
const allocEnd = new Date(alloc.endDate);
const left = toLeft(allocStart);
const width = Math.max(CELL_WIDTH, toWidth(allocStart, allocEnd));
const isAllocDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id;
const dispStart =
isAllocDragged && allocDragState.currentStartDate
? allocDragState.currentStartDate
: allocStart;
const dispEnd =
isAllocDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : allocEnd;
// Multi-drag visual offset
const isMultiDragTarget =
multiSelectState.isMultiDragging &&
multiSelectState.selectedAllocationIds.includes(alloc.id);
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
const multiDragMode = multiSelectState.multiDragMode;
let left = toLeft(dispStart);
let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
// Clamp negative left (bar starts before view) to avoid extending outside canvas
if (left < 0) {
width += left;
left = 0;
}
if (width <= 0 || left >= totalCanvasWidth) return null;
if (isMultiDragTarget && multiDragMode === "resize-start") {
left += multiDragPx;
width = Math.max(CELL_WIDTH, width - multiDragPx);
} else if (isMultiDragTarget && multiDragMode === "resize-end") {
width = Math.max(CELL_WIDTH, width + multiDragPx);
}
const roleEntity = (
alloc as { roleEntity?: { id: string; name: string; color: string | null } | null }
).roleEntity;
@@ -1006,39 +963,99 @@ function renderOpenDemandRow(
const roleColor = roleEntity?.color ?? "#f59e0b";
const headcount = (alloc as { headcount?: number }).headcount ?? 1;
const lane = laneMap.get(alloc.id) ?? 0;
const top = 4 + lane * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP);
const top = 8 + lane * SUB_LANE_HEIGHT;
const blockHeight = SUB_LANE_HEIGHT - 8;
const HANDLE_W = width >= 48 ? 8 : 6;
const allocInfo: AllocMouseDownInfo = {
mode: "move",
allocationId: alloc.id,
mutationAllocationId: getPlanningEntryMutationId(alloc),
projectId: alloc.projectId,
projectName: alloc.project.name,
resourceId: null,
startDate: allocStart,
endDate: allocEnd,
};
return (
<div
key={alloc.id}
className="absolute rounded-md flex items-center px-2 gap-1 overflow-hidden cursor-pointer hover:ring-2 hover:ring-amber-400 hover:ring-offset-1 z-[10]"
className={clsx(
"absolute rounded-md flex items-stretch overflow-hidden z-[10] group/demand",
isAllocDragged
? "ring-2 ring-amber-500 z-20"
: "hover:ring-2 hover:ring-amber-400 hover:ring-offset-1",
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
)}
title={`${roleName}${headcount > 1 ? ` x${headcount}` : ""} · ${alloc.hoursPerDay}h/day · ${formatDateLong(allocStart)} ${formatDateLong(allocEnd)}`}
style={{
left: left + 2,
width: width - 4,
top,
height: DEMAND_LANE_HEIGHT,
backgroundColor: `${roleColor}33`,
border: `2px dashed ${roleColor}99`,
height: blockHeight,
backgroundColor: `${roleColor}4D`,
border: `2px dashed ${roleColor}B3`,
...(multiDragPx && multiDragMode === "move"
? { transform: `translateX(${multiDragPx}px)` }
: {}),
}}
onClick={() => {
onOpenDemandClick({
id: getPlanningEntryMutationId(alloc),
projectId: alloc.projectId,
roleId: (alloc as { roleId?: string | null }).roleId ?? null,
role: (alloc as { role?: string | null }).role ?? null,
headcount,
startDate: allocStart,
endDate: allocEnd,
hoursPerDay: alloc.hoursPerDay,
roleEntity: roleEntity ?? null,
project: alloc.project as { id: string; name: string; shortCode: string },
});
onMouseDown={(e) => {
if (e.button === 2) e.stopPropagation();
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onAllocationContextMenu(
{ allocationId: alloc.id, projectId: alloc.projectId },
e.clientX,
e.clientY,
);
}}
>
<span className="text-xs font-medium truncate" style={{ color: roleColor }}>
{roleName}
{headcount > 1 ? ` x${headcount}` : ""}
</span>
{/* Left resize handle */}
<div
className="flex-shrink-0 cursor-ew-resize hover:bg-black/10"
style={{ width: HANDLE_W }}
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
}}
/>
{/* Center — move + click */}
<div
className={clsx(
"flex-1 min-w-0 flex items-center px-1 gap-1",
isAllocDragged ? "cursor-grabbing" : "cursor-grab",
)}
onMouseDown={(e) => {
e.stopPropagation();
onAllocMouseDown(e, allocInfo);
}}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, allocInfo);
}}
>
<span className="text-xs font-medium truncate" style={{ color: roleColor }}>
{roleName}
{headcount > 1 ? ` x${headcount}` : ""}
</span>
</div>
{/* Right resize handle */}
<div
className="flex-shrink-0 cursor-ew-resize hover:bg-black/10"
style={{ width: HANDLE_W }}
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
}}
/>
</div>
);
})}
@@ -1157,6 +1174,7 @@ function renderProjectDragHandles(
anchorX: number,
anchorY: number,
) => void,
multiSelectState: MultiSelectState,
) {
return allocs.map((alloc) => {
const allocStart = new Date(alloc.startDate);
@@ -1170,10 +1188,24 @@ function renderProjectDragHandles(
const dispEnd =
isAllocDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : allocEnd;
const left = toLeft(dispStart);
const width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
let left = toLeft(dispStart);
let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
if (width <= 0 || left >= totalCanvasWidth) return null;
// Multi-drag visual offset
const isMultiDragTarget =
multiSelectState.isMultiDragging &&
multiSelectState.selectedAllocationIds.includes(alloc.id);
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
const multiDragMode = multiSelectState.multiDragMode;
if (isMultiDragTarget && multiDragMode === "resize-start") {
left += multiDragPx;
width = Math.max(CELL_WIDTH, width - multiDragPx);
} else if (isMultiDragTarget && multiDragMode === "resize-end") {
width = Math.max(CELL_WIDTH, width + multiDragPx);
}
// Always show resize handles — for narrow bars, use overlapping handles
const HANDLE_W = width >= 48 ? 8 : 6;
const hasRecurrence = !!(alloc.metadata as Record<string, unknown> | null)?.recurrence;
@@ -1198,8 +1230,20 @@ function renderProjectDragHandles(
isAllocDragged
? "ring-2 ring-brand-400 z-20"
: "hover:ring-1 hover:ring-brand-300/70 z-[15]",
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
)}
style={{ left: left + 2, width: width - 4, top: 2, bottom: 2 }}
style={{
left: left + 2,
width: width - 4,
top: 2,
bottom: 2,
...(multiDragPx && multiDragMode === "move"
? { transform: `translateX(${multiDragPx}px)` }
: {}),
}}
onMouseDown={(e) => {
if (e.button === 2) e.stopPropagation();
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -1315,6 +1359,40 @@ function renderVacationBlocksForProjectRow(
// ─── Range overlay for project view ─────────────────────────────────────────
function renderOverbookingBlinkProject(
allocs: TimelineAssignmentEntry[],
dates: Date[],
CELL_WIDTH: number,
) {
const REF_H = 8;
const overbooked: number[] = [];
for (let i = 0; i < dates.length; i++) {
const d = new Date(dates[i]!);
d.setHours(0, 0, 0, 0);
const t = d.getTime();
let totalH = 0;
for (const a of allocs) {
const s = new Date(a.startDate);
s.setHours(0, 0, 0, 0);
const e = new Date(a.endDate);
e.setHours(0, 0, 0, 0);
if (t >= s.getTime() && t <= e.getTime()) totalH += a.hoursPerDay;
}
if (totalH > REF_H) overbooked.push(i);
}
if (overbooked.length === 0) return null;
return overbooked.map((i) => (
<div
key={`ob-${i}`}
className="absolute top-0 bottom-0 pointer-events-none z-[15] animate-overbooking-blink"
style={{ left: i * CELL_WIDTH, width: CELL_WIDTH }}
/>
));
}
function renderRangeOverlayProject(
rangeState: RangeState,
resourceId: string,
@@ -12,7 +12,7 @@ import { ConflictOverlay } from "./ConflictOverlay.js";
import { computeSubLanes } from "./utils.js";
import { heatmapBgColor, heatmapColor } from "./heatmapUtils.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { formatDateLong } from "~/lib/format.js";
import { TimelineTooltip } from "./TimelineTooltip.js";
import {
ROW_HEIGHT,
SUB_LANE_HEIGHT,
@@ -24,6 +24,7 @@ import type {
AllocDragState,
RangeState,
ShiftPreviewData,
MultiSelectState,
} from "~/hooks/useTimelineDrag.js";
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
@@ -45,6 +46,7 @@ interface TimelineResourcePanelProps {
anchorX: number,
anchorY: number,
) => void;
multiSelectState: MultiSelectState;
// Layout from useTimelineLayout
CELL_WIDTH: number;
dates: Date[];
@@ -86,6 +88,7 @@ export function TimelineResourcePanel({
onRowMouseDown,
onRowTouchStart,
onAllocationContextMenu,
multiSelectState,
CELL_WIDTH,
dates,
totalCanvasWidth,
@@ -103,6 +106,7 @@ export function TimelineResourcePanel({
viewEnd,
displayMode,
heatmapScheme,
blinkOverbookedDays,
activeFilterCount,
} = useTimelineContext();
@@ -407,7 +411,7 @@ export function TimelineResourcePanel({
>
<div
className={clsx(
"flex border-b border-gray-100 hover:bg-blue-50/20 group transition-colors",
"flex border-b border-gray-100 dark:border-gray-800 hover:bg-blue-50/20 dark:hover:bg-gray-800/30 group transition-colors",
dragState.isDragging && isContextResource && "border-l-4 border-l-brand-400",
)}
style={{ height: rowHeight }}
@@ -415,19 +419,19 @@ export function TimelineResourcePanel({
{/* Label column */}
<div
className={clsx(
"flex-shrink-0 border-r border-gray-200 flex items-center px-4 gap-2.5 bg-white sticky left-0 z-30 group-hover:bg-blue-50",
dragState.isDragging && isContextResource && "bg-brand-50",
"flex-shrink-0 border-r border-gray-200 dark:border-gray-700 flex items-center px-4 gap-2.5 bg-white dark:bg-gray-900 sticky left-0 z-30 group-hover:bg-blue-50 dark:group-hover:bg-gray-800",
dragState.isDragging && isContextResource && "bg-brand-50 dark:bg-brand-950/40",
)}
style={{ width: LABEL_WIDTH }}
>
<div className="w-8 h-8 rounded-full bg-brand-100 flex items-center justify-center text-xs font-bold text-brand-700 flex-shrink-0">
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/40 flex items-center justify-center text-xs font-bold text-brand-700 dark:text-brand-300 flex-shrink-0">
{resource.displayName.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">
<div className="min-w-0" data-resource-hover-id={resource.id}>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate cursor-pointer hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
{resource.displayName}
</div>
<div className="text-xs text-gray-400 truncate">
<div className="text-xs text-gray-400 dark:text-gray-500 truncate">
{resource.chapter ?? resource.eid}
</div>
</div>
@@ -467,6 +471,7 @@ export function TimelineResourcePanel({
toLeft,
toWidth,
totalCanvasWidth,
multiSelectState,
)
: renderAllocBlocksFromData(
precomputed?.blockData ?? [],
@@ -480,6 +485,7 @@ export function TimelineResourcePanel({
onAllocMouseDown,
onAllocTouchStart,
onAllocationContextMenu,
multiSelectState,
)}
{renderVacationBlocksForRow(
vacationBlocksByResource.get(resource.id) ?? [],
@@ -488,6 +494,8 @@ export function TimelineResourcePanel({
{displayMode === "strip" && renderLoadGraph(allocs, dates, CELL_WIDTH)}
{displayMode === "heatmap" &&
renderHeatmapOverlay(allocs, dates, CELL_WIDTH, heatmapScheme)}
{blinkOverbookedDays &&
renderOverbookingBlink(allocs, dates, CELL_WIDTH)}
{renderRangeOverlay(
rangeState,
resource.id,
@@ -523,7 +531,7 @@ export function TimelineResourcePanel({
})}
{/* Tooltips rendered inside the panel so they live near their data source */}
<ResourcePanelTooltips
<TimelineTooltip
heatmapTooltipRef={heatmapTooltipRef}
heatmapTooltipPos={heatmapTooltipPosRef.current}
vacationTooltipRef={vacationTooltipRef}
@@ -535,113 +543,7 @@ export function TimelineResourcePanel({
);
}
// ─── Tooltip sub-component (portal-free: positioned fixed) ──────────────────
function ResourcePanelTooltips({
heatmapTooltipRef,
heatmapTooltipPos,
vacationTooltipRef,
vacationTooltipPos,
heatmapHover,
vacationHover,
}: {
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
heatmapTooltipPos: { left: number; top: number };
vacationTooltipRef: React.RefObject<HTMLDivElement | null>;
vacationTooltipPos: { left: number; top: number };
heatmapHover: {
date: Date;
totalH: number;
pct: number;
breakdown: {
projectId: string;
shortCode: string;
projectName: string;
orderType: string;
hoursPerDay: number;
responsiblePerson?: string | null;
}[];
} | null;
vacationHover: {
type: string;
startDate: Date | string;
endDate: Date | string;
note?: string | null;
requestedBy?: { name?: string | null; email: string } | null;
approvedBy?: { name?: string | null; email: string } | null;
approvedAt?: Date | string | null;
} | null;
}) {
return (
<>
{heatmapHover ? (
<div
ref={heatmapTooltipRef}
style={{
left: heatmapTooltipPos.left,
top: heatmapTooltipPos.top,
backgroundColor: "rgba(3, 7, 18, 0.96)",
}}
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
>
<div className="flex items-center justify-between gap-3">
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
<span className="text-[11px] text-gray-300">
{heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
</span>
</div>
<div className="mt-2 space-y-1.5">
{heatmapHover.breakdown.length > 0 ? (
heatmapHover.breakdown.slice(0, 6).map((entry) => (
<div
key={`${entry.projectId}-${entry.shortCode}`}
className="flex items-start justify-between gap-3"
>
<div className="min-w-0">
<div className="truncate font-medium text-white">
{entry.shortCode ? `${entry.shortCode} · ` : ""}
{entry.projectName}
</div>
<div className="truncate text-[11px] text-gray-400">
{entry.responsiblePerson
? `Lead: ${entry.responsiblePerson}`
: entry.orderType}
</div>
</div>
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
{entry.hoursPerDay}h
</span>
</div>
))
) : (
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
)}
</div>
</div>
) : null}
{vacationHover ? (
<div
ref={vacationTooltipRef}
style={{
left: vacationTooltipPos.left,
top: vacationTooltipPos.top,
backgroundColor: "rgba(120, 53, 15, 0.95)",
}}
className="fixed z-40 max-w-xs pointer-events-none rounded-xl border border-amber-700/50 bg-amber-950/95 px-3 py-2 text-xs text-amber-50 shadow-2xl"
>
<div className="font-semibold">{vacationHover.type.replaceAll("_", " ")}</div>
<div className="mt-1 text-[11px] text-amber-100/90">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div>
{vacationHover.note ? (
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
) : null}
</div>
) : null}
</>
);
}
// ResourcePanelTooltips removed — now uses shared TimelineTooltip component
// ─── Helper types ───────────────────────────────────────────────────────────
@@ -749,6 +651,7 @@ function renderAllocBlocksFromData(
anchorX: number,
anchorY: number,
) => void,
multiSelectState: MultiSelectState,
) {
const anyDragActive = dragState.isDragging || allocDragState.isActive;
@@ -771,8 +674,23 @@ function renderAllocBlocksFromData(
dispEnd = allocDragState.currentEndDate;
}
const left = toLeft(dispStart);
const width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
// Multi-drag offset: shift selected allocations visually during multi-drag
const isMultiDragTarget =
multiSelectState.isMultiDragging &&
multiSelectState.selectedAllocationIds.includes(alloc.id);
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
const multiDragMode = multiSelectState.multiDragMode;
let left = toLeft(dispStart);
let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
// For multi-drag resize, adjust left/width instead of using translateX
if (isMultiDragTarget && multiDragMode === "resize-start") {
left += multiDragPx;
width = Math.max(CELL_WIDTH, width - multiDragPx);
} else if (isMultiDragTarget && multiDragMode === "resize-end") {
width = Math.max(CELL_WIDTH, width + multiDragPx);
}
if (width <= 0 || left >= totalCanvasWidth) return null;
const blockTop = 8 + lane * SUB_LANE_HEIGHT;
@@ -811,6 +729,7 @@ function renderAllocBlocksFromData(
: isOtherDragged
? "opacity-30 z-[10]"
: "hover:ring-2 hover:ring-white hover:ring-offset-1 z-[10]",
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
)}
style={{
left: left + 2,
@@ -818,6 +737,12 @@ function renderAllocBlocksFromData(
top: blockTop,
height: blockHeight,
...(customColor ? { backgroundColor: customColor } : {}),
...(multiDragPx && multiDragMode === "move" ? { transform: `translateX(${multiDragPx}px)` } : {}),
}}
onMouseDown={(e) => {
// Stop right-click mouseDown from bubbling to the canvas,
// which would falsely start a multi-selection rectangle.
if (e.button === 2) e.stopPropagation();
}}
onContextMenu={(e) => {
e.preventDefault();
@@ -965,6 +890,45 @@ function renderHeatmapOverlay(
});
}
// ─── Overbooking blink overlay ───────────────────────────────────────────────
function renderOverbookingBlink(
allocs: TimelineAssignmentEntry[],
dates: Date[],
CELL_WIDTH: number,
) {
const REF_H = 8;
const overbooked: number[] = [];
for (let i = 0; i < dates.length; i++) {
const d = new Date(dates[i]!);
d.setHours(0, 0, 0, 0);
const t = d.getTime();
let totalH = 0;
for (const a of allocs) {
const s = new Date(a.startDate);
s.setHours(0, 0, 0, 0);
const e = new Date(a.endDate);
e.setHours(0, 0, 0, 0);
if (t >= s.getTime() && t <= e.getTime()) totalH += a.hoursPerDay;
}
if (totalH > REF_H) overbooked.push(i);
}
if (overbooked.length === 0) return null;
return overbooked.map((i) => (
<div
key={`ob-${i}`}
className="absolute top-0 bottom-0 pointer-events-none z-[15] animate-overbooking-blink"
style={{
left: i * CELL_WIDTH,
width: CELL_WIDTH,
}}
/>
));
}
// ─── Bar-mode: stacked daily bars ────────────────────────────────────────────
function renderDailyBars(
@@ -983,6 +947,7 @@ function renderDailyBars(
toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number,
totalCanvasWidth: number,
multiSelectState: MultiSelectState,
) {
const BAR_AREA = rowHeight - 8;
const REF_H = 8;
@@ -1061,8 +1026,21 @@ function renderDailyBars(
isBeingDragged
? "opacity-90 ring-2 ring-white ring-offset-1 z-20"
: "hover:opacity-80 z-[10]",
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
)}
style={{ left: i * CELL_WIDTH + 2, width: CELL_WIDTH - 4, height: segH, bottom }}
style={{
left: i * CELL_WIDTH + 2,
width: CELL_WIDTH - 4,
height: segH,
bottom,
...(multiSelectState.isMultiDragging &&
multiSelectState.selectedAllocationIds.includes(alloc.id)
? { transform: `translateX(${multiSelectState.multiDragDaysDelta * CELL_WIDTH}px)` }
: {}),
}}
onMouseDown={(e) => {
if (e.button === 2) e.stopPropagation();
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -0,0 +1,191 @@
"use client";
import { formatDateLong } from "~/lib/format.js";
export type HeatmapHoverData = {
date: Date;
totalH: number;
pct: number;
breakdown: {
projectId: string;
shortCode: string;
projectName: string;
orderType: string;
hoursPerDay: number;
responsiblePerson?: string | null;
}[];
};
export type VacationHoverData = {
type: string;
startDate: Date | string;
endDate: Date | string;
note?: string | null;
requestedBy?: { name?: string | null; email: string } | null;
approvedBy?: { name?: string | null; email: string } | null;
approvedAt?: Date | string | null;
};
interface TimelineTooltipProps {
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
heatmapTooltipPos: { left: number; top: number };
vacationTooltipRef: React.RefObject<HTMLDivElement | null>;
vacationTooltipPos: { left: number; top: number };
heatmapHover: HeatmapHoverData | null;
vacationHover: VacationHoverData | null;
}
export function TimelineTooltip({
heatmapTooltipRef,
heatmapTooltipPos,
vacationTooltipRef,
vacationTooltipPos,
heatmapHover,
vacationHover,
}: TimelineTooltipProps) {
// When both are active, render a single merged tooltip using the heatmap position
if (heatmapHover && vacationHover) {
return (
<div
ref={(el) => {
// Wire both refs to the same element so position updates work from either handler
(heatmapTooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
(vacationTooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
}}
style={{
left: heatmapTooltipPos.left,
top: heatmapTooltipPos.top,
backgroundColor: "rgba(3, 7, 18, 0.96)",
}}
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
>
{/* Date + hours header */}
<div className="flex items-center justify-between gap-3">
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
<span className="text-[11px] text-gray-300">
{heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
</span>
</div>
{/* Project breakdown */}
<div className="mt-2 space-y-1.5">
{heatmapHover.breakdown.length > 0 ? (
heatmapHover.breakdown.slice(0, 6).map((entry) => (
<div
key={`${entry.projectId}-${entry.shortCode}`}
className="flex items-start justify-between gap-3"
>
<div className="min-w-0">
<div className="truncate font-medium text-white">
{entry.shortCode ? `${entry.shortCode} · ` : ""}
{entry.projectName}
</div>
<div className="truncate text-[11px] text-gray-400">
{entry.responsiblePerson
? `Lead: ${entry.responsiblePerson}`
: entry.orderType}
</div>
</div>
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
{entry.hoursPerDay}h
</span>
</div>
))
) : (
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
)}
</div>
{/* Vacation section — merged below */}
<div className="mt-2 pt-2 border-t border-amber-700/40">
<div className="flex items-center gap-1.5">
<span className="inline-block w-2 h-2 rounded-full bg-amber-500 flex-shrink-0" />
<span className="font-semibold text-amber-300">
{vacationHover.type.replaceAll("_", " ")}
</span>
</div>
<div className="mt-0.5 text-[11px] text-amber-200/80">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div>
{vacationHover.note ? (
<div className="mt-1 text-[11px] text-amber-200/60">{vacationHover.note}</div>
) : null}
</div>
</div>
);
}
// Heatmap only
if (heatmapHover) {
return (
<div
ref={heatmapTooltipRef}
style={{
left: heatmapTooltipPos.left,
top: heatmapTooltipPos.top,
backgroundColor: "rgba(3, 7, 18, 0.96)",
}}
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
>
<div className="flex items-center justify-between gap-3">
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
<span className="text-[11px] text-gray-300">
{heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
</span>
</div>
<div className="mt-2 space-y-1.5">
{heatmapHover.breakdown.length > 0 ? (
heatmapHover.breakdown.slice(0, 6).map((entry) => (
<div
key={`${entry.projectId}-${entry.shortCode}`}
className="flex items-start justify-between gap-3"
>
<div className="min-w-0">
<div className="truncate font-medium text-white">
{entry.shortCode ? `${entry.shortCode} · ` : ""}
{entry.projectName}
</div>
<div className="truncate text-[11px] text-gray-400">
{entry.responsiblePerson
? `Lead: ${entry.responsiblePerson}`
: entry.orderType}
</div>
</div>
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
{entry.hoursPerDay}h
</span>
</div>
))
) : (
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
)}
</div>
</div>
);
}
// Vacation only
if (vacationHover) {
return (
<div
ref={vacationTooltipRef}
style={{
left: vacationTooltipPos.left,
top: vacationTooltipPos.top,
backgroundColor: "rgba(120, 53, 15, 0.95)",
}}
className="fixed z-40 max-w-xs pointer-events-none rounded-xl border border-amber-700/50 bg-amber-950/95 px-3 py-2 text-xs text-amber-50 shadow-2xl"
>
<div className="font-semibold">{vacationHover.type.replaceAll("_", " ")}</div>
<div className="mt-1 text-[11px] text-amber-100/90">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div>
{vacationHover.note ? (
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
) : null}
</div>
);
}
return null;
}
+498 -19
View File
@@ -1,13 +1,19 @@
"use client";
import { clsx } from "clsx";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAllocationHistory } from "~/hooks/useAllocationHistory.js";
import { useProjectDragContext } from "~/hooks/useProjectDragContext.js";
import { useTimelineDrag } from "~/hooks/useTimelineDrag.js";
import { useTimelineLayout } from "~/hooks/useTimelineLayout.js";
import { trpc } from "~/lib/trpc/client.js";
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
import { AllocationPopover } from "./AllocationPopover.js";
import { DemandPopover } from "./DemandPopover.js";
import { ResourceHoverCard } from "./ResourceHoverCard.js";
import type { TimelineDemandEntry } from "./TimelineContext.js";
import { BatchAssignPopover } from "./BatchAssignPopover.js";
import { FloatingActionBar } from "./FloatingActionBar.js";
import { NewAllocationPopover } from "./NewAllocationPopover.js";
import { ProjectPanel } from "./ProjectPanel.js";
import { ShiftPreviewTooltip } from "./ShiftPreviewTooltip.js";
@@ -31,9 +37,11 @@ import { TimelineProjectPanel, type OpenDemandAssignment } from "./TimelineProje
export function TimelineView() {
const mousePosRef = useRef({ x: 0, y: 0 });
const { push: pushHistory, undo, redo, canUndo, canRedo } = useAllocationHistory();
const { push: pushHistory, pushBatch: pushBatchHistory, undo, redo, canUndo, canRedo } = useAllocationHistory();
const pushHistoryRef = useRef(pushHistory);
pushHistoryRef.current = pushHistory;
const pushBatchHistoryRef = useRef(pushBatchHistory);
pushBatchHistoryRef.current = pushBatchHistory;
const [popover, setPopover] = useState<{
allocationId: string;
@@ -48,6 +56,10 @@ export function TimelineView() {
suggestedProjectId: string | null;
anchorX: number;
anchorY: number;
/** Selection coordinates to keep the overlay visible while popover is open */
selectionResourceId: string;
selectionStart: Date;
selectionEnd: Date;
} | null>(null);
// cellWidth placeholder — the real value comes from useTimelineLayout inside the content.
@@ -55,10 +67,22 @@ export function TimelineView() {
// We start with 40 (day zoom default) and update via a ref.
const cellWidthRef = useRef(40);
const outerUtils = trpc.useUtils();
const batchShiftMutationOuter = trpc.timeline.batchShiftAllocations.useMutation({
onSuccess: () => {
void outerUtils.timeline.getEntries.invalidate();
void outerUtils.timeline.getEntriesView.invalidate();
void outerUtils.timeline.getProjectContext.invalidate();
void outerUtils.timeline.getBudgetStatus.invalidate();
},
});
const {
dragState,
allocDragState,
rangeState,
multiSelectState,
setMultiSelectState,
shiftPreview,
isPreviewLoading,
isApplying,
@@ -69,6 +93,8 @@ export function TimelineView() {
onCanvasMouseMove,
onCanvasMouseUp,
onCanvasMouseLeave,
onCanvasRightMouseDown,
clearMultiSelect,
onProjectBarTouchStart,
onAllocTouchStart,
onRowTouchStart,
@@ -92,11 +118,33 @@ export function TimelineView() {
suggestedProjectId: info.suggestedProjectId,
anchorX: info.anchorX,
anchorY: info.anchorY,
selectionResourceId: info.resourceId,
selectionStart: info.startDate,
selectionEnd: info.endDate,
});
},
onAllocationMoved: (snapshot) => {
pushHistoryRef.current(snapshot);
},
onShiftClickAlloc: (allocationId: string) => {
setMultiSelectState(prev => {
const ids = new Set(prev.selectedAllocationIds);
if (ids.has(allocationId)) {
ids.delete(allocationId);
} else {
ids.add(allocationId);
}
return { ...prev, isSelecting: false, selectedAllocationIds: [...ids] };
});
},
onMultiDragComplete: (daysDelta, mode) => {
const ids = multiSelectState.selectedAllocationIds;
if (ids.length > 0 && daysDelta !== 0) {
pushBatchHistoryRef.current(ids, daysDelta, mode);
batchShiftMutationOuter.mutate({ allocationIds: ids, daysDelta, mode });
clearMultiSelect();
}
},
});
const [openPanelProjectId, setOpenPanelProjectId] = useState<string | null>(null);
@@ -115,6 +163,10 @@ export function TimelineView() {
dragState={dragState}
allocDragState={allocDragState}
rangeState={rangeState}
multiSelectState={multiSelectState}
setMultiSelectState={setMultiSelectState}
onCanvasRightMouseDown={onCanvasRightMouseDown}
clearMultiSelect={clearMultiSelect}
shiftPreview={shiftPreview}
isPreviewLoading={isPreviewLoading}
isApplying={isApplying}
@@ -154,6 +206,10 @@ function TimelineViewContent({
dragState,
allocDragState,
rangeState,
multiSelectState,
setMultiSelectState,
onCanvasRightMouseDown,
clearMultiSelect,
shiftPreview,
isPreviewLoading,
isApplying,
@@ -186,6 +242,10 @@ function TimelineViewContent({
dragState: ReturnType<typeof useTimelineDrag>["dragState"];
allocDragState: ReturnType<typeof useTimelineDrag>["allocDragState"];
rangeState: ReturnType<typeof useTimelineDrag>["rangeState"];
multiSelectState: ReturnType<typeof useTimelineDrag>["multiSelectState"];
setMultiSelectState: ReturnType<typeof useTimelineDrag>["setMultiSelectState"];
onCanvasRightMouseDown: ReturnType<typeof useTimelineDrag>["onCanvasRightMouseDown"];
clearMultiSelect: ReturnType<typeof useTimelineDrag>["clearMultiSelect"];
shiftPreview: ReturnType<typeof useTimelineDrag>["shiftPreview"];
isPreviewLoading: boolean;
isApplying: boolean;
@@ -211,6 +271,9 @@ function TimelineViewContent({
suggestedProjectId: string | null;
anchorX: number;
anchorY: number;
selectionResourceId: string;
selectionStart: Date;
selectionEnd: Date;
} | null;
setNewAllocPopover: React.Dispatch<React.SetStateAction<typeof newAllocPopover>>;
openPanelProjectId: string | null;
@@ -224,6 +287,8 @@ function TimelineViewContent({
const {
resources,
projectGroups,
allocsByResource,
openDemandsByProject,
viewStart,
viewEnd,
viewDays,
@@ -248,12 +313,69 @@ function TimelineViewContent({
const dragTooltipRef = useRef<HTMLDivElement>(null);
const allocTooltipRef = useRef<HTMLDivElement>(null);
const rangeHintRef = useRef<HTMLDivElement>(null);
const multiDragTooltipRef = useRef<HTMLDivElement>(null);
const [openDemandToAssign, setOpenDemandToAssign] = useState<OpenDemandAssignment | null>(null);
const [demandPopover, setDemandPopover] = useState<{
demand: TimelineDemandEntry;
x: number;
y: number;
} | null>(null);
const [showBatchAssign, setShowBatchAssign] = useState(false);
const [resourceHover, setResourceHover] = useState<{
resourceId: string;
anchorEl: HTMLElement;
} | null>(null);
const resourceHoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const utils = trpc.useUtils();
const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({
onSuccess: () => {
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
clearMultiSelect();
},
});
const { CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate } =
useTimelineLayout(viewStart, viewDays, filters.zoom, filters.showWeekends, today);
const hasActivePointerOverlay =
dragState.isDragging || allocDragState.isActive || rangeState.isSelecting;
dragState.isDragging || allocDragState.isActive || rangeState.isSelecting || multiSelectState.isMultiDragging;
// ─── Keep selection overlay visible while popover is open ───────────────────
const effectiveRangeState: typeof rangeState = rangeState.isSelecting
? rangeState
: newAllocPopover
? {
isSelecting: true,
resourceId: newAllocPopover.selectionResourceId,
startDate: newAllocPopover.selectionStart,
currentDate: newAllocPopover.selectionEnd,
suggestedProjectId: newAllocPopover.suggestedProjectId,
startClientX: 0,
}
: rangeState;
// ─── Auto-suggest project for resource-view range select ───────────────────
const enrichedSuggestedProjectId = useMemo(() => {
if (!newAllocPopover) return null;
// Already has a suggestion (e.g. from project view)
if (newAllocPopover.suggestedProjectId) return newAllocPopover.suggestedProjectId;
// Resource view: find the project with the most hours in this resource's row
const allocs = allocsByResource.get(newAllocPopover.resourceId);
if (!allocs || allocs.length === 0) return null;
const projectHours = new Map<string, number>();
for (const alloc of allocs) {
projectHours.set(alloc.projectId, (projectHours.get(alloc.projectId) ?? 0) + alloc.hoursPerDay);
}
let maxPid: string | null = null;
let maxH = 0;
for (const [pid, h] of projectHours) {
if (h > maxH) { maxH = h; maxPid = pid; }
}
return maxPid;
}, [newAllocPopover, allocsByResource]);
function openAllocationPopoverAt(
info: {
@@ -263,6 +385,13 @@ function TimelineViewContent({
anchorX: number,
anchorY: number,
) {
// Check if this is a demand (not an assignment) — route to DemandPopover
const demands = openDemandsByProject.get(info.projectId);
const demand = demands?.find((d) => d.id === info.allocationId);
if (demand) {
setDemandPopover({ demand, x: anchorX, y: anchorY });
return;
}
setPopover({
allocationId: info.allocationId,
projectId: info.projectId,
@@ -295,10 +424,16 @@ function TimelineViewContent({
rangeHintRef.current.style.left = `${x + 12}px`;
rangeHintRef.current.style.top = `${y - 28}px`;
}
if (multiDragTooltipRef.current) {
multiDragTooltipRef.current.style.left = `${x + 14}px`;
multiDragTooltipRef.current.style.top = `${y - 36}px`;
}
};
el.addEventListener("mousemove", handler, { passive: true });
return () => el.removeEventListener("mousemove", handler);
}, [hasActivePointerOverlay, isLoading, mousePosRef]); // eslint-disable-line react-hooks/exhaustive-deps
// During multi-drag, listen on document (cursor may leave canvas)
const target: EventTarget = multiSelectState.isMultiDragging ? document : el;
target.addEventListener("mousemove", handler as EventListener, { passive: true });
return () => target.removeEventListener("mousemove", handler as EventListener);
}, [hasActivePointerOverlay, isLoading, mousePosRef, multiSelectState.isMultiDragging]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Shift+wheel → horizontal scroll ──────────────────────────────────────
useEffect(() => {
@@ -333,6 +468,92 @@ function TimelineViewContent({
return () => window.removeEventListener("keydown", handler);
}, [undo, redo]);
// ─── ESC to close overlays (topmost first) ─────────────────────────────────
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0) {
e.preventDefault();
clearMultiSelect();
return;
}
if (demandPopover) {
e.preventDefault();
setDemandPopover(null);
} else if (popover) {
e.preventDefault();
setPopover(null);
} else if (newAllocPopover) {
e.preventDefault();
setNewAllocPopover(null);
} else if (openDemandToAssign) {
e.preventDefault();
setOpenDemandToAssign(null);
} else if (openPanelProjectId) {
e.preventDefault();
setOpenPanelProjectId(null);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [demandPopover, popover, newAllocPopover, openDemandToAssign, openPanelProjectId, setPopover, setNewAllocPopover, setOpenPanelProjectId, multiSelectState.selectedAllocationIds.length, multiSelectState.selectedResourceIds.length, clearMultiSelect]);
// ─── Resource hover card — event delegation on label columns ──────────────
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const HOVER_DELAY = 400;
function onMouseOver(e: MouseEvent) {
const target = (e.target as HTMLElement).closest<HTMLElement>("[data-resource-hover-id]");
if (!target) return;
const rid = target.dataset.resourceHoverId;
if (!rid) return;
// Clear any pending hide
if (resourceHoverTimerRef.current) {
clearTimeout(resourceHoverTimerRef.current);
resourceHoverTimerRef.current = null;
}
// If already showing this resource, skip
if (resourceHover?.resourceId === rid) return;
resourceHoverTimerRef.current = setTimeout(() => {
resourceHoverTimerRef.current = null;
setResourceHover({ resourceId: rid, anchorEl: target });
}, HOVER_DELAY);
}
function onMouseOut(e: MouseEvent) {
const related = e.relatedTarget as HTMLElement | null;
// Don't close if moving into another resource-hover target or the hover card itself
if (related?.closest?.("[data-resource-hover-id]") || related?.closest?.("[data-resource-hover-card]")) return;
if (resourceHoverTimerRef.current) {
clearTimeout(resourceHoverTimerRef.current);
resourceHoverTimerRef.current = null;
}
// Small delay before hiding to allow moving into hover card
resourceHoverTimerRef.current = setTimeout(() => {
resourceHoverTimerRef.current = null;
setResourceHover(null);
}, 150);
}
canvas.addEventListener("mouseover", onMouseOver, { passive: true });
canvas.addEventListener("mouseout", onMouseOut, { passive: true });
return () => {
canvas.removeEventListener("mouseover", onMouseOver);
canvas.removeEventListener("mouseout", onMouseOut);
if (resourceHoverTimerRef.current) {
clearTimeout(resourceHoverTimerRef.current);
resourceHoverTimerRef.current = null;
}
};
}, [resourceHover?.resourceId]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Lazy-extend date range on scroll ─────────────────────────────────────
function handleContainerScroll() {
const el = scrollContainerRef.current;
@@ -348,6 +569,126 @@ function TimelineViewContent({
onCanvasMouseMove(e);
};
// ─── Multi-select intersection computation ────────────────────────────────
useEffect(() => {
// Only compute when drag just ended (isSelecting false but has coordinates)
if (multiSelectState.isSelecting) return;
if (multiSelectState.startX === 0 && multiSelectState.startY === 0) return;
if (multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0) return;
const canvasEl = canvasRef.current;
if (!canvasEl) return;
// Selection rectangle in viewport coordinates (same coordinate space as
// getBoundingClientRect). Using viewport coords directly avoids any
// coordinate transformation errors from sticky headers or virtualizer offsets.
const selTop = Math.min(multiSelectState.startY, multiSelectState.currentY);
const selBottom = Math.max(multiSelectState.startY, multiSelectState.currentY);
const selLeft = Math.min(multiSelectState.startX, multiSelectState.currentX);
const selRight = Math.max(multiSelectState.startX, multiSelectState.currentX);
// For X-axis: convert viewport X to canvas-relative X for allocation matching.
// Query any row element to find the actual canvas area position.
const canvasRect = canvasEl.getBoundingClientRect();
const canvasXOffset = canvasRect.left + LABEL_WIDTH;
const toCanvasX = (clientX: number) => clientX - canvasXOffset;
const selLeftCanvas = toCanvasX(selLeft);
const selRightCanvas = toCanvasX(selRight);
// Derive date range from pixel X positions
const colIndexStart = Math.max(0, Math.min(dates.length - 1, Math.floor(selLeftCanvas / CELL_WIDTH)));
const colIndexEnd = Math.max(0, Math.min(dates.length - 1, Math.floor(selRightCanvas / CELL_WIDTH)));
const startDate = dates[colIndexStart] ?? today;
const endDate = dates[colIndexEnd] ?? today;
// Find allocations within the rectangle by querying actual DOM positions.
// This avoids any mismatch between computed row positions and actual rendering.
const selectedIds: string[] = [];
const selectedResIds: string[] = [];
// Query all rendered row elements (virtualizer only renders visible + overscan rows)
const rowElements = canvasEl.querySelectorAll<HTMLElement>("[data-index]");
if (viewMode === "resource") {
rowElements.forEach((rowEl) => {
const idx = Number(rowEl.dataset.index);
const resource = resources[idx];
if (!resource) return;
const rowRect = rowEl.getBoundingClientRect();
// Compare directly in viewport coordinates
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
selectedResIds.push(resource.id);
const allocs = allocsByResource.get(resource.id) ?? [];
for (const alloc of allocs) {
const allocLeft = toLeft(new Date(alloc.startDate));
const allocRight = allocLeft + toWidth(new Date(alloc.startDate), new Date(alloc.endDate));
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
selectedIds.push(alloc.id);
}
}
});
} else if (viewMode === "project") {
// Project view: query actual resource row DOM elements by data attribute.
// Each row carries data-project-id and data-resource-id for alloc lookup.
const projectRowEls = canvasEl.querySelectorAll<HTMLElement>("[data-project-resource-row]");
projectRowEls.forEach((rowEl) => {
const rowRect = rowEl.getBoundingClientRect();
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
const projectId = rowEl.dataset.projectId;
const resourceId = rowEl.dataset.resourceId;
if (!projectId || !resourceId) return;
// Find matching group and row
const group = projectGroups.find((g) => g.id === projectId);
if (!group) return;
const row = group.resourceRows.find((r) => r.resource.id === resourceId);
if (!row) return;
for (const alloc of row.allocs) {
const allocLeft = toLeft(new Date(alloc.startDate));
const allocRight = allocLeft + toWidth(new Date(alloc.startDate), new Date(alloc.endDate));
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
selectedIds.push(alloc.id);
}
}
});
// Also check demand rows for open demand selection
const demandRowEls = canvasEl.querySelectorAll<HTMLElement>("[data-project-demand-row]");
demandRowEls.forEach((rowEl) => {
const rowRect = rowEl.getBoundingClientRect();
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
const projectId = rowEl.dataset.projectId;
if (!projectId) return;
const demands = openDemandsByProject.get(projectId) ?? [];
for (const demand of demands) {
const allocLeft = toLeft(new Date(demand.startDate));
const allocRight = allocLeft + toWidth(new Date(demand.startDate), new Date(demand.endDate));
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
selectedIds.push(demand.id);
}
}
});
}
if (selectedIds.length > 0 || selectedResIds.length > 0) {
setMultiSelectState(prev => ({
...prev,
selectedAllocationIds: selectedIds,
selectedResourceIds: selectedResIds,
dateRange: { start: startDate, end: endDate },
}));
} else {
clearMultiSelect();
}
}, [multiSelectState.isSelecting, multiSelectState.startX, multiSelectState.startY]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="relative flex flex-1 flex-col gap-4 min-h-0">
{/* Toolbar */}
@@ -404,14 +745,21 @@ function TimelineViewContent({
onMouseMove={handleMouseMove}
onMouseUp={(e) => void onCanvasMouseUp(e)}
onMouseLeave={onCanvasMouseLeave}
onMouseDown={(e) => {
if (e.button === 2) {
onCanvasRightMouseDown(e);
}
}}
onContextMenu={(e) => e.preventDefault()}
onTouchMove={(e) => {
if (!hasActivePointerOverlay) return;
onCanvasTouchMove(e);
}}
onTouchEnd={(e) => void onCanvasTouchEnd(e)}
className={clsx(
(dragState.isDragging || allocDragState.isActive) && "cursor-grabbing select-none",
(dragState.isDragging || allocDragState.isActive || multiSelectState.isMultiDragging) && "cursor-grabbing select-none",
rangeState.isSelecting && "cursor-crosshair select-none",
multiSelectState.isSelecting && "cursor-crosshair select-none",
)}
>
{viewMode === "resource" && (
@@ -419,7 +767,7 @@ function TimelineViewContent({
scrollContainerRef={scrollContainerRef}
dragState={dragState}
allocDragState={allocDragState}
rangeState={rangeState}
rangeState={effectiveRangeState}
shiftPreview={shiftPreview}
contextResourceIds={contextResourceIds}
onAllocMouseDown={onAllocMouseDown}
@@ -427,6 +775,7 @@ function TimelineViewContent({
onRowMouseDown={onRowMouseDown}
onRowTouchStart={onRowTouchStart}
onAllocationContextMenu={openAllocationPopoverAt}
multiSelectState={multiSelectState}
CELL_WIDTH={CELL_WIDTH}
dates={dates}
totalCanvasWidth={totalCanvasWidth}
@@ -442,7 +791,8 @@ function TimelineViewContent({
scrollContainerRef={scrollContainerRef}
dragState={dragState}
allocDragState={allocDragState}
rangeState={rangeState}
rangeState={effectiveRangeState}
multiSelectState={multiSelectState}
onProjectBarMouseDown={onProjectBarMouseDown}
onProjectBarTouchStart={onProjectBarTouchStart}
onAllocMouseDown={onAllocMouseDown}
@@ -466,6 +816,19 @@ function TimelineViewContent({
)}
</div>
{/* Multi-select rectangle overlay */}
{multiSelectState.isSelecting && (
<div
className="fixed border-2 border-sky-500 bg-sky-500/10 pointer-events-none z-30 rounded"
style={{
left: Math.min(multiSelectState.startX, multiSelectState.currentX),
top: Math.min(multiSelectState.startY, multiSelectState.currentY),
width: Math.abs(multiSelectState.currentX - multiSelectState.startX),
height: Math.abs(multiSelectState.currentY - multiSelectState.startY),
}}
/>
)}
{/* Saving indicators */}
{(isApplying || isAllocSaving) && (
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center rounded-2xl bg-white/50 dark:bg-gray-950/50">
@@ -540,18 +903,95 @@ function TimelineViewContent({
</div>
)}
{/* Allocation popover */}
{popover && (
<AllocationPopover
allocationId={popover.allocationId}
projectId={popover.projectId}
onClose={() => setPopover(null)}
{/* Multi-drag tooltip */}
{multiSelectState.isMultiDragging && multiSelectState.multiDragDaysDelta !== 0 && (
<div
ref={multiDragTooltipRef}
className="fixed z-50 bg-sky-700 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg font-medium"
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
>
{multiSelectState.multiDragMode === "resize-start" ? "Start " : multiSelectState.multiDragMode === "resize-end" ? "End " : ""}
{multiSelectState.multiDragDaysDelta > 0 ? "+" : ""}
{multiSelectState.multiDragDaysDelta}d
{" "}
({multiSelectState.selectedAllocationIds.length} allocations)
</div>
)}
{/* Allocation / Demand popover (click path) */}
{popover && (() => {
// Check if clicked allocation is actually a demand
const clickedDemand = openDemandsByProject.get(popover.projectId)?.find((d) => d.id === popover.allocationId);
if (clickedDemand) {
return (
<DemandPopover
demand={clickedDemand}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
onFillDemand={(d) => {
setPopover(null);
setOpenDemandToAssign({
id: d.id,
projectId: d.projectId,
roleId: d.roleId,
role: d.role,
headcount: d.requestedHeadcount,
startDate: new Date(d.startDate),
endDate: new Date(d.endDate),
hoursPerDay: d.hoursPerDay,
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
...(d.project !== undefined ? { project: d.project } : {}),
});
}}
anchorX={popover.x}
anchorY={popover.y}
/>
);
}
return (
<AllocationPopover
allocationId={popover.allocationId}
projectId={popover.projectId}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
anchorX={popover.x}
anchorY={popover.y}
/>
);
})()}
{/* Demand popover */}
{demandPopover && (
<DemandPopover
demand={demandPopover.demand}
onClose={() => setDemandPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setDemandPopover(null);
setOpenPanelProjectId(pid);
}}
anchorX={popover.x}
anchorY={popover.y}
onFillDemand={(d) => {
setDemandPopover(null);
setOpenDemandToAssign({
id: d.id,
projectId: d.projectId,
roleId: d.roleId,
role: d.role,
headcount: d.requestedHeadcount,
startDate: new Date(d.startDate),
endDate: new Date(d.endDate),
hoursPerDay: d.hoursPerDay,
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
...(d.project !== undefined ? { project: d.project } : {}),
});
}}
anchorX={demandPopover.x}
anchorY={demandPopover.y}
/>
)}
@@ -561,7 +1001,7 @@ function TimelineViewContent({
resourceId={newAllocPopover.resourceId}
startDate={newAllocPopover.startDate}
endDate={newAllocPopover.endDate}
suggestedProjectId={newAllocPopover.suggestedProjectId}
suggestedProjectId={enrichedSuggestedProjectId}
anchorX={newAllocPopover.anchorX}
anchorY={newAllocPopover.anchorY}
onClose={() => setNewAllocPopover(null)}
@@ -582,6 +1022,45 @@ function TimelineViewContent({
onSuccess={() => setOpenDemandToAssign(null)}
/>
)}
{/* Multi-select floating action bar */}
<FloatingActionBar
selectedAllocationCount={multiSelectState.selectedAllocationIds.length}
selectedResourceCount={multiSelectState.selectedResourceIds.length}
onDelete={() => {
if (multiSelectState.selectedAllocationIds.length === 0) return;
const msg = `Delete ${multiSelectState.selectedAllocationIds.length} allocation(s)? This cannot be undone.`;
if (window.confirm(msg)) {
batchDeleteMutation.mutate({ ids: multiSelectState.selectedAllocationIds });
}
}}
onAssign={() => setShowBatchAssign(true)}
onClear={clearMultiSelect}
isDeleting={batchDeleteMutation.isPending}
/>
{/* Batch assign popover */}
{showBatchAssign && multiSelectState.dateRange && (
<BatchAssignPopover
resourceIds={multiSelectState.selectedResourceIds}
startDate={multiSelectState.dateRange.start}
endDate={multiSelectState.dateRange.end}
onClose={() => setShowBatchAssign(false)}
onCreated={() => {
setShowBatchAssign(false);
clearMultiSelect();
}}
/>
)}
{/* Resource hover card */}
{resourceHover && (
<ResourceHoverCard
resourceId={resourceHover.resourceId}
anchorEl={resourceHover.anchorEl}
onClose={() => setResourceHover(null)}
/>
)}
</div>
);
}