diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index 4585204..74692cb 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -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 ( - - {children} - - ); -} - -function DashboardIcon() { - return ; -} -function ResourcesIcon() { - return ; -} -function ProjectsIcon() { - return ; -} -function EstimatesIcon() { - return ; -} -function AllocationsIcon() { - return ; -} -function TimelineIcon() { - return ; -} -function StaffingIcon() { - return ; -} -function VacationIcon() { - return ; -} -function RolesIcon() { - return ; -} -function SkillsIcon() { - return ; -} -function MarketplaceIcon() { - return ; -} -function ChargeabilityIcon() { - return ; -} -function BenchIcon() { - return ; -} -function ReportBuilderIcon() { - return ; -} -function GraphIcon() { - return ; -} -function InsightsIcon() { - return ; -} -function NotificationsIcon() { - return ; -} -function BroadcastIcon() { - return ; -} -function ActivityLogIcon() { - return ; -} -function AdminIcon() { - return ; -} -function BlueprintIcon() { - return ; -} -function ClientsIcon() { - return ; -} -function CountryIcon() { - return ; -} -function OrgUnitIcon() { - return ; -} -function CategoryIcon() { - return ; -} -function LevelsIcon() { - return ; -} -function ImportIcon() { - return ; -} -function CalcRulesIcon() { - return ; -} -function UsersIcon() { - return ; -} -function SystemRolesIcon() { - return ; -} -function SecurityIcon() { - return ; -} -function SettingsIcon() { - return ; -} -function WebhooksIcon() { - return ; -} -function ScenariosIcon() { - return ; -} - -function CollapseIcon({ collapsed }: { collapsed: boolean }) { - return ( - - - - ); -} - -function HamburgerIcon() { - return ( - - - - ); -} - -function CloseIcon() { - return ( - - - - ); -} - 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: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, - { href: "/timeline", label: "Timeline", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, - { href: "/allocations", label: "Allocations", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, + { + href: "/dashboard", + label: "Dashboard", + icon: , + roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"], + }, + { + href: "/timeline", + label: "Timeline", + icon: , + roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"], + }, + { + href: "/allocations", + label: "Allocations", + icon: , + roles: ["ADMIN", "MANAGER", "CONTROLLER"], + }, { href: "/staffing", label: "Staffing", icon: , roles: ["ADMIN", "MANAGER"] }, - { href: "/scenarios", label: "Scenarios", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, - { href: "/notifications", label: "Notifications", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, + { + href: "/scenarios", + label: "Scenarios", + icon: , + roles: ["ADMIN", "MANAGER", "CONTROLLER"], + }, + { + href: "/notifications", + label: "Notifications", + icon: , + roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"], + }, ], }, { label: "Estimating", collapsed: true, items: [ - { href: "/estimates", label: "Estimates", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, - { href: "/admin/rate-cards", label: "Rate Cards", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, - { href: "/admin/effort-rules", label: "Effort Rules", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, - { href: "/admin/experience-multipliers", label: "Exp. Multipliers", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, + { + href: "/estimates", + label: "Estimates", + icon: , + roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"], + }, + { + href: "/admin/rate-cards", + label: "Rate Cards", + icon: , + roles: ["ADMIN", "MANAGER", "CONTROLLER"], + }, + { + href: "/admin/effort-rules", + label: "Effort Rules", + icon: , + roles: ["ADMIN", "MANAGER", "CONTROLLER"], + }, + { + href: "/admin/experience-multipliers", + label: "Exp. Multipliers", + icon: , + roles: ["ADMIN", "MANAGER", "CONTROLLER"], + }, ], }, { label: "Resources", items: [ - { href: "/resources", label: "Resources", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, - { href: "/bench", label: "Bench", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, - { href: "/projects", label: "Projects", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] }, - { href: "/roles", label: "Roles", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, + { + href: "/resources", + label: "Resources", + icon: , + roles: ["ADMIN", "MANAGER", "CONTROLLER"], + }, + { + href: "/bench", + label: "Bench", + icon: , + roles: ["ADMIN", "MANAGER", "CONTROLLER"], + }, + { + href: "/projects", + label: "Projects", + icon: , + roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"], + }, + { + href: "/roles", + label: "Roles", + icon: , + roles: ["ADMIN", "MANAGER", "CONTROLLER"], + }, ], }, { label: "Analytics", items: [ - { href: "/analytics/skills", label: "Skills Hub", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] }, - { href: "/reports/chargeability", label: "Chargeability", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, - { href: "/reports/builder", label: "Report Builder", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, - { href: "/analytics/computation-graph", label: "Computation Graph", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, - { href: "/analytics/insights", label: "AI Insights", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, + { + href: "/analytics/skills", + label: "Skills Hub", + icon: , + roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"], + }, + { + href: "/reports/chargeability", + label: "Chargeability", + icon: , + roles: ["ADMIN", "MANAGER", "CONTROLLER"], + }, + { + href: "/reports/builder", + label: "Report Builder", + icon: , + roles: ["ADMIN", "MANAGER", "CONTROLLER"], + }, + { + href: "/analytics/computation-graph", + label: "Computation Graph", + icon: , + roles: ["ADMIN", "MANAGER", "CONTROLLER"], + }, + { + href: "/analytics/insights", + label: "AI Insights", + icon: , + roles: ["ADMIN", "MANAGER", "CONTROLLER"], + }, ], }, { label: "Time Off", items: [ - { href: "/vacations/my", label: "My Vacations", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, - { href: "/vacations", label: "Vacation Mgmt", icon: , roles: ["ADMIN", "MANAGER"] }, + { + href: "/vacations/my", + label: "My Vacations", + icon: , + roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"], + }, + { + href: "/vacations", + label: "Vacation Mgmt", + icon: , + roles: ["ADMIN", "MANAGER"], + }, ], }, { label: "Account", items: [ - { href: "/account/security", label: "Security", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, + { + href: "/account/security", + label: "Security", + icon: , + 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 */} -
-
+
+
@@ -441,7 +452,9 @@ function SidebarContent({

CapaKraken

-

Resource & Capacity Planning

+

+ Resource & Capacity Planning +

)}
@@ -478,7 +491,12 @@ function SidebarContent({ stroke="currentColor" viewBox="0 0 24 24" > - + )} @@ -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" > - + + + {entry.label} - + @@ -632,18 +657,24 @@ function SidebarContent({
{/* Bottom actions */} -
+
-
+
{!sidebarCollapsed && ( - Notifications + + Notifications + )}
@@ -658,8 +689,18 @@ function SidebarContent({ )} > - - + + {!sidebarCollapsed && HartBOT} @@ -676,9 +717,24 @@ function SidebarContent({ )} > - - - + + + {!sidebarCollapsed && Preferences} @@ -695,8 +751,18 @@ function SidebarContent({ )} > - - + + {!sidebarCollapsed && Sign out} @@ -704,7 +770,10 @@ function SidebarContent({ {/* Collapse toggle */} - +
- ); } @@ -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" > - + )} diff --git a/apps/web/src/components/layout/nav-icons.tsx b/apps/web/src/components/layout/nav-icons.tsx new file mode 100644 index 0000000..1f49645 --- /dev/null +++ b/apps/web/src/components/layout/nav-icons.tsx @@ -0,0 +1,468 @@ +import type { ReactNode } from "react"; +import { clsx } from "clsx"; + +export function IconFrame({ children, isActive }: { children: ReactNode; isActive?: boolean }) { + return ( + + {children} + + ); +} + +export function DashboardIcon() { + return ( + + + + ); +} +export function ResourcesIcon() { + return ( + + + + ); +} +export function ProjectsIcon() { + return ( + + + + ); +} +export function EstimatesIcon() { + return ( + + + + ); +} +export function AllocationsIcon() { + return ( + + + + ); +} +export function TimelineIcon() { + return ( + + + + ); +} +export function StaffingIcon() { + return ( + + + + ); +} +export function VacationIcon() { + return ( + + + + ); +} +export function RolesIcon() { + return ( + + + + ); +} +export function SkillsIcon() { + return ( + + + + ); +} +export function MarketplaceIcon() { + return ( + + + + ); +} +export function ChargeabilityIcon() { + return ( + + + + ); +} +export function BenchIcon() { + return ( + + + + ); +} +export function ReportBuilderIcon() { + return ( + + + + ); +} +export function GraphIcon() { + return ( + + + + + + + ); +} +export function InsightsIcon() { + return ( + + + + ); +} +export function NotificationsIcon() { + return ( + + + + ); +} +export function BroadcastIcon() { + return ( + + + + ); +} +export function ActivityLogIcon() { + return ( + + + + ); +} +export function AdminIcon() { + return ( + + + + ); +} +export function BlueprintIcon() { + return ( + + + + ); +} +export function ClientsIcon() { + return ( + + + + ); +} +export function CountryIcon() { + return ( + + + + ); +} +export function OrgUnitIcon() { + return ( + + + + ); +} +export function CategoryIcon() { + return ( + + + + ); +} +export function LevelsIcon() { + return ( + + + + ); +} +export function ImportIcon() { + return ( + + + + ); +} +export function CalcRulesIcon() { + return ( + + + + ); +} +export function UsersIcon() { + return ( + + + + ); +} +export function SystemRolesIcon() { + return ( + + + + ); +} +export function SecurityIcon() { + return ( + + + + ); +} +export function SettingsIcon() { + return ( + + + + + ); +} +export function WebhooksIcon() { + return ( + + + + ); +} +export function ScenariosIcon() { + return ( + + + + ); +} + +export function CollapseIcon({ collapsed }: { collapsed: boolean }) { + return ( + + + + ); +} + +export function HamburgerIcon() { + return ( + + + + ); +} + +export function CloseIcon() { + return ( + + + + ); +} diff --git a/apps/web/src/components/reports/ReportBuilder.tsx b/apps/web/src/components/reports/ReportBuilder.tsx index a292483..8f3ff74 100644 --- a/apps/web/src/components/reports/ReportBuilder.tsx +++ b/apps/web/src/components/reports/ReportBuilder.tsx @@ -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): ResourceMonthTemplateCompleteness { +function buildResourceMonthCompleteness( + columns: Iterable, +): 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) => ({ 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) => ({ 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) => ({ id: generateId(), ...filter }))); + setFilters( + config.filters.map((filter: Omit) => ({ 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(RESOURCE_MONTH_RECOMMENDED_COLUMNS) : new Set(), + () => + entity === "resource_month" + ? new Set(RESOURCE_MONTH_RECOMMENDED_COLUMNS) + : new Set(), [entity], ); - const currentTemplateConfig = useMemo(() => ({ - 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( + () => ({ + 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() { {templates.map((template) => ( ))} @@ -634,7 +668,8 @@ export function ReportBuilder() { )} {selectedTemplate && hasTemplateDraftChanges ? (

- 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.

) : null}
@@ -674,10 +709,16 @@ export function ReportBuilder() {

- 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.

@@ -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"} {displayedResourceMonthCompleteness.selectedMinimumAuditColumnCount}/ - {displayedResourceMonthCompleteness.minimumAuditColumnCount} minimum audit columns + {displayedResourceMonthCompleteness.minimumAuditColumnCount} minimum audit + columns {displayedResourceMonthCompleteness.selectedRecommendedColumnCount}/ - {displayedResourceMonthCompleteness.recommendedColumnCount} recommended columns + {displayedResourceMonthCompleteness.recommendedColumnCount} recommended + columns - {selectedTemplate && !hasTemplateDraftChanges ? "Saved template status" : "Current builder status"} + {selectedTemplate && !hasTemplateDraftChanges + ? "Saved template status" + : "Current builder status"} {displayedResourceMonthCompleteness.missingMinimumAuditColumns.length > 0 ? (

- Missing audit/export basis columns: {summarizeMissingColumns( + Missing audit/export basis columns:{" "} + {summarizeMissingColumns( displayedResourceMonthCompleteness.missingMinimumAuditColumns, columnLabelMap, )}

) : displayedResourceMonthCompleteness.missingRecommendedColumns.length > 0 ? (

- Audit-ready, but still missing recommended basis columns: {summarizeMissingColumns( + Audit-ready, but still missing recommended basis columns:{" "} + {summarizeMissingColumns( displayedResourceMonthCompleteness.missingRecommendedColumns, columnLabelMap, )}

) : (

- 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.

)} @@ -816,13 +868,17 @@ export function ReportBuilder() { ))}

- 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.

- 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.

- 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.

@@ -886,16 +942,19 @@ export function ReportBuilder() { {/* Filter Builder */}
- + @@ -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) => ( - + ))} @@ -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) => ( - + ))} @@ -945,7 +1008,12 @@ export function ReportBuilder() { title="Remove filter" > - +
@@ -967,7 +1035,9 @@ export function ReportBuilder() { > {scalarColumns.map((col) => ( - + ))}
@@ -982,7 +1052,9 @@ export function ReportBuilder() { > {scalarColumns.map((col) => ( - + ))} @@ -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" > - - + + Run Report {selectedColumns.size === 0 && ( - Select at least one column + + Select at least one column + )} {/* Results */} {runQuery && ( -
- {/* Results Header */} -
-
-
-

Results

- {!isLoading && ( - - {totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""} - - )} -
-

- {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."} -

- {groupBy && rows.length > 0 ? ( -

- Grouped by {columnLabelMap.get(groupBy) ?? groupBy} with page-local section headers. -

- ) : null} -
- -
- - {reportExplainability?.entity === "resource_month" ? ( -
-
- - Month: {reportExplainability.periodMonth ?? "current"} - - - Location: {(reportExplainability.locationContextColumns.length > 0 - ? reportExplainability.locationContextColumns - : ["none"]).map((column) => columnLabelMap.get(column) ?? column).join(", ")} - - - Holidays: {(reportExplainability.holidayMetricColumns.length > 0 - ? reportExplainability.holidayMetricColumns - : ["none"]).map((column) => columnLabelMap.get(column) ?? column).join(", ")} - - - Absences: {(reportExplainability.absenceMetricColumns.length > 0 - ? reportExplainability.absenceMetricColumns - : ["none"]).map((column) => columnLabelMap.get(column) ?? column).join(", ")} - - - Capacity: {(reportExplainability.capacityMetricColumns.length > 0 - ? reportExplainability.capacityMetricColumns - : ["none"]).map((column) => columnLabelMap.get(column) ?? column).join(", ")} - -
-

- {reportExplainability.notes.join(" ")} -

- {reportExplainability.missingRecommendedColumns.length > 0 ? ( -

- Missing recommended audit columns: {summarizeMissingColumns( - reportExplainability.missingRecommendedColumns, - columnLabelMap, - )} -

- ) : null} -
- ) : null} - - {/* Table */} -
- {isLoading ? ( -
-
-
- ) : rows.length === 0 ? ( -
- No data found. Try adjusting your filters. -
- ) : ( - - - - {outputColumns.map((col) => { - const isSortable = !col.includes("."); - const isSorted = sortBy === col; - return ( - - ); - })} - - - - {rows.map((row, idx) => { - const group = groupStartByIndex.get(idx); - - return ( - - {group ? ( - - - - ) : null} - - {outputColumns.map((col) => ( - - ))} - - - ); - })} - -
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", - )} - > - - {columnLabelMap.get(col) ?? col} - {isSorted && ( - - - - )} - -
- {columnLabelMap.get(groupBy) ?? groupBy}: {group.label} · {group.rowCount} row{group.rowCount === 1 ? "" : "s"} -
- {formatCellValue(row[col])} -
- )} -
- - {/* Pagination */} - {totalPages > 1 && ( -
- - Page {page + 1} of {totalPages} - -
- - -
-
- )} -
+ void handleExport()} + onPageChange={setPage} + summarizeMissing={summarizeMissingColumns} + /> )}
); } - -// ─── 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); -} diff --git a/apps/web/src/components/reports/ReportResultsPanel.tsx b/apps/web/src/components/reports/ReportResultsPanel.tsx new file mode 100644 index 0000000..7654de7 --- /dev/null +++ b/apps/web/src/components/reports/ReportResultsPanel.tsx @@ -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[]; + totalCount: number; + outputColumns: string[]; + groups: ReportGroupSummary[]; + explainability: ReportExplainability | undefined; + groupBy: string; + sortBy: string; + sortDir: "asc" | "desc"; + isLoading: boolean; + page: number; + pageSize: number; + columnLabelMap: Map; + exportPending: boolean; + onSort: (column: string) => void; + onExport: () => void; + onPageChange: (page: number) => void; + summarizeMissing: (columns: string[], labelMap: Map, 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 ( +
+ {/* Results Header */} +
+
+
+

Results

+ {!isLoading && ( + + {totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""} + + )} +
+

+ {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."} +

+ {groupBy && rows.length > 0 ? ( +

+ Grouped by {columnLabelMap.get(groupBy) ?? groupBy} with page-local section headers. +

+ ) : null} +
+ +
+ + {explainability?.entity === "resource_month" ? ( +
+
+ + Month: {explainability.periodMonth ?? "current"} + + + Location:{" "} + {(explainability.locationContextColumns.length > 0 + ? explainability.locationContextColumns + : ["none"] + ) + .map((column) => columnLabelMap.get(column) ?? column) + .join(", ")} + + + Holidays:{" "} + {(explainability.holidayMetricColumns.length > 0 + ? explainability.holidayMetricColumns + : ["none"] + ) + .map((column) => columnLabelMap.get(column) ?? column) + .join(", ")} + + + Absences:{" "} + {(explainability.absenceMetricColumns.length > 0 + ? explainability.absenceMetricColumns + : ["none"] + ) + .map((column) => columnLabelMap.get(column) ?? column) + .join(", ")} + + + Capacity:{" "} + {(explainability.capacityMetricColumns.length > 0 + ? explainability.capacityMetricColumns + : ["none"] + ) + .map((column) => columnLabelMap.get(column) ?? column) + .join(", ")} + +
+

+ {explainability.notes.join(" ")} +

+ {explainability.missingRecommendedColumns.length > 0 ? ( +

+ Missing recommended audit columns:{" "} + {summarizeMissing(explainability.missingRecommendedColumns, columnLabelMap)} +

+ ) : null} +
+ ) : null} + + {/* Table */} +
+ {isLoading ? ( +
+
+
+ ) : rows.length === 0 ? ( +
+ No data found. Try adjusting your filters. +
+ ) : ( + + + + {outputColumns.map((col) => { + const isSortable = !col.includes("."); + const isSorted = sortBy === col; + return ( + + ); + })} + + + + {rows.map((row, idx) => { + const group = groupStartByIndex.get(idx); + + return ( + + {group ? ( + + + + ) : null} + + {outputColumns.map((col) => ( + + ))} + + + ); + })} + +
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", + )} + > + + {columnLabelMap.get(col) ?? col} + {isSorted && ( + + + + )} + +
+ {columnLabelMap.get(groupBy) ?? groupBy}: {group.label} · {group.rowCount}{" "} + row{group.rowCount === 1 ? "" : "s"} +
+ {formatCellValue(row[col])} +
+ )} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {page + 1} of {totalPages} + +
+ + +
+
+ )} +
+ ); +}