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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user