refactor(web): extract ReportResultsPanel and nav icons from monolithic components

Extract ReportResultsPanel (293 lines) from ReportBuilder (1231→1044 lines)
and move 38 inline icon components from AppShell (937→833 lines) to nav-icons.tsx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 08:58:31 +02:00
parent 17f2de5f48
commit d1d33aa810
4 changed files with 1244 additions and 503 deletions
+277 -198
View File
@@ -14,153 +14,49 @@ import { PageTransition } from "./PageTransition.js";
import { NotificationBell } from "../notifications/NotificationBell.js";
import { ChatPanel } from "../assistant/ChatPanel.js";
import { NavProgressBar } from "~/components/ui/NavProgressBar.js";
import {
IconFrame,
DashboardIcon,
ResourcesIcon,
ProjectsIcon,
EstimatesIcon,
AllocationsIcon,
TimelineIcon,
StaffingIcon,
VacationIcon,
RolesIcon,
SkillsIcon,
MarketplaceIcon,
ChargeabilityIcon,
BenchIcon,
ReportBuilderIcon,
GraphIcon,
InsightsIcon,
NotificationsIcon,
BroadcastIcon,
ActivityLogIcon,
AdminIcon,
BlueprintIcon,
ClientsIcon,
CountryIcon,
OrgUnitIcon,
CategoryIcon,
LevelsIcon,
ImportIcon,
CalcRulesIcon,
UsersIcon,
SystemRolesIcon,
SecurityIcon,
SettingsIcon,
WebhooksIcon,
ScenariosIcon,
CollapseIcon,
HamburgerIcon,
CloseIcon,
} from "./nav-icons.js";
const SIDEBAR_COLLAPSED_KEY = "capakraken_sidebar_collapsed";
function IconFrame({ children, isActive }: { children: ReactNode; isActive?: boolean }) {
return (
<span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border shadow-sm transition-all duration-200 ${
isActive
? "border-[rgb(var(--accent-300))] bg-[rgb(var(--accent-50))] text-[rgb(var(--accent-600))] shadow-[0_0_10px_rgba(var(--accent-500),0.2)] dark:border-[rgb(var(--accent-700))] dark:bg-[rgb(var(--accent-500)/0.15)] dark:text-[rgb(var(--accent-300))]"
: "border-white/60 bg-white/80 text-slate-600 hover:shadow-[0_0_12px_rgba(var(--accent-500),0.15)] dark:border-gray-700/80 dark:bg-gray-800/70 dark:text-gray-400"
}`}>
{children}
</span>
);
}
function DashboardIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 13h6V5H4v8zm10 6h6V5h-6v14zM4 19h6v-2H4v2zm0-4h6v-2H4v2zm10 4h6v-6h-6v6z" /></svg>;
}
function ResourcesIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2m18 0v-2a4 4 0 00-3-3.87M14 3.13a4 4 0 010 7.75M12 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>;
}
function ProjectsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M3 7h18M7 3v4m10-4v4M5 11h14v8H5z" /></svg>;
}
function EstimatesIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M8 7h8M8 11h4m-4 4h8M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg>;
}
function AllocationsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M8 7V3m8 4V3M4 11h16M5 5h14a1 1 0 011 1v13a1 1 0 01-1 1H5a1 1 0 01-1-1V6a1 1 0 011-1z" /></svg>;
}
function TimelineIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 6h16M4 12h10M4 18h7m9-8h-4m4 6h-7" /></svg>;
}
function StaffingIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 20l9-5-9-5-9 5 9 5zm0-10l9-5-9-5-9 5 9 5zm0 10v-10" /></svg>;
}
function VacationIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 19c3-4 6-6 8-6s5 2 8 6M7 12c.8-2.5 2.5-4 5-4s4.2 1.5 5 4M12 8V4" /></svg>;
}
function RolesIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M7 7h10v10H7zM4 4h4m8 0h4m-4 16h4M4 20h4" /></svg>;
}
function SkillsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 3l2.8 5.7 6.2.9-4.5 4.4 1 6.2L12 17.2 6.5 20.2l1-6.2L3 9.6l6.2-.9L12 3z" /></svg>;
}
function MarketplaceIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M3 3h18l-2 9H5L3 3zm0 0l-1-1m6 16a1 1 0 102 0 1 1 0 00-2 0zm10 0a1 1 0 102 0 1 1 0 00-2 0zM5 12h14" /></svg>;
}
function ChargeabilityIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M5 17l4-4 3 3 7-8M19 19H5V5" /></svg>;
}
function BenchIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M20 7H4a1 1 0 00-1 1v2h18V8a1 1 0 00-1-1zM3 10v4h18v-4M5 14v3m14-3v3M8 17h8" /></svg>;
}
function ReportBuilderIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>;
}
function GraphIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.5" strokeWidth={1.8} /><circle cx="18" cy="6" r="2.5" strokeWidth={1.8} /><circle cx="12" cy="18" r="2.5" strokeWidth={1.8} /><path strokeLinecap="round" strokeWidth={1.8} d="M8.5 7.5l2 7M15.5 7.5l-2 7M8.5 6h7" /></svg>;
}
function InsightsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg>;
}
function NotificationsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>;
}
function BroadcastIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg>;
}
function ActivityLogIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>;
}
function AdminIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 8a4 4 0 100 8 4 4 0 000-8zm8 4l-2.1.7a7.9 7.9 0 01-.6 1.5l1 2-2.1 2.1-2-1a7.9 7.9 0 01-1.5.6L12 20l-1.7-2.1a7.9 7.9 0 01-1.5-.6l-2 1-2.1-2.1 1-2a7.9 7.9 0 01-.6-1.5L4 12l2.1-1.7a7.9 7.9 0 01.6-1.5l-1-2 2.1-2.1 2 1a7.9 7.9 0 011.5-.6L12 4l1.7 2.1a7.9 7.9 0 011.5.6l2-1 2.1 2.1-1 2a7.9 7.9 0 01.6 1.5L20 12z" /></svg>;
}
function BlueprintIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>;
}
function ClientsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>;
}
function CountryIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>;
}
function OrgUnitIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" /></svg>;
}
function CategoryIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" /></svg>;
}
function LevelsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M3 4h18M3 8h12M3 12h8M3 16h14M3 20h10" /></svg>;
}
function ImportIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>;
}
function CalcRulesIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>;
}
function UsersIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>;
}
function SystemRolesIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>;
}
function SecurityIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>;
}
function SettingsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>;
}
function WebhooksIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>;
}
function ScenariosIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg>;
}
function CollapseIcon({ collapsed }: { collapsed: boolean }) {
return (
<svg
className={clsx("h-4 w-4 transition-transform duration-200", collapsed && "rotate-180")}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
);
}
function HamburgerIcon() {
return (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
);
}
function CloseIcon() {
return (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
);
}
type NavItem = { href: string; label: string; icon: ReactNode; roles: string[] };
type NavSection = { label: string; collapsed?: boolean; items: NavItem[] };
@@ -168,54 +64,159 @@ const navSections: NavSection[] = [
{
label: "Planning",
items: [
{ href: "/dashboard", label: "Dashboard", icon: <DashboardIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/timeline", label: "Timeline", icon: <TimelineIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/allocations", label: "Allocations", icon: <AllocationsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{
href: "/dashboard",
label: "Dashboard",
icon: <DashboardIcon />,
roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"],
},
{
href: "/timeline",
label: "Timeline",
icon: <TimelineIcon />,
roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"],
},
{
href: "/allocations",
label: "Allocations",
icon: <AllocationsIcon />,
roles: ["ADMIN", "MANAGER", "CONTROLLER"],
},
{ href: "/staffing", label: "Staffing", icon: <StaffingIcon />, roles: ["ADMIN", "MANAGER"] },
{ href: "/scenarios", label: "Scenarios", icon: <ScenariosIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/notifications", label: "Notifications", icon: <NotificationsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{
href: "/scenarios",
label: "Scenarios",
icon: <ScenariosIcon />,
roles: ["ADMIN", "MANAGER", "CONTROLLER"],
},
{
href: "/notifications",
label: "Notifications",
icon: <NotificationsIcon />,
roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"],
},
],
},
{
label: "Estimating",
collapsed: true,
items: [
{ href: "/estimates", label: "Estimates", icon: <EstimatesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/admin/rate-cards", label: "Rate Cards", icon: <EstimatesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/admin/effort-rules", label: "Effort Rules", icon: <EstimatesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/admin/experience-multipliers", label: "Exp. Multipliers", icon: <EstimatesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{
href: "/estimates",
label: "Estimates",
icon: <EstimatesIcon />,
roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"],
},
{
href: "/admin/rate-cards",
label: "Rate Cards",
icon: <EstimatesIcon />,
roles: ["ADMIN", "MANAGER", "CONTROLLER"],
},
{
href: "/admin/effort-rules",
label: "Effort Rules",
icon: <EstimatesIcon />,
roles: ["ADMIN", "MANAGER", "CONTROLLER"],
},
{
href: "/admin/experience-multipliers",
label: "Exp. Multipliers",
icon: <EstimatesIcon />,
roles: ["ADMIN", "MANAGER", "CONTROLLER"],
},
],
},
{
label: "Resources",
items: [
{ href: "/resources", label: "Resources", icon: <ResourcesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/bench", label: "Bench", icon: <BenchIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/projects", label: "Projects", icon: <ProjectsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/roles", label: "Roles", icon: <RolesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{
href: "/resources",
label: "Resources",
icon: <ResourcesIcon />,
roles: ["ADMIN", "MANAGER", "CONTROLLER"],
},
{
href: "/bench",
label: "Bench",
icon: <BenchIcon />,
roles: ["ADMIN", "MANAGER", "CONTROLLER"],
},
{
href: "/projects",
label: "Projects",
icon: <ProjectsIcon />,
roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"],
},
{
href: "/roles",
label: "Roles",
icon: <RolesIcon />,
roles: ["ADMIN", "MANAGER", "CONTROLLER"],
},
],
},
{
label: "Analytics",
items: [
{ href: "/analytics/skills", label: "Skills Hub", icon: <SkillsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/reports/chargeability", label: "Chargeability", icon: <ChargeabilityIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/reports/builder", label: "Report Builder", icon: <ReportBuilderIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/analytics/computation-graph", label: "Computation Graph", icon: <GraphIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/analytics/insights", label: "AI Insights", icon: <InsightsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{
href: "/analytics/skills",
label: "Skills Hub",
icon: <SkillsIcon />,
roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"],
},
{
href: "/reports/chargeability",
label: "Chargeability",
icon: <ChargeabilityIcon />,
roles: ["ADMIN", "MANAGER", "CONTROLLER"],
},
{
href: "/reports/builder",
label: "Report Builder",
icon: <ReportBuilderIcon />,
roles: ["ADMIN", "MANAGER", "CONTROLLER"],
},
{
href: "/analytics/computation-graph",
label: "Computation Graph",
icon: <GraphIcon />,
roles: ["ADMIN", "MANAGER", "CONTROLLER"],
},
{
href: "/analytics/insights",
label: "AI Insights",
icon: <InsightsIcon />,
roles: ["ADMIN", "MANAGER", "CONTROLLER"],
},
],
},
{
label: "Time Off",
items: [
{ href: "/vacations/my", label: "My Vacations", icon: <VacationIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/vacations", label: "Vacation Mgmt", icon: <VacationIcon />, roles: ["ADMIN", "MANAGER"] },
{
href: "/vacations/my",
label: "My Vacations",
icon: <VacationIcon />,
roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"],
},
{
href: "/vacations",
label: "Vacation Mgmt",
icon: <VacationIcon />,
roles: ["ADMIN", "MANAGER"],
},
],
},
{
label: "Account",
items: [
{ href: "/account/security", label: "Security", icon: <SecurityIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{
href: "/account/security",
label: "Security",
icon: <SecurityIcon />,
roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"],
},
],
},
];
@@ -310,7 +311,13 @@ function NavTooltip({
/* ------------------------------------------------------------------ */
/** Routes that benefit from eager prefetching (loaded while user reads current page). */
const PREFETCH_ROUTES = new Set(["/dashboard", "/timeline", "/projects", "/resources", "/allocations"]);
const PREFETCH_ROUTES = new Set([
"/dashboard",
"/timeline",
"/projects",
"/resources",
"/allocations",
]);
const NavItemLink = memo(function NavItemLink({
href,
@@ -425,14 +432,18 @@ function SidebarContent({
return (
<>
{/* Logo */}
<div className={clsx(
"border-b border-gray-200/80 dark:border-slate-800",
sidebarCollapsed ? "px-3 py-4" : "px-6 py-6",
)}>
<div className={clsx(
"inline-flex items-center rounded-2xl border border-brand-200/70 bg-gradient-to-br from-white to-brand-50 shadow-sm dark:border-white/[0.08] dark:from-[rgb(var(--surface-elevated))] dark:to-[rgb(var(--surface-elevated))]",
sidebarCollapsed ? "p-2" : "gap-3 px-4 py-3",
)}>
<div
className={clsx(
"border-b border-gray-200/80 dark:border-slate-800",
sidebarCollapsed ? "px-3 py-4" : "px-6 py-6",
)}
>
<div
className={clsx(
"inline-flex items-center rounded-2xl border border-brand-200/70 bg-gradient-to-br from-white to-brand-50 shadow-sm dark:border-white/[0.08] dark:from-[rgb(var(--surface-elevated))] dark:to-[rgb(var(--surface-elevated))]",
sidebarCollapsed ? "p-2" : "gap-3 px-4 py-3",
)}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-brand-600 text-white shadow-lg shadow-brand-600/25">
<DashboardIcon />
</div>
@@ -441,7 +452,9 @@ function SidebarContent({
<h1 className="font-display text-xl font-semibold text-gray-900 dark:text-gray-50">
Capa<span className="text-brand-600">Kraken</span>
</h1>
<p className="text-xs uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">Resource & Capacity Planning</p>
<p className="text-xs uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
Resource & Capacity Planning
</p>
</div>
)}
</div>
@@ -478,7 +491,12 @@ function SidebarContent({
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
)}
</button>
@@ -557,7 +575,9 @@ function SidebarContent({
onClick={() => toggleSection(entry.label)}
className="flex w-full items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium text-gray-500 transition-all hover:bg-gray-100/90 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/[0.05] dark:hover:text-gray-200"
>
<IconFrame><AdminIcon /></IconFrame>
<IconFrame>
<AdminIcon />
</IconFrame>
<span className="flex-1 text-left">{entry.label}</span>
<svg
className={clsx(
@@ -568,7 +588,12 @@ function SidebarContent({
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<AnimatePresence initial={false}>
@@ -632,18 +657,24 @@ function SidebarContent({
</div>
{/* Bottom actions */}
<div className={clsx(
"space-y-1 border-t border-gray-200/80 dark:border-slate-800",
sidebarCollapsed ? "p-2" : "p-4",
)}>
<div
className={clsx(
"space-y-1 border-t border-gray-200/80 dark:border-slate-800",
sidebarCollapsed ? "p-2" : "p-4",
)}
>
<NavTooltip label="Notifications" show={sidebarCollapsed}>
<div className={clsx(
"flex items-center",
sidebarCollapsed ? "justify-center px-2 py-2" : "gap-2 px-3 py-2",
)}>
<div
className={clsx(
"flex items-center",
sidebarCollapsed ? "justify-center px-2 py-2" : "gap-2 px-3 py-2",
)}
>
<NotificationBell />
{!sidebarCollapsed && (
<span className="text-xs uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500">Notifications</span>
<span className="text-xs uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500">
Notifications
</span>
)}
</div>
</NavTooltip>
@@ -658,8 +689,18 @@ function SidebarContent({
)}
>
<IconFrame>
<svg className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
<svg
className="h-4 w-4 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
</IconFrame>
{!sidebarCollapsed && <span>HartBOT</span>}
@@ -676,9 +717,24 @@ function SidebarContent({
)}
>
<IconFrame>
<svg className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<svg
className="h-4 w-4 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</IconFrame>
{!sidebarCollapsed && <span>Preferences</span>}
@@ -695,8 +751,18 @@ function SidebarContent({
)}
>
<IconFrame>
<svg className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
<svg
className="h-4 w-4 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
</IconFrame>
{!sidebarCollapsed && <span>Sign out</span>}
@@ -704,7 +770,10 @@ function SidebarContent({
</NavTooltip>
{/* Collapse toggle */}
<NavTooltip label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} show={sidebarCollapsed}>
<NavTooltip
label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
show={sidebarCollapsed}
>
<button
type="button"
onClick={onToggleCollapse}
@@ -720,7 +789,6 @@ function SidebarContent({
</button>
</NavTooltip>
</div>
</>
);
}
@@ -833,7 +901,13 @@ function MobileSidebar({
/* AppShell (main export) */
/* ------------------------------------------------------------------ */
export function AppShell({ children, userRole = "USER" }: { children: React.ReactNode; userRole?: string }) {
export function AppShell({
children,
userRole = "USER",
}: {
children: React.ReactNode;
userRole?: string;
}) {
const [chatOpen, setChatOpen] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
@@ -927,7 +1001,12 @@ export function AppShell({ children, userRole = "USER" }: { children: React.Reac
title="HartBOT"
>
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
</button>
)}
@@ -0,0 +1,468 @@
import type { ReactNode } from "react";
import { clsx } from "clsx";
export function IconFrame({ children, isActive }: { children: ReactNode; isActive?: boolean }) {
return (
<span
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border shadow-sm transition-all duration-200 ${
isActive
? "border-[rgb(var(--accent-300))] bg-[rgb(var(--accent-50))] text-[rgb(var(--accent-600))] shadow-[0_0_10px_rgba(var(--accent-500),0.2)] dark:border-[rgb(var(--accent-700))] dark:bg-[rgb(var(--accent-500)/0.15)] dark:text-[rgb(var(--accent-300))]"
: "border-white/60 bg-white/80 text-slate-600 hover:shadow-[0_0_12px_rgba(var(--accent-500),0.15)] dark:border-gray-700/80 dark:bg-gray-800/70 dark:text-gray-400"
}`}
>
{children}
</span>
);
}
export function DashboardIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M4 13h6V5H4v8zm10 6h6V5h-6v14zM4 19h6v-2H4v2zm0-4h6v-2H4v2zm10 4h6v-6h-6v6z"
/>
</svg>
);
}
export function ResourcesIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2m18 0v-2a4 4 0 00-3-3.87M14 3.13a4 4 0 010 7.75M12 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
);
}
export function ProjectsIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M3 7h18M7 3v4m10-4v4M5 11h14v8H5z"
/>
</svg>
);
}
export function EstimatesIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M8 7h8M8 11h4m-4 4h8M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z"
/>
</svg>
);
}
export function AllocationsIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M8 7V3m8 4V3M4 11h16M5 5h14a1 1 0 011 1v13a1 1 0 01-1 1H5a1 1 0 01-1-1V6a1 1 0 011-1z"
/>
</svg>
);
}
export function TimelineIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M4 6h16M4 12h10M4 18h7m9-8h-4m4 6h-7"
/>
</svg>
);
}
export function StaffingIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M12 20l9-5-9-5-9 5 9 5zm0-10l9-5-9-5-9 5 9 5zm0 10v-10"
/>
</svg>
);
}
export function VacationIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M4 19c3-4 6-6 8-6s5 2 8 6M7 12c.8-2.5 2.5-4 5-4s4.2 1.5 5 4M12 8V4"
/>
</svg>
);
}
export function RolesIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M7 7h10v10H7zM4 4h4m8 0h4m-4 16h4M4 20h4"
/>
</svg>
);
}
export function SkillsIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M12 3l2.8 5.7 6.2.9-4.5 4.4 1 6.2L12 17.2 6.5 20.2l1-6.2L3 9.6l6.2-.9L12 3z"
/>
</svg>
);
}
export function MarketplaceIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M3 3h18l-2 9H5L3 3zm0 0l-1-1m6 16a1 1 0 102 0 1 1 0 00-2 0zm10 0a1 1 0 102 0 1 1 0 00-2 0zM5 12h14"
/>
</svg>
);
}
export function ChargeabilityIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M5 17l4-4 3 3 7-8M19 19H5V5"
/>
</svg>
);
}
export function BenchIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M20 7H4a1 1 0 00-1 1v2h18V8a1 1 0 00-1-1zM3 10v4h18v-4M5 14v3m14-3v3M8 17h8"
/>
</svg>
);
}
export function ReportBuilderIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
);
}
export function GraphIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="6" cy="6" r="2.5" strokeWidth={1.8} />
<circle cx="18" cy="6" r="2.5" strokeWidth={1.8} />
<circle cx="12" cy="18" r="2.5" strokeWidth={1.8} />
<path strokeLinecap="round" strokeWidth={1.8} d="M8.5 7.5l2 7M15.5 7.5l-2 7M8.5 6h7" />
</svg>
);
}
export function InsightsIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
);
}
export function NotificationsIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
);
}
export function BroadcastIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"
/>
</svg>
);
}
export function ActivityLogIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
}
export function AdminIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M12 8a4 4 0 100 8 4 4 0 000-8zm8 4l-2.1.7a7.9 7.9 0 01-.6 1.5l1 2-2.1 2.1-2-1a7.9 7.9 0 01-1.5.6L12 20l-1.7-2.1a7.9 7.9 0 01-1.5-.6l-2 1-2.1-2.1 1-2a7.9 7.9 0 01-.6-1.5L4 12l2.1-1.7a7.9 7.9 0 01.6-1.5l-1-2 2.1-2.1 2 1a7.9 7.9 0 011.5-.6L12 4l1.7 2.1a7.9 7.9 0 011.5.6l2-1 2.1 2.1-1 2a7.9 7.9 0 01.6 1.5L20 12z"
/>
</svg>
);
}
export function BlueprintIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
);
}
export function ClientsIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
);
}
export function CountryIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
}
export function OrgUnitIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
/>
</svg>
);
}
export function CategoryIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
);
}
export function LevelsIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M3 4h18M3 8h12M3 12h8M3 16h14M3 20h10"
/>
</svg>
);
}
export function ImportIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
);
}
export function CalcRulesIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
);
}
export function UsersIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
);
}
export function SystemRolesIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
);
}
export function SecurityIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
);
}
export function SettingsIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
);
}
export function WebhooksIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
);
}
export function ScenariosIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
/>
</svg>
);
}
export function CollapseIcon({ collapsed }: { collapsed: boolean }) {
return (
<svg
className={clsx("h-4 w-4 transition-transform duration-200", collapsed && "rotate-180")}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
/>
</svg>
);
}
export function HamburgerIcon() {
return (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
);
}
export function CloseIcon() {
return (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
);
}
+206 -305
View File
@@ -1,6 +1,6 @@
"use client";
import { Fragment, useState, useMemo, useCallback } from "react";
import { useState, useMemo, useCallback } from "react";
import { keepPreviousData } from "@tanstack/react-query";
import { trpc } from "~/lib/trpc/client.js";
import { clsx } from "clsx";
@@ -9,6 +9,7 @@ import {
buildReportWorkbookSheets,
type ReportExplainability,
} from "./reportBuilderExplainability.js";
import { ReportResultsPanel } from "./ReportResultsPanel.js";
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -174,7 +175,8 @@ function normalizeTemplateConfig(config: TemplateConfig): TemplateConfig {
.sort((left, right) =>
`${left.field}:${left.op}:${left.value}`.localeCompare(
`${right.field}:${right.op}:${right.value}`,
)),
),
),
...(config.groupBy ? { groupBy: config.groupBy } : {}),
...(config.sortBy ? { sortBy: config.sortBy } : {}),
...(config.sortBy ? { sortDir: config.sortDir ?? "asc" } : {}),
@@ -186,19 +188,24 @@ function serializeTemplateConfig(config: TemplateConfig): string {
return JSON.stringify(normalizeTemplateConfig(config));
}
function buildResourceMonthCompleteness(columns: Iterable<string>): ResourceMonthTemplateCompleteness {
function buildResourceMonthCompleteness(
columns: Iterable<string>,
): ResourceMonthTemplateCompleteness {
const selectedColumns = new Set(columns);
const missingRecommendedColumns = RESOURCE_MONTH_RECOMMENDED_COLUMNS
.filter((column) => !selectedColumns.has(column));
const missingMinimumAuditColumns = RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS
.filter((column) => !selectedColumns.has(column));
const missingRecommendedColumns = RESOURCE_MONTH_RECOMMENDED_COLUMNS.filter(
(column) => !selectedColumns.has(column),
);
const missingMinimumAuditColumns = RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.filter(
(column) => !selectedColumns.has(column),
);
return {
scope: "resource_month",
isAuditReady: missingMinimumAuditColumns.length === 0,
isRecommendedComplete: missingRecommendedColumns.length === 0,
recommendedColumnCount: RESOURCE_MONTH_RECOMMENDED_COLUMNS.length,
selectedRecommendedColumnCount: RESOURCE_MONTH_RECOMMENDED_COLUMNS.length - missingRecommendedColumns.length,
selectedRecommendedColumnCount:
RESOURCE_MONTH_RECOMMENDED_COLUMNS.length - missingRecommendedColumns.length,
minimumAuditColumnCount: RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.length,
selectedMinimumAuditColumnCount:
RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.length - missingMinimumAuditColumns.length,
@@ -293,10 +300,10 @@ export function ReportBuilder() {
}, [runQuery, entity, selectedColumns, filters, groupBy, sortBy, sortDir, page, periodMonth]);
// Fetch report data
const reportQuery = trpc.report.getReportData.useQuery(
queryInput!,
{ enabled: queryInput !== null, placeholderData: keepPreviousData },
);
const reportQuery = trpc.report.getReportData.useQuery(queryInput!, {
enabled: queryInput !== null,
placeholderData: keepPreviousData,
});
const exportMutation = trpc.report.exportReport.useMutation();
@@ -319,22 +326,27 @@ export function ReportBuilder() {
setPage(0);
}, []);
const applyTemplate = useCallback((template: ReportTemplateSummary) => {
const config = template.config;
setSelectedTemplateId(template.id);
setTemplateName(template.name);
setTemplateDescription(template.description ?? "");
setTemplateIsShared(template.isShared);
setEntity(config.entity);
setSelectedColumns(new Set(config.columns));
setFilters(config.filters.map((filter: Omit<FilterRow, "id">) => ({ id: generateId(), ...filter })));
setGroupBy(config.groupBy ?? "");
setSortBy(config.sortBy ?? "");
setSortDir(config.sortDir ?? "asc");
setPeriodMonth(config.periodMonth ?? getCurrentPeriodMonth());
setRunQuery(false);
setPage(0);
}, [templatesQuery.data]);
const applyTemplate = useCallback(
(template: ReportTemplateSummary) => {
const config = template.config;
setSelectedTemplateId(template.id);
setTemplateName(template.name);
setTemplateDescription(template.description ?? "");
setTemplateIsShared(template.isShared);
setEntity(config.entity);
setSelectedColumns(new Set(config.columns));
setFilters(
config.filters.map((filter: Omit<FilterRow, "id">) => ({ id: generateId(), ...filter })),
);
setGroupBy(config.groupBy ?? "");
setSortBy(config.sortBy ?? "");
setSortDir(config.sortDir ?? "asc");
setPeriodMonth(config.periodMonth ?? getCurrentPeriodMonth());
setRunQuery(false);
setPage(0);
},
[templatesQuery.data],
);
const applyBlueprint = useCallback((blueprint: ReportBlueprint) => {
const config = blueprint.config;
@@ -344,7 +356,9 @@ export function ReportBuilder() {
setTemplateIsShared(false);
setEntity(config.entity);
setSelectedColumns(new Set(config.columns));
setFilters(config.filters.map((filter: Omit<FilterRow, "id">) => ({ id: generateId(), ...filter })));
setFilters(
config.filters.map((filter: Omit<FilterRow, "id">) => ({ id: generateId(), ...filter })),
);
setGroupBy(config.groupBy ?? "");
setSortBy(config.sortBy ?? "");
setSortDir(config.sortDir ?? "asc");
@@ -393,23 +407,26 @@ export function ReportBuilder() {
setRunQuery(true);
}, []);
const handleSort = useCallback((column: string) => {
if (!column.includes(".")) {
if (sortBy === column) {
setSortDir((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortBy(column);
setSortDir("asc");
const handleSort = useCallback(
(column: string) => {
if (!column.includes(".")) {
if (sortBy === column) {
setSortDir((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortBy(column);
setSortDir("asc");
}
// Re-run with new sort
setRunQuery(true);
}
// Re-run with new sort
setRunQuery(true);
}
}, [sortBy]);
},
[sortBy],
);
const handleExport = useCallback(async () => {
if (selectedColumns.size === 0) return;
try {
const result = await exportMutation.mutateAsync({
const result = (await exportMutation.mutateAsync({
entity,
columns: Array.from(selectedColumns),
filters: filters
@@ -419,7 +436,7 @@ export function ReportBuilder() {
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
limit: 5000,
}) as ExportReportResult;
})) as ExportReportResult;
if (result.explainability?.entity === "resource_month") {
await downloadWorkbookSheets(
@@ -447,7 +464,17 @@ export function ReportBuilder() {
} catch {
// Error handled by tRPC
}
}, [columnLabelMap, entity, selectedColumns, filters, groupBy, sortBy, sortDir, exportMutation, periodMonth]);
}, [
columnLabelMap,
entity,
selectedColumns,
filters,
groupBy,
sortBy,
sortDir,
exportMutation,
periodMonth,
]);
const handleSaveTemplate = useCallback(async () => {
if (!templateName.trim() || selectedColumns.size === 0) return;
@@ -502,48 +529,57 @@ export function ReportBuilder() {
const selectedTemplate =
templates.find((template: ReportTemplateSummary) => template.id === selectedTemplateId) ?? null;
const resourceMonthBlueprints = useMemo(
() => ((blueprintsQuery.data ?? []) as ReportBlueprint[]).filter((blueprint) => blueprint.entity === entity),
() =>
((blueprintsQuery.data ?? []) as ReportBlueprint[]).filter(
(blueprint) => blueprint.entity === entity,
),
[blueprintsQuery.data, entity],
);
const recommendedColumnSet = useMemo(
() => entity === "resource_month" ? new Set<string>(RESOURCE_MONTH_RECOMMENDED_COLUMNS) : new Set<string>(),
() =>
entity === "resource_month"
? new Set<string>(RESOURCE_MONTH_RECOMMENDED_COLUMNS)
: new Set<string>(),
[entity],
);
const currentTemplateConfig = useMemo<TemplateConfig>(() => ({
entity,
columns: Array.from(selectedColumns),
filters: filters
.filter((filter) => filter.field && filter.value)
.map(({ field, op, value }) => ({ field, op, value })),
...(entity === "resource_month" ? { periodMonth } : {}),
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
}), [entity, filters, groupBy, periodMonth, selectedColumns, sortBy, sortDir]);
const currentTemplateConfig = useMemo<TemplateConfig>(
() => ({
entity,
columns: Array.from(selectedColumns),
filters: filters
.filter((filter) => filter.field && filter.value)
.map(({ field, op, value }) => ({ field, op, value })),
...(entity === "resource_month" ? { periodMonth } : {}),
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
}),
[entity, filters, groupBy, periodMonth, selectedColumns, sortBy, sortDir],
);
const selectedTemplateFingerprint = selectedTemplate
? serializeTemplateConfig(selectedTemplate.config)
: null;
const currentTemplateFingerprint = serializeTemplateConfig(currentTemplateConfig);
const selectedTemplateMetadataFingerprint = selectedTemplate
? JSON.stringify({
name: selectedTemplate.name,
description: selectedTemplate.description ?? "",
isShared: selectedTemplate.isShared,
})
name: selectedTemplate.name,
description: selectedTemplate.description ?? "",
isShared: selectedTemplate.isShared,
})
: null;
const currentTemplateMetadataFingerprint = JSON.stringify({
name: templateName.trim(),
description: templateDescription.trim(),
isShared: templateIsShared,
});
const hasTemplateDraftChanges = selectedTemplateFingerprint !== null
&& (
selectedTemplateFingerprint !== currentTemplateFingerprint
|| selectedTemplateMetadataFingerprint !== currentTemplateMetadataFingerprint
);
const hasUnsavedLocalView = selectedTemplate === null
&& (selectedColumns.size > 0 || filters.some((filter) => filter.field && filter.value));
const hasTemplateDraftChanges =
selectedTemplateFingerprint !== null &&
(selectedTemplateFingerprint !== currentTemplateFingerprint ||
selectedTemplateMetadataFingerprint !== currentTemplateMetadataFingerprint);
const hasUnsavedLocalView =
selectedTemplate === null &&
(selectedColumns.size > 0 || filters.some((filter) => filter.field && filter.value));
const currentResourceMonthCompleteness = useMemo(
() => entity === "resource_month" ? buildResourceMonthCompleteness(selectedColumns) : null,
() => (entity === "resource_month" ? buildResourceMonthCompleteness(selectedColumns) : null),
[entity, selectedColumns],
);
@@ -552,19 +588,14 @@ export function ReportBuilder() {
return null;
}
if (
selectedTemplate
&& !hasTemplateDraftChanges
&& selectedTemplate.completeness?.scope === "resource_month"
selectedTemplate &&
!hasTemplateDraftChanges &&
selectedTemplate.completeness?.scope === "resource_month"
) {
return selectedTemplate.completeness;
}
return currentResourceMonthCompleteness;
}, [currentResourceMonthCompleteness, entity, hasTemplateDraftChanges, selectedTemplate]);
const groupStartByIndex = useMemo(
() => new Map(reportGroups.map((group) => [group.startIndex, group] as const)),
[reportGroups],
);
// ─── Render ───────────────────────────────────────────────────────────
return (
@@ -589,7 +620,9 @@ export function ReportBuilder() {
onChange={(e) => {
const nextId = e.target.value;
setSelectedTemplateId(nextId);
const template = templates.find((entry: ReportTemplateSummary) => entry.id === nextId);
const template = templates.find(
(entry: ReportTemplateSummary) => entry.id === nextId,
);
if (template) {
applyTemplate(template);
}
@@ -599,7 +632,8 @@ export function ReportBuilder() {
<option value="">Unsaved view</option>
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}{template.isShared && !template.isOwner ? " · shared" : ""}
{template.name}
{template.isShared && !template.isOwner ? " · shared" : ""}
</option>
))}
</select>
@@ -634,7 +668,8 @@ export function ReportBuilder() {
)}
{selectedTemplate && hasTemplateDraftChanges ? (
<p className="text-xs text-amber-700 dark:text-amber-300">
The current builder state differs from the saved template. Use Update template to persist these local changes.
The current builder state differs from the saved template. Use Update template to
persist these local changes.
</p>
) : null}
</div>
@@ -674,10 +709,16 @@ export function ReportBuilder() {
<button
type="button"
onClick={() => void handleSaveTemplate()}
disabled={!templateName.trim() || selectedColumns.size === 0 || saveTemplateMutation.isPending}
disabled={
!templateName.trim() || selectedColumns.size === 0 || saveTemplateMutation.isPending
}
className="self-end rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{saveTemplateMutation.isPending ? "Saving..." : selectedTemplateId ? "Update template" : "Save template"}
{saveTemplateMutation.isPending
? "Saving..."
: selectedTemplateId
? "Update template"
: "Save template"}
</button>
<button
type="button"
@@ -726,7 +767,9 @@ export function ReportBuilder() {
/>
</div>
<p className="max-w-2xl text-sm text-emerald-900/80 dark:text-emerald-200/80">
Resource Months uses the CapaKraken holiday and absence logic directly. SAH, booked hours and chargeability are calculated per resource and month with country, state and city context.
Resource Months uses the CapaKraken holiday and absence logic directly. SAH,
booked hours and chargeability are calculated per resource and month with country,
state and city context.
</p>
</div>
@@ -760,37 +803,46 @@ export function ReportBuilder() {
: "bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-200",
)}
>
{displayedResourceMonthCompleteness.isAuditReady ? "Audit ready" : "Audit gap"}
{displayedResourceMonthCompleteness.isAuditReady
? "Audit ready"
: "Audit gap"}
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
{displayedResourceMonthCompleteness.selectedMinimumAuditColumnCount}/
{displayedResourceMonthCompleteness.minimumAuditColumnCount} minimum audit columns
{displayedResourceMonthCompleteness.minimumAuditColumnCount} minimum audit
columns
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
{displayedResourceMonthCompleteness.selectedRecommendedColumnCount}/
{displayedResourceMonthCompleteness.recommendedColumnCount} recommended columns
{displayedResourceMonthCompleteness.recommendedColumnCount} recommended
columns
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] text-emerald-900/80 dark:bg-slate-950 dark:text-emerald-200/80">
{selectedTemplate && !hasTemplateDraftChanges ? "Saved template status" : "Current builder status"}
{selectedTemplate && !hasTemplateDraftChanges
? "Saved template status"
: "Current builder status"}
</span>
</div>
{displayedResourceMonthCompleteness.missingMinimumAuditColumns.length > 0 ? (
<p className="mt-3 text-xs text-amber-800 dark:text-amber-200">
Missing audit/export basis columns: {summarizeMissingColumns(
Missing audit/export basis columns:{" "}
{summarizeMissingColumns(
displayedResourceMonthCompleteness.missingMinimumAuditColumns,
columnLabelMap,
)}
</p>
) : displayedResourceMonthCompleteness.missingRecommendedColumns.length > 0 ? (
<p className="mt-3 text-xs text-emerald-900/80 dark:text-emerald-200/80">
Audit-ready, but still missing recommended basis columns: {summarizeMissingColumns(
Audit-ready, but still missing recommended basis columns:{" "}
{summarizeMissingColumns(
displayedResourceMonthCompleteness.missingRecommendedColumns,
columnLabelMap,
)}
</p>
) : (
<p className="mt-3 text-xs text-emerald-900/80 dark:text-emerald-200/80">
This view includes the full recommended audit/export basis set for monthly SAH and chargeability checks.
This view includes the full recommended audit/export basis set for monthly
SAH and chargeability checks.
</p>
)}
</div>
@@ -816,13 +868,17 @@ export function ReportBuilder() {
))}
</div>
<p className="mt-3 text-xs text-emerald-900/75 dark:text-emerald-200/75">
Formula reference: base available hours - holiday deduction - absence deduction = monthly SAH. Chargeability uses booked hours divided by monthly SAH.
Formula reference: base available hours - holiday deduction - absence deduction =
monthly SAH. Chargeability uses booked hours divided by monthly SAH.
</p>
<p className="mt-2 text-xs text-emerald-900/75 dark:text-emerald-200/75">
Export recommendation: include both basis columns and computed metrics in the CSV. That keeps Excel as a review layer instead of rebuilding CapaKraken logic outside the product.
Export recommendation: include both basis columns and computed metrics in the CSV.
That keeps Excel as a review layer instead of rebuilding CapaKraken logic outside
the product.
</p>
<p className="mt-2 text-xs text-emerald-900/75 dark:text-emerald-200/75">
Minimum audit set: month, location context, SAH, holiday deductions, absence deductions, target hours, booked hours and unassigned hours.
Minimum audit set: month, location context, SAH, holiday deductions, absence
deductions, target hours, booked hours and unassigned hours.
</p>
</div>
</div>
@@ -886,16 +942,19 @@ export function ReportBuilder() {
{/* Filter Builder */}
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Filters
</label>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Filters</label>
<button
type="button"
onClick={addFilter}
className="flex items-center gap-1 text-xs font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400"
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
Add filter
</button>
@@ -913,7 +972,9 @@ export function ReportBuilder() {
className="w-44 rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300"
>
{scalarColumns.map((col) => (
<option key={col.key} value={col.key}>{col.label}</option>
<option key={col.key} value={col.key}>
{col.label}
</option>
))}
</select>
@@ -924,7 +985,9 @@ export function ReportBuilder() {
className="w-36 rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300"
>
{OPERATOR_OPTIONS.map((op) => (
<option key={op.value} value={op.value}>{op.label}</option>
<option key={op.value} value={op.value}>
{op.label}
</option>
))}
</select>
@@ -945,7 +1008,12 @@ export function ReportBuilder() {
title="Remove filter"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
@@ -967,7 +1035,9 @@ export function ReportBuilder() {
>
<option value="">None</option>
{scalarColumns.map((col) => (
<option key={col.key} value={col.key}>{col.label}</option>
<option key={col.key} value={col.key}>
{col.label}
</option>
))}
</select>
</div>
@@ -982,7 +1052,9 @@ export function ReportBuilder() {
>
<option value="">Default</option>
{scalarColumns.map((col) => (
<option key={col.key} value={col.key}>{col.label}</option>
<option key={col.key} value={col.key}>
{col.label}
</option>
))}
</select>
</div>
@@ -1010,222 +1082,51 @@ export function ReportBuilder() {
className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Run Report
</button>
{selectedColumns.size === 0 && (
<span className="text-sm text-gray-400 dark:text-gray-500">Select at least one column</span>
<span className="text-sm text-gray-400 dark:text-gray-500">
Select at least one column
</span>
)}
</div>
</div>
{/* Results */}
{runQuery && (
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950">
{/* Results Header */}
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-slate-800">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Results</h2>
{!isLoading && (
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-slate-800 dark:text-gray-400">
{totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""}
</span>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
{reportExplainability?.entity === "resource_month"
? "Exports include the report sheet plus an Explainability sheet with location, holiday, absence and SAH basis."
: "CSV exports include the selected basis columns and computed CapaKraken metrics exactly as shown here."}
</p>
{groupBy && rows.length > 0 ? (
<p className="text-xs text-gray-500 dark:text-gray-400">
Grouped by {columnLabelMap.get(groupBy) ?? groupBy} with page-local section headers.
</p>
) : null}
</div>
<button
type="button"
onClick={() => void handleExport()}
disabled={exportMutation.isPending || totalCount === 0}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300 dark:hover:bg-slate-800"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{exportMutation.isPending ? "Exporting..." : reportExplainability?.entity === "resource_month" ? "Export XLSX" : "Export CSV"}
</button>
</div>
{reportExplainability?.entity === "resource_month" ? (
<div className="border-b border-emerald-100 bg-emerald-50/70 px-6 py-4 text-sm dark:border-emerald-950/60 dark:bg-emerald-950/20">
<div className="flex flex-wrap gap-2">
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
Month: {reportExplainability.periodMonth ?? "current"}
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
Location: {(reportExplainability.locationContextColumns.length > 0
? reportExplainability.locationContextColumns
: ["none"]).map((column) => columnLabelMap.get(column) ?? column).join(", ")}
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
Holidays: {(reportExplainability.holidayMetricColumns.length > 0
? reportExplainability.holidayMetricColumns
: ["none"]).map((column) => columnLabelMap.get(column) ?? column).join(", ")}
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
Absences: {(reportExplainability.absenceMetricColumns.length > 0
? reportExplainability.absenceMetricColumns
: ["none"]).map((column) => columnLabelMap.get(column) ?? column).join(", ")}
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
Capacity: {(reportExplainability.capacityMetricColumns.length > 0
? reportExplainability.capacityMetricColumns
: ["none"]).map((column) => columnLabelMap.get(column) ?? column).join(", ")}
</span>
</div>
<p className="mt-3 text-xs text-emerald-900/80 dark:text-emerald-200/80">
{reportExplainability.notes.join(" ")}
</p>
{reportExplainability.missingRecommendedColumns.length > 0 ? (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
Missing recommended audit columns: {summarizeMissingColumns(
reportExplainability.missingRecommendedColumns,
columnLabelMap,
)}
</p>
) : null}
</div>
) : null}
{/* Table */}
<div className="overflow-x-auto">
{isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-600 border-t-transparent" />
</div>
) : rows.length === 0 ? (
<div className="py-16 text-center text-sm text-gray-400 dark:text-gray-500">
No data found. Try adjusting your filters.
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50/80 dark:border-slate-800 dark:bg-slate-900/50">
{outputColumns.map((col) => {
const isSortable = !col.includes(".");
const isSorted = sortBy === col;
return (
<th
key={col}
onClick={isSortable ? () => handleSort(col) : undefined}
className={clsx(
"whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400",
isSortable && "cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200",
)}
>
<span className="inline-flex items-center gap-1">
{columnLabelMap.get(col) ?? col}
{isSorted && (
<svg className={clsx("h-3 w-3", sortDir === "desc" && "rotate-180")} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
)}
</span>
</th>
);
})}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-800/60">
{rows.map((row, idx) => {
const group = groupStartByIndex.get(idx);
return (
<Fragment key={`grouped-row:${String(row.id ?? idx)}:${idx}`}>
{group ? (
<tr key={`${group.key}:${idx}`} className="bg-brand-50/70 dark:bg-brand-950/20">
<td
colSpan={outputColumns.length}
className="px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-brand-700 dark:text-brand-200"
>
{columnLabelMap.get(groupBy) ?? groupBy}: {group.label} · {group.rowCount} row{group.rowCount === 1 ? "" : "s"}
</td>
</tr>
) : null}
<tr
key={(row.id as string) ?? idx}
className="transition-colors hover:bg-gray-50/60 dark:hover:bg-slate-900/40"
>
{outputColumns.map((col) => (
<td
key={col}
className="whitespace-nowrap px-4 py-2.5 text-gray-700 dark:text-gray-300"
>
{formatCellValue(row[col])}
</td>
))}
</tr>
</Fragment>
);
})}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-gray-200 px-6 py-3 dark:border-slate-800">
<span className="text-sm text-gray-500 dark:text-gray-400">
Page {page + 1} of {totalPages}
</span>
<div className="flex gap-2">
<button
type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-gray-300 dark:hover:bg-slate-800"
>
Previous
</button>
<button
type="button"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-gray-300 dark:hover:bg-slate-800"
>
Next
</button>
</div>
</div>
)}
</div>
<ReportResultsPanel
rows={rows}
totalCount={totalCount}
outputColumns={outputColumns}
groups={reportGroups}
explainability={reportExplainability}
groupBy={groupBy}
sortBy={sortBy}
sortDir={sortDir}
isLoading={isLoading}
page={page}
pageSize={PAGE_SIZE}
columnLabelMap={columnLabelMap}
exportPending={exportMutation.isPending}
onSort={handleSort}
onExport={() => void handleExport()}
onPageChange={setPage}
summarizeMissing={summarizeMissingColumns}
/>
)}
</div>
);
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function formatCellValue(value: unknown): string {
if (value === null || value === undefined) return "--";
if (typeof value === "boolean") return value ? "Yes" : "No";
if (typeof value === "string") {
// ISO date detection
if (/^\d{4}-\d{2}-\d{2}T/.test(value)) {
return new Date(value).toLocaleDateString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
return value;
}
if (typeof value === "number") {
return value.toLocaleString("de-DE");
}
return String(value);
}
@@ -0,0 +1,293 @@
import { Fragment } from "react";
import { clsx } from "clsx";
import type { ReportExplainability } from "./reportBuilderExplainability.js";
interface ReportGroupSummary {
key: string;
label: string;
rowCount: number;
startIndex: number;
}
type ReportResultsPanelProps = {
rows: Record<string, unknown>[];
totalCount: number;
outputColumns: string[];
groups: ReportGroupSummary[];
explainability: ReportExplainability | undefined;
groupBy: string;
sortBy: string;
sortDir: "asc" | "desc";
isLoading: boolean;
page: number;
pageSize: number;
columnLabelMap: Map<string, string>;
exportPending: boolean;
onSort: (column: string) => void;
onExport: () => void;
onPageChange: (page: number) => void;
summarizeMissing: (columns: string[], labelMap: Map<string, string>, limit?: number) => string;
};
function formatCellValue(value: unknown): string {
if (value === null || value === undefined) return "--";
if (typeof value === "boolean") return value ? "Yes" : "No";
if (typeof value === "string") {
if (/^\d{4}-\d{2}-\d{2}T/.test(value)) {
return new Date(value).toLocaleDateString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
return value;
}
if (typeof value === "number") {
return value.toLocaleString("de-DE");
}
return String(value);
}
export function ReportResultsPanel({
rows,
totalCount,
outputColumns,
groups,
explainability,
groupBy,
sortBy,
sortDir,
isLoading,
page,
pageSize,
columnLabelMap,
exportPending,
onSort,
onExport,
onPageChange,
summarizeMissing,
}: ReportResultsPanelProps) {
const totalPages = Math.ceil(totalCount / pageSize);
const groupStartByIndex = new Map(groups.map((group) => [group.startIndex, group] as const));
return (
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950">
{/* Results Header */}
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-slate-800">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Results</h2>
{!isLoading && (
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-slate-800 dark:text-gray-400">
{totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""}
</span>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
{explainability?.entity === "resource_month"
? "Exports include the report sheet plus an Explainability sheet with location, holiday, absence and SAH basis."
: "CSV exports include the selected basis columns and computed CapaKraken metrics exactly as shown here."}
</p>
{groupBy && rows.length > 0 ? (
<p className="text-xs text-gray-500 dark:text-gray-400">
Grouped by {columnLabelMap.get(groupBy) ?? groupBy} with page-local section headers.
</p>
) : null}
</div>
<button
type="button"
onClick={onExport}
disabled={exportPending || totalCount === 0}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300 dark:hover:bg-slate-800"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
{exportPending
? "Exporting..."
: explainability?.entity === "resource_month"
? "Export XLSX"
: "Export CSV"}
</button>
</div>
{explainability?.entity === "resource_month" ? (
<div className="border-b border-emerald-100 bg-emerald-50/70 px-6 py-4 text-sm dark:border-emerald-950/60 dark:bg-emerald-950/20">
<div className="flex flex-wrap gap-2">
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
Month: {explainability.periodMonth ?? "current"}
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
Location:{" "}
{(explainability.locationContextColumns.length > 0
? explainability.locationContextColumns
: ["none"]
)
.map((column) => columnLabelMap.get(column) ?? column)
.join(", ")}
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
Holidays:{" "}
{(explainability.holidayMetricColumns.length > 0
? explainability.holidayMetricColumns
: ["none"]
)
.map((column) => columnLabelMap.get(column) ?? column)
.join(", ")}
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
Absences:{" "}
{(explainability.absenceMetricColumns.length > 0
? explainability.absenceMetricColumns
: ["none"]
)
.map((column) => columnLabelMap.get(column) ?? column)
.join(", ")}
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
Capacity:{" "}
{(explainability.capacityMetricColumns.length > 0
? explainability.capacityMetricColumns
: ["none"]
)
.map((column) => columnLabelMap.get(column) ?? column)
.join(", ")}
</span>
</div>
<p className="mt-3 text-xs text-emerald-900/80 dark:text-emerald-200/80">
{explainability.notes.join(" ")}
</p>
{explainability.missingRecommendedColumns.length > 0 ? (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
Missing recommended audit columns:{" "}
{summarizeMissing(explainability.missingRecommendedColumns, columnLabelMap)}
</p>
) : null}
</div>
) : null}
{/* Table */}
<div className="overflow-x-auto">
{isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-600 border-t-transparent" />
</div>
) : rows.length === 0 ? (
<div className="py-16 text-center text-sm text-gray-400 dark:text-gray-500">
No data found. Try adjusting your filters.
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50/80 dark:border-slate-800 dark:bg-slate-900/50">
{outputColumns.map((col) => {
const isSortable = !col.includes(".");
const isSorted = sortBy === col;
return (
<th
key={col}
onClick={isSortable ? () => onSort(col) : undefined}
className={clsx(
"whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400",
isSortable &&
"cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200",
)}
>
<span className="inline-flex items-center gap-1">
{columnLabelMap.get(col) ?? col}
{isSorted && (
<svg
className={clsx("h-3 w-3", sortDir === "desc" && "rotate-180")}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 15l7-7 7 7"
/>
</svg>
)}
</span>
</th>
);
})}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-800/60">
{rows.map((row, idx) => {
const group = groupStartByIndex.get(idx);
return (
<Fragment key={`grouped-row:${String(row.id ?? idx)}:${idx}`}>
{group ? (
<tr
key={`${group.key}:${idx}`}
className="bg-brand-50/70 dark:bg-brand-950/20"
>
<td
colSpan={outputColumns.length}
className="px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-brand-700 dark:text-brand-200"
>
{columnLabelMap.get(groupBy) ?? groupBy}: {group.label} · {group.rowCount}{" "}
row{group.rowCount === 1 ? "" : "s"}
</td>
</tr>
) : null}
<tr
key={(row.id as string) ?? idx}
className="transition-colors hover:bg-gray-50/60 dark:hover:bg-slate-900/40"
>
{outputColumns.map((col) => (
<td
key={col}
className="whitespace-nowrap px-4 py-2.5 text-gray-700 dark:text-gray-300"
>
{formatCellValue(row[col])}
</td>
))}
</tr>
</Fragment>
);
})}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-gray-200 px-6 py-3 dark:border-slate-800">
<span className="text-sm text-gray-500 dark:text-gray-400">
Page {page + 1} of {totalPages}
</span>
<div className="flex gap-2">
<button
type="button"
onClick={() => onPageChange(Math.max(0, page - 1))}
disabled={page === 0}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-gray-300 dark:hover:bg-slate-800"
>
Previous
</button>
<button
type="button"
onClick={() => onPageChange(Math.min(totalPages - 1, page + 1))}
disabled={page >= totalPages - 1}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-gray-300 dark:hover:bg-slate-800"
>
Next
</button>
</div>
</div>
)}
</div>
);
}