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
+16 -6
View File
@@ -6,7 +6,7 @@ import Link from "next/link";
import type { Route } from "next";
import { usePathname } from "next/navigation";
import { clsx } from "clsx";
import { Suspense, useState } from "react";
import { Suspense, useMemo, useState } from "react";
import { PreferencesModal } from "./PreferencesModal.js";
import { ThemeProvider } from "./ThemeProvider.js";
import { NotificationBell } from "../notifications/NotificationBell.js";
@@ -139,6 +139,7 @@ const adminNavEntries: AdminEntry[] = [
},
{ href: "/admin/calculation-rules", label: "Calc. Rules", icon: <AdminIcon /> },
{ href: "/admin/users", label: "Users", icon: <AdminIcon /> },
{ href: "/admin/system-roles", label: "System Roles", icon: <AdminIcon /> },
{ href: "/admin/settings", label: "Settings", icon: <AdminIcon /> },
{ href: "/admin/skill-import", label: "Skill Import", icon: <AdminIcon /> },
{ href: "/admin/notifications", label: "Broadcasts", icon: <BroadcastIcon /> },
@@ -180,6 +181,15 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
const pathname = usePathname();
const [prefsOpen, setPrefsOpen] = useState(false);
// Memoize active href set — avoids O(n²) on every render
const activeHrefSet = useMemo(() => {
const set = new Set<string>();
for (const href of ALL_NAV_HREFS) {
if (isNavItemActive(pathname, href)) set.add(href);
}
return set;
}, [pathname]);
const visibleSections = navSections
.map((section) => ({
...section,
@@ -194,13 +204,13 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
const initial: Record<string, boolean> = {};
for (const section of visibleSections) {
if (section.collapsed) {
const hasActiveRoute = section.items.some((item) => isNavItemActive(pathname, item.href));
const hasActiveRoute = section.items.some((item) => activeHrefSet.has(item.href));
initial[section.label] = !hasActiveRoute;
}
}
for (const entry of adminNavEntries) {
if (isSubGroup(entry) && entry.collapsed) {
const hasActiveRoute = entry.items.some((item) => isNavItemActive(pathname, item.href));
const hasActiveRoute = entry.items.some((item) => activeHrefSet.has(item.href));
initial[entry.label] = !hasActiveRoute;
}
}
@@ -270,7 +280,7 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
href={item.href as Route}
className={clsx(
"group flex items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium transition-all",
isNavItemActive(pathname, item.href)
activeHrefSet.has(item.href)
? "bg-gradient-to-r from-brand-50 to-brand-100/70 text-brand-800 shadow-sm ring-1 ring-brand-200/70 dark:from-brand-900/30 dark:to-brand-800/20 dark:text-brand-200 dark:ring-brand-900/40"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
@@ -325,7 +335,7 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
href={item.href as Route}
className={clsx(
"group flex items-center gap-3 rounded-2xl px-3 py-1.5 text-sm font-medium transition-all",
isNavItemActive(pathname, item.href)
activeHrefSet.has(item.href)
? "bg-gradient-to-r from-brand-50 to-brand-100/70 text-brand-800 shadow-sm ring-1 ring-brand-200/70 dark:from-brand-900/30 dark:to-brand-800/20 dark:text-brand-200 dark:ring-brand-900/40"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
@@ -344,7 +354,7 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
href={entry.href as Route}
className={clsx(
"group flex items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium transition-all",
isNavItemActive(pathname, entry.href)
activeHrefSet.has(entry.href)
? "bg-gradient-to-r from-brand-50 to-brand-100/70 text-brand-800 shadow-sm ring-1 ring-brand-200/70 dark:from-brand-900/30 dark:to-brand-800/20 dark:text-brand-200 dark:ring-brand-900/40"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}