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:
@@ -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"
|
||||
>
|
||||
×
|
||||
</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)} – {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">
|
||||
▾
|
||||
</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"
|
||||
>
|
||||
×
|
||||
</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">×</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">×</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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user