From 7908ab6d05a3a697e32308f80d3fbd2bdeeb3873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 23:07:36 +0200 Subject: [PATCH] feat(web): strengthen report builder explainability --- .../src/components/reports/ReportBuilder.tsx | 526 ++++++++++++++---- 1 file changed, 407 insertions(+), 119 deletions(-) diff --git a/apps/web/src/components/reports/ReportBuilder.tsx b/apps/web/src/components/reports/ReportBuilder.tsx index 46437f9..a292483 100644 --- a/apps/web/src/components/reports/ReportBuilder.tsx +++ b/apps/web/src/components/reports/ReportBuilder.tsx @@ -1,9 +1,14 @@ "use client"; -import { useState, useMemo, useCallback } from "react"; +import { Fragment, useState, useMemo, useCallback } from "react"; import { keepPreviousData } from "@tanstack/react-query"; import { trpc } from "~/lib/trpc/client.js"; import { clsx } from "clsx"; +import { downloadWorkbookSheets } from "~/lib/workbook-export.js"; +import { + buildReportWorkbookSheets, + type ReportExplainability, +} from "./reportBuilderExplainability.js"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -41,19 +46,45 @@ interface ReportTemplateSummary { config: TemplateConfig; isShared: boolean; isOwner: boolean; + completeness: ResourceMonthTemplateCompleteness | null; updatedAt: string | Date; } +interface ResourceMonthTemplateCompleteness { + scope: "resource_month"; + isAuditReady: boolean; + isRecommendedComplete: boolean; + recommendedColumnCount: number; + selectedRecommendedColumnCount: number; + minimumAuditColumnCount: number; + selectedMinimumAuditColumnCount: number; + missingRecommendedColumns: string[]; + missingMinimumAuditColumns: string[]; +} + +interface ReportGroupSummary { + key: string; + label: string; + rowCount: number; + startIndex: number; +} + +interface ExportReportResult { + csv: string; + rowCount: number; + rows: Record[]; + columns: string[]; + groups: ReportGroupSummary[]; + explainability?: ReportExplainability; +} + interface ReportBlueprint { id: string; label: string; description: string; entity: EntityType; - columns: string[]; - groupBy?: string; - sortBy?: string; - sortDir?: "asc" | "desc"; templateName: string; + config: TemplateConfig; } const ENTITY_OPTIONS: { value: EntityType; label: string }[] = [ @@ -77,6 +108,7 @@ const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [ const PAGE_SIZE = 50; const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [ + "monthKey", "displayName", "eid", "chapter", @@ -84,9 +116,12 @@ const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [ "countryName", "federalState", "metroCityName", + "orgUnitName", + "managementLevelGroupName", "monthlyBaseWorkingDays", "monthlyEffectiveWorkingDays", "monthlyBaseAvailableHours", + "monthlyPublicHolidayCount", "monthlyPublicHolidayWorkdayCount", "monthlyPublicHolidayHoursDeduction", "monthlyAbsenceDayEquivalent", @@ -101,85 +136,21 @@ const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [ "monthlyUnassignedHours", ] as const; -const REPORT_BLUEPRINTS: ReportBlueprint[] = [ - { - id: "resource-month-sah-transparency", - label: "SAH transparency", - description: "Explains how monthly SAH is reduced by holidays and absences per person.", - entity: "resource_month", - templateName: "Monthly SAH transparency", - columns: [ - "displayName", - "eid", - "chapter", - "countryName", - "federalState", - "metroCityName", - "monthlyBaseWorkingDays", - "monthlyEffectiveWorkingDays", - "monthlyBaseAvailableHours", - "monthlyPublicHolidayWorkdayCount", - "monthlyPublicHolidayHoursDeduction", - "monthlyAbsenceDayEquivalent", - "monthlyAbsenceHoursDeduction", - "monthlySahHours", - "monthlyChargeabilityTargetPct", - "monthlyTargetHours", - ], - sortBy: "displayName", - sortDir: "asc", - }, - { - id: "resource-month-chargeability-audit", - label: "Chargeability audit", - description: "Shows the full path from monthly SAH to booked, target and unassigned hours.", - entity: "resource_month", - templateName: "Monthly chargeability audit", - columns: [ - "displayName", - "eid", - "chapter", - "countryName", - "federalState", - "metroCityName", - "monthlySahHours", - "monthlyChargeabilityTargetPct", - "monthlyTargetHours", - "monthlyActualBookedHours", - "monthlyExpectedBookedHours", - "monthlyActualChargeabilityPct", - "monthlyExpectedChargeabilityPct", - "monthlyUnassignedHours", - "lcrCents", - "currency", - ], - sortBy: "monthlyActualChargeabilityPct", - sortDir: "desc", - }, - { - id: "resource-month-location-comparison", - label: "Location comparison", - description: "Compares holiday impact across country, state and city contexts for the same month.", - entity: "resource_month", - templateName: "Monthly holiday comparison by location", - columns: [ - "displayName", - "chapter", - "countryName", - "federalState", - "metroCityName", - "monthlyBaseWorkingDays", - "monthlyPublicHolidayWorkdayCount", - "monthlyPublicHolidayHoursDeduction", - "monthlyAbsenceHoursDeduction", - "monthlySahHours", - "monthlyActualChargeabilityPct", - ], - groupBy: "federalState", - sortBy: "monthlyPublicHolidayHoursDeduction", - sortDir: "desc", - }, -]; +const RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS = [ + "monthKey", + "displayName", + "countryName", + "federalState", + "metroCityName", + "monthlyPublicHolidayCount", + "monthlyPublicHolidayHoursDeduction", + "monthlyAbsenceDayEquivalent", + "monthlyAbsenceHoursDeduction", + "monthlySahHours", + "monthlyTargetHours", + "monthlyActualBookedHours", + "monthlyUnassignedHours", +] as const; function generateId(): string { return Math.random().toString(36).slice(2, 10); @@ -190,6 +161,64 @@ function getCurrentPeriodMonth(): string { return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; } +function normalizeTemplateConfig(config: TemplateConfig): TemplateConfig { + return { + entity: config.entity, + columns: [...config.columns].sort(), + filters: [...config.filters] + .map((filter) => ({ + field: filter.field, + op: filter.op, + value: filter.value, + })) + .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" } : {}), + ...(config.entity === "resource_month" ? { periodMonth: config.periodMonth ?? "" } : {}), + }; +} + +function serializeTemplateConfig(config: TemplateConfig): string { + return JSON.stringify(normalizeTemplateConfig(config)); +} + +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)); + + 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, + minimumAuditColumnCount: RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.length, + selectedMinimumAuditColumnCount: + RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.length - missingMinimumAuditColumns.length, + missingRecommendedColumns, + missingMinimumAuditColumns, + }; +} + +function summarizeMissingColumns( + columns: string[], + columnLabelMap: Map, + limit = 6, +): string { + const labels = columns.map((column) => columnLabelMap.get(column) ?? column); + if (labels.length <= limit) { + return labels.join(", "); + } + return `${labels.slice(0, limit).join(", ")} +${labels.length - limit} more`; +} + // ─── Component ────────────────────────────────────────────────────────────── export function ReportBuilder() { @@ -203,6 +232,8 @@ export function ReportBuilder() { const [periodMonth, setPeriodMonth] = useState(getCurrentPeriodMonth()); const [selectedTemplateId, setSelectedTemplateId] = useState(""); const [templateName, setTemplateName] = useState(""); + const [templateDescription, setTemplateDescription] = useState(""); + const [templateIsShared, setTemplateIsShared] = useState(false); const [page, setPage] = useState(0); const [runQuery, setRunQuery] = useState(false); @@ -214,6 +245,7 @@ export function ReportBuilder() { const availableColumns: AvailableColumn[] = columnsQuery.data ?? []; const templatesQuery = trpc.report.listTemplates.useQuery(); + const blueprintsQuery = trpc.report.listBlueprints.useQuery({ entity }); const saveTemplateMutation = trpc.report.saveTemplate.useMutation({ onSuccess: async (result) => { setSelectedTemplateId(result.id); @@ -224,6 +256,8 @@ export function ReportBuilder() { onSuccess: async () => { setSelectedTemplateId(""); setTemplateName(""); + setTemplateDescription(""); + setTemplateIsShared(false); await templatesQuery.refetch(); }, }); @@ -233,6 +267,13 @@ export function ReportBuilder() { () => availableColumns.filter((c) => !c.key.includes(".")), [availableColumns], ); + const columnLabelMap = useMemo(() => { + const map = new Map(); + for (const col of availableColumns) { + map.set(col.key, col.label); + } + return map; + }, [availableColumns]); // Build query input const queryInput = useMemo(() => { @@ -263,6 +304,10 @@ export function ReportBuilder() { const handleEntityChange = useCallback((newEntity: EntityType) => { setEntity(newEntity); + setSelectedTemplateId(""); + setTemplateName(""); + setTemplateDescription(""); + setTemplateIsShared(false); setSelectedColumns(new Set()); setFilters([]); setGroupBy(""); @@ -278,6 +323,8 @@ export function ReportBuilder() { 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 }))); @@ -290,16 +337,19 @@ export function ReportBuilder() { }, [templatesQuery.data]); const applyBlueprint = useCallback((blueprint: ReportBlueprint) => { + const config = blueprint.config; setSelectedTemplateId(""); setTemplateName(blueprint.templateName); - setEntity(blueprint.entity); - setSelectedColumns(new Set(blueprint.columns)); - setFilters([]); - setGroupBy(blueprint.groupBy ?? ""); - setSortBy(blueprint.sortBy ?? ""); - setSortDir(blueprint.sortDir ?? "asc"); - if (blueprint.entity === "resource_month") { - setPeriodMonth((current) => current || getCurrentPeriodMonth()); + setTemplateDescription(blueprint.description); + setTemplateIsShared(false); + 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"); + if (config.entity === "resource_month") { + setPeriodMonth(config.periodMonth ?? getCurrentPeriodMonth()); } setRunQuery(false); setPage(0); @@ -369,7 +419,22 @@ export function ReportBuilder() { ...(groupBy ? { groupBy } : {}), ...(sortBy ? { sortBy, sortDir } : {}), limit: 5000, - }); + }) as ExportReportResult; + + if (result.explainability?.entity === "resource_month") { + await downloadWorkbookSheets( + `report-${entity}-${new Date().toISOString().slice(0, 10)}.xlsx`, + buildReportWorkbookSheets({ + columns: result.columns, + rows: result.rows, + groups: result.groups, + ...(groupBy ? { groupBy } : {}), + explainability: result.explainability, + resolveColumnLabel: (column) => columnLabelMap.get(column) ?? column, + }), + ); + return; + } // Download CSV const blob = new Blob([result.csv], { type: "text/csv;charset=utf-8;" }); @@ -382,14 +447,17 @@ export function ReportBuilder() { } catch { // Error handled by tRPC } - }, [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; + const normalizedDescription = templateDescription.trim(); await saveTemplateMutation.mutateAsync({ ...(selectedTemplateId ? { id: selectedTemplateId } : {}), name: templateName.trim(), + description: normalizedDescription, + isShared: templateIsShared, config: { entity, columns: Array.from(selectedColumns), @@ -411,6 +479,8 @@ export function ReportBuilder() { selectedTemplateId, sortBy, sortDir, + templateDescription, + templateIsShared, templateName, ]); @@ -424,26 +494,76 @@ export function ReportBuilder() { const rows = reportQuery.data?.rows ?? []; const totalCount = reportQuery.data?.totalCount ?? 0; const outputColumns = reportQuery.data?.columns ?? []; + const reportGroups = (reportQuery.data?.groups ?? []) as ReportGroupSummary[]; + const reportExplainability = reportQuery.data?.explainability as ReportExplainability | undefined; const totalPages = Math.ceil(totalCount / PAGE_SIZE); const isLoading = reportQuery.isFetching; const templates = templatesQuery.data ?? []; + const selectedTemplate = + templates.find((template: ReportTemplateSummary) => template.id === selectedTemplateId) ?? null; const resourceMonthBlueprints = useMemo( - () => REPORT_BLUEPRINTS.filter((blueprint) => blueprint.entity === 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], ); + 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, + }) + : 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 currentResourceMonthCompleteness = useMemo( + () => entity === "resource_month" ? buildResourceMonthCompleteness(selectedColumns) : null, + [entity, selectedColumns], + ); - // Column label lookup - const columnLabelMap = useMemo(() => { - const map = new Map(); - for (const col of availableColumns) { - map.set(col.key, col.label); + const displayedResourceMonthCompleteness = useMemo(() => { + if (entity !== "resource_month") { + return null; } - return map; - }, [availableColumns]); + if ( + 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 ─────────────────────────────────────────────────────────── @@ -483,6 +603,40 @@ export function ReportBuilder() { ))} + {selectedTemplate ? ( +
+ + {selectedTemplate.isShared ? "Shared template" : "Private template"} + + + {selectedTemplate.isOwner ? "Owned by you" : "Shared by another user"} + + + {hasTemplateDraftChanges ? "Modified locally" : "Saved state"} + +
+ ) : ( +
+ + {hasUnsavedLocalView ? "Unsaved local view" : "No template selected"} + + + Save the current builder state as a reusable template or shared view + +
+ )} + {selectedTemplate && hasTemplateDraftChanges ? ( +

+ The current builder state differs from the saved template. Use “Update template” to persist these local changes. +

+ ) : null}
+
+ + setTemplateDescription(e.target.value)} + placeholder="Explains which basis columns and metrics this template is meant to audit" + className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 placeholder:text-gray-400 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300" + /> +
+ + {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 ? ( @@ -868,21 +1140,37 @@ export function ReportBuilder() { - {rows.map((row, idx) => ( - - {outputColumns.map((col) => ( - { + const group = groupStartByIndex.get(idx); + + return ( + + {group ? ( + + + {columnLabelMap.get(groupBy) ?? groupBy}: {group.label} · {group.rowCount} row{group.rowCount === 1 ? "" : "s"} + + + ) : null} + - {formatCellValue(row[col])} - - ))} - - ))} + {outputColumns.map((col) => ( + + {formatCellValue(row[col])} + + ))} + + + ); + })} )}