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

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

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-18 23:43:51 +01:00
parent d0f04f13f8
commit ddec3a927a
67 changed files with 4930 additions and 1166 deletions
@@ -0,0 +1,187 @@
"use client";
import { useEffect, useRef } from "react";
import type { TimelineDemandEntry } from "./TimelineContext.js";
import { formatDateLong } from "~/lib/format.js";
interface DemandPopoverProps {
demand: TimelineDemandEntry;
onClose: () => void;
onOpenPanel: (projectId: string) => void;
onFillDemand: (demand: TimelineDemandEntry) => void;
anchorX: number;
anchorY: number;
}
export function DemandPopover({
demand,
onClose,
onOpenPanel,
onFillDemand,
anchorX,
anchorY,
}: DemandPopoverProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [onClose]);
const roleName = demand.roleEntity?.name ?? demand.role ?? "Unspecified";
const roleColor = demand.roleEntity?.color ?? "#f59e0b";
const startDate = new Date(demand.startDate);
const endDate = new Date(demand.endDate);
const days = Math.max(1, Math.round((endDate.getTime() - startDate.getTime()) / 86_400_000) + 1);
const totalHours = demand.hoursPerDay * days;
const budgetCents = demand.dailyCostCents * days;
const popoverStyle: React.CSSProperties = {
position: "fixed",
left: Math.min(anchorX, window.innerWidth - 320),
top: Math.min(anchorY + 8, window.innerHeight - 340),
zIndex: 50,
width: 300,
};
return (
<div
ref={ref}
style={popoverStyle}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden"
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-700"
style={{ backgroundColor: `${roleColor}18` }}
>
<div className="flex items-center gap-2 min-w-0">
<div
className="w-3 h-3 rounded-full flex-shrink-0 border-2 border-dashed"
style={{ borderColor: roleColor, backgroundColor: `${roleColor}33` }}
/>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">
{roleName}
</span>
</div>
<button
onClick={onClose}
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 text-lg leading-none ml-2"
>
&times;
</button>
</div>
<div className="p-4 space-y-3">
{/* Project */}
<div className="text-xs text-gray-500 dark:text-gray-400">
Project:{" "}
<span className="font-medium text-gray-700 dark:text-gray-200">
{demand.project.name}
</span>
{" "}
<span className="text-gray-400 dark:text-gray-500">({demand.project.shortCode})</span>
</div>
{/* Status badge */}
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400 border border-dashed border-amber-300 dark:border-amber-700">
Open Demand
</span>
<span className="text-[11px] text-gray-400 dark:text-gray-500">
{demand.status}
</span>
</div>
{/* Headcount */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Requested</div>
<div className="font-medium text-gray-800 dark:text-gray-200">
{demand.requestedHeadcount} {demand.requestedHeadcount === 1 ? "person" : "people"}
</div>
</div>
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Unfilled</div>
<div className="font-medium text-amber-600 dark:text-amber-400">
{demand.unfilledHeadcount} remaining
</div>
</div>
{/* Date range */}
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Start</div>
<div className="font-medium text-gray-800 dark:text-gray-200">
{formatDateLong(startDate)}
</div>
</div>
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">End</div>
<div className="font-medium text-gray-800 dark:text-gray-200">
{formatDateLong(endDate)}
</div>
</div>
{/* Hours */}
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Hours / day</div>
<div className="font-medium text-gray-800 dark:text-gray-200">{demand.hoursPerDay}h</div>
</div>
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Total hours</div>
<div className="font-medium text-gray-800 dark:text-gray-200">{totalHours}h ({days}d)</div>
</div>
{/* Budget */}
{budgetCents > 0 && (
<>
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Daily cost</div>
<div className="font-medium text-gray-800 dark:text-gray-200">
{(demand.dailyCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR
</div>
</div>
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Total cost</div>
<div className="font-medium text-gray-800 dark:text-gray-200">
{(budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR
</div>
</div>
</>
)}
{/* Percentage */}
{demand.percentage > 0 && (
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Percentage</div>
<div className="font-medium text-gray-800 dark:text-gray-200">{demand.percentage}%</div>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-2 border-t border-gray-100 dark:border-gray-700">
{demand.unfilledHeadcount > 0 && (
<button
onClick={() => { onClose(); onFillDemand(demand); }}
className="flex-1 py-1.5 rounded-lg text-sm font-medium bg-amber-500 text-white hover:bg-amber-600 transition-colors"
>
Fill Demand
</button>
)}
<button
onClick={() => { onClose(); onOpenPanel(demand.projectId); }}
className="flex-1 py-1.5 rounded-lg text-sm font-medium border border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Open Project
</button>
</div>
</div>
</div>
);
}