feat(web): strengthen report builder explainability

This commit is contained in:
2026-03-31 23:07:36 +02:00
parent 8cb34a1c9b
commit 7908ab6d05
+407 -119
View File
@@ -1,9 +1,14 @@
"use client"; "use client";
import { useState, useMemo, useCallback } from "react"; import { Fragment, useState, useMemo, useCallback } from "react";
import { keepPreviousData } from "@tanstack/react-query"; import { keepPreviousData } from "@tanstack/react-query";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { downloadWorkbookSheets } from "~/lib/workbook-export.js";
import {
buildReportWorkbookSheets,
type ReportExplainability,
} from "./reportBuilderExplainability.js";
// ─── Types ────────────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────────────
@@ -41,19 +46,45 @@ interface ReportTemplateSummary {
config: TemplateConfig; config: TemplateConfig;
isShared: boolean; isShared: boolean;
isOwner: boolean; isOwner: boolean;
completeness: ResourceMonthTemplateCompleteness | null;
updatedAt: string | Date; 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<string, unknown>[];
columns: string[];
groups: ReportGroupSummary[];
explainability?: ReportExplainability;
}
interface ReportBlueprint { interface ReportBlueprint {
id: string; id: string;
label: string; label: string;
description: string; description: string;
entity: EntityType; entity: EntityType;
columns: string[];
groupBy?: string;
sortBy?: string;
sortDir?: "asc" | "desc";
templateName: string; templateName: string;
config: TemplateConfig;
} }
const ENTITY_OPTIONS: { value: EntityType; label: string }[] = [ const ENTITY_OPTIONS: { value: EntityType; label: string }[] = [
@@ -77,6 +108,7 @@ const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [ const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [
"monthKey",
"displayName", "displayName",
"eid", "eid",
"chapter", "chapter",
@@ -84,9 +116,12 @@ const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [
"countryName", "countryName",
"federalState", "federalState",
"metroCityName", "metroCityName",
"orgUnitName",
"managementLevelGroupName",
"monthlyBaseWorkingDays", "monthlyBaseWorkingDays",
"monthlyEffectiveWorkingDays", "monthlyEffectiveWorkingDays",
"monthlyBaseAvailableHours", "monthlyBaseAvailableHours",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayWorkdayCount", "monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction", "monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent", "monthlyAbsenceDayEquivalent",
@@ -101,85 +136,21 @@ const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [
"monthlyUnassignedHours", "monthlyUnassignedHours",
] as const; ] as const;
const REPORT_BLUEPRINTS: ReportBlueprint[] = [ const RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS = [
{ "monthKey",
id: "resource-month-sah-transparency", "displayName",
label: "SAH transparency", "countryName",
description: "Explains how monthly SAH is reduced by holidays and absences per person.", "federalState",
entity: "resource_month", "metroCityName",
templateName: "Monthly SAH transparency", "monthlyPublicHolidayCount",
columns: [ "monthlyPublicHolidayHoursDeduction",
"displayName", "monthlyAbsenceDayEquivalent",
"eid", "monthlyAbsenceHoursDeduction",
"chapter", "monthlySahHours",
"countryName", "monthlyTargetHours",
"federalState", "monthlyActualBookedHours",
"metroCityName", "monthlyUnassignedHours",
"monthlyBaseWorkingDays", ] as const;
"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",
},
];
function generateId(): string { function generateId(): string {
return Math.random().toString(36).slice(2, 10); 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")}`; 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<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));
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<string, string>,
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 ────────────────────────────────────────────────────────────── // ─── Component ──────────────────────────────────────────────────────────────
export function ReportBuilder() { export function ReportBuilder() {
@@ -203,6 +232,8 @@ export function ReportBuilder() {
const [periodMonth, setPeriodMonth] = useState<string>(getCurrentPeriodMonth()); const [periodMonth, setPeriodMonth] = useState<string>(getCurrentPeriodMonth());
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(""); const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
const [templateName, setTemplateName] = useState<string>(""); const [templateName, setTemplateName] = useState<string>("");
const [templateDescription, setTemplateDescription] = useState<string>("");
const [templateIsShared, setTemplateIsShared] = useState(false);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [runQuery, setRunQuery] = useState(false); const [runQuery, setRunQuery] = useState(false);
@@ -214,6 +245,7 @@ export function ReportBuilder() {
const availableColumns: AvailableColumn[] = columnsQuery.data ?? []; const availableColumns: AvailableColumn[] = columnsQuery.data ?? [];
const templatesQuery = trpc.report.listTemplates.useQuery(); const templatesQuery = trpc.report.listTemplates.useQuery();
const blueprintsQuery = trpc.report.listBlueprints.useQuery({ entity });
const saveTemplateMutation = trpc.report.saveTemplate.useMutation({ const saveTemplateMutation = trpc.report.saveTemplate.useMutation({
onSuccess: async (result) => { onSuccess: async (result) => {
setSelectedTemplateId(result.id); setSelectedTemplateId(result.id);
@@ -224,6 +256,8 @@ export function ReportBuilder() {
onSuccess: async () => { onSuccess: async () => {
setSelectedTemplateId(""); setSelectedTemplateId("");
setTemplateName(""); setTemplateName("");
setTemplateDescription("");
setTemplateIsShared(false);
await templatesQuery.refetch(); await templatesQuery.refetch();
}, },
}); });
@@ -233,6 +267,13 @@ export function ReportBuilder() {
() => availableColumns.filter((c) => !c.key.includes(".")), () => availableColumns.filter((c) => !c.key.includes(".")),
[availableColumns], [availableColumns],
); );
const columnLabelMap = useMemo(() => {
const map = new Map<string, string>();
for (const col of availableColumns) {
map.set(col.key, col.label);
}
return map;
}, [availableColumns]);
// Build query input // Build query input
const queryInput = useMemo(() => { const queryInput = useMemo(() => {
@@ -263,6 +304,10 @@ export function ReportBuilder() {
const handleEntityChange = useCallback((newEntity: EntityType) => { const handleEntityChange = useCallback((newEntity: EntityType) => {
setEntity(newEntity); setEntity(newEntity);
setSelectedTemplateId("");
setTemplateName("");
setTemplateDescription("");
setTemplateIsShared(false);
setSelectedColumns(new Set()); setSelectedColumns(new Set());
setFilters([]); setFilters([]);
setGroupBy(""); setGroupBy("");
@@ -278,6 +323,8 @@ export function ReportBuilder() {
const config = template.config; const config = template.config;
setSelectedTemplateId(template.id); setSelectedTemplateId(template.id);
setTemplateName(template.name); setTemplateName(template.name);
setTemplateDescription(template.description ?? "");
setTemplateIsShared(template.isShared);
setEntity(config.entity); setEntity(config.entity);
setSelectedColumns(new Set(config.columns)); 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 })));
@@ -290,16 +337,19 @@ export function ReportBuilder() {
}, [templatesQuery.data]); }, [templatesQuery.data]);
const applyBlueprint = useCallback((blueprint: ReportBlueprint) => { const applyBlueprint = useCallback((blueprint: ReportBlueprint) => {
const config = blueprint.config;
setSelectedTemplateId(""); setSelectedTemplateId("");
setTemplateName(blueprint.templateName); setTemplateName(blueprint.templateName);
setEntity(blueprint.entity); setTemplateDescription(blueprint.description);
setSelectedColumns(new Set(blueprint.columns)); setTemplateIsShared(false);
setFilters([]); setEntity(config.entity);
setGroupBy(blueprint.groupBy ?? ""); setSelectedColumns(new Set(config.columns));
setSortBy(blueprint.sortBy ?? ""); setFilters(config.filters.map((filter: Omit<FilterRow, "id">) => ({ id: generateId(), ...filter })));
setSortDir(blueprint.sortDir ?? "asc"); setGroupBy(config.groupBy ?? "");
if (blueprint.entity === "resource_month") { setSortBy(config.sortBy ?? "");
setPeriodMonth((current) => current || getCurrentPeriodMonth()); setSortDir(config.sortDir ?? "asc");
if (config.entity === "resource_month") {
setPeriodMonth(config.periodMonth ?? getCurrentPeriodMonth());
} }
setRunQuery(false); setRunQuery(false);
setPage(0); setPage(0);
@@ -369,7 +419,22 @@ export function ReportBuilder() {
...(groupBy ? { groupBy } : {}), ...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}), ...(sortBy ? { sortBy, sortDir } : {}),
limit: 5000, 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 // Download CSV
const blob = new Blob([result.csv], { type: "text/csv;charset=utf-8;" }); const blob = new Blob([result.csv], { type: "text/csv;charset=utf-8;" });
@@ -382,14 +447,17 @@ export function ReportBuilder() {
} catch { } catch {
// Error handled by tRPC // 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 () => { const handleSaveTemplate = useCallback(async () => {
if (!templateName.trim() || selectedColumns.size === 0) return; if (!templateName.trim() || selectedColumns.size === 0) return;
const normalizedDescription = templateDescription.trim();
await saveTemplateMutation.mutateAsync({ await saveTemplateMutation.mutateAsync({
...(selectedTemplateId ? { id: selectedTemplateId } : {}), ...(selectedTemplateId ? { id: selectedTemplateId } : {}),
name: templateName.trim(), name: templateName.trim(),
description: normalizedDescription,
isShared: templateIsShared,
config: { config: {
entity, entity,
columns: Array.from(selectedColumns), columns: Array.from(selectedColumns),
@@ -411,6 +479,8 @@ export function ReportBuilder() {
selectedTemplateId, selectedTemplateId,
sortBy, sortBy,
sortDir, sortDir,
templateDescription,
templateIsShared,
templateName, templateName,
]); ]);
@@ -424,26 +494,76 @@ export function ReportBuilder() {
const rows = reportQuery.data?.rows ?? []; const rows = reportQuery.data?.rows ?? [];
const totalCount = reportQuery.data?.totalCount ?? 0; const totalCount = reportQuery.data?.totalCount ?? 0;
const outputColumns = reportQuery.data?.columns ?? []; 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 totalPages = Math.ceil(totalCount / PAGE_SIZE);
const isLoading = reportQuery.isFetching; const isLoading = reportQuery.isFetching;
const templates = templatesQuery.data ?? []; const templates = templatesQuery.data ?? [];
const selectedTemplate =
templates.find((template: ReportTemplateSummary) => template.id === selectedTemplateId) ?? null;
const resourceMonthBlueprints = useMemo( const resourceMonthBlueprints = useMemo(
() => REPORT_BLUEPRINTS.filter((blueprint) => blueprint.entity === entity), () => ((blueprintsQuery.data ?? []) as ReportBlueprint[]).filter((blueprint) => blueprint.entity === entity),
[entity], [blueprintsQuery.data, entity],
); );
const recommendedColumnSet = useMemo( 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], [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 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 displayedResourceMonthCompleteness = useMemo(() => {
const columnLabelMap = useMemo(() => { if (entity !== "resource_month") {
const map = new Map<string, string>(); return null;
for (const col of availableColumns) {
map.set(col.key, col.label);
} }
return map; if (
}, [availableColumns]); 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 ─────────────────────────────────────────────────────────── // ─── Render ───────────────────────────────────────────────────────────
@@ -483,6 +603,40 @@ export function ReportBuilder() {
</option> </option>
))} ))}
</select> </select>
{selectedTemplate ? (
<div className="flex flex-wrap gap-2 text-[11px]">
<span className="rounded-full bg-gray-200 px-2 py-0.5 font-medium text-gray-700 dark:bg-slate-800 dark:text-gray-200">
{selectedTemplate.isShared ? "Shared template" : "Private template"}
</span>
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-gray-600 dark:bg-slate-900 dark:text-gray-300">
{selectedTemplate.isOwner ? "Owned by you" : "Shared by another user"}
</span>
<span
className={clsx(
"rounded-full px-2 py-0.5 font-medium",
hasTemplateDraftChanges
? "bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-200"
: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-200",
)}
>
{hasTemplateDraftChanges ? "Modified locally" : "Saved state"}
</span>
</div>
) : (
<div className="flex flex-wrap gap-2 text-[11px]">
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-gray-600 dark:bg-slate-900 dark:text-gray-300">
{hasUnsavedLocalView ? "Unsaved local view" : "No template selected"}
</span>
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-gray-600 dark:bg-slate-900 dark:text-gray-300">
Save the current builder state as a reusable template or shared view
</span>
</div>
)}
{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.
</p>
) : null}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
@@ -496,6 +650,27 @@ export function ReportBuilder() {
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" 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"
/> />
</div> </div>
<div className="space-y-2 lg:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Description
</label>
<input
type="text"
value={templateDescription}
onChange={(e) => 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"
/>
</div>
<label className="flex items-center gap-2 self-end rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300">
<input
type="checkbox"
checked={templateIsShared}
onChange={(e) => setTemplateIsShared(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-slate-600"
/>
Shared with controllers
</label>
<button <button
type="button" type="button"
onClick={() => void handleSaveTemplate()} onClick={() => void handleSaveTemplate()}
@@ -574,6 +749,52 @@ export function ReportBuilder() {
</div> </div>
<div className="rounded-2xl border border-emerald-200/80 bg-white/60 p-4 dark:border-emerald-900/60 dark:bg-slate-950/40"> <div className="rounded-2xl border border-emerald-200/80 bg-white/60 p-4 dark:border-emerald-900/60 dark:bg-slate-950/40">
{displayedResourceMonthCompleteness ? (
<div className="mb-4 rounded-2xl border border-emerald-200/80 bg-emerald-50/80 p-4 dark:border-emerald-900/60 dark:bg-emerald-950/20">
<div className="flex flex-wrap items-center gap-2">
<span
className={clsx(
"rounded-full px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em]",
displayedResourceMonthCompleteness.isAuditReady
? "bg-emerald-500 text-white"
: "bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-200",
)}
>
{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
</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
</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"}
</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(
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(
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.
</p>
)}
</div>
) : null}
<div className="text-sm font-medium text-emerald-950 dark:text-emerald-100"> <div className="text-sm font-medium text-emerald-950 dark:text-emerald-100">
Recommended transparency columns Recommended transparency columns
</div> </div>
@@ -600,6 +821,9 @@ export function ReportBuilder() {
<p className="mt-2 text-xs text-emerald-900/75 dark:text-emerald-200/75"> <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>
<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.
</p>
</div> </div>
</div> </div>
)} )}
@@ -812,8 +1036,15 @@ export function ReportBuilder() {
)} )}
</div> </div>
<p className="text-xs text-gray-500 dark:text-gray-400"> <p className="text-xs text-gray-500 dark:text-gray-400">
CSV exports include the selected basis columns and computed CapaKraken metrics exactly as shown here. {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> </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> </div>
<button <button
type="button" type="button"
@@ -824,10 +1055,51 @@ export function ReportBuilder() {
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
{exportMutation.isPending ? "Exporting..." : "Export CSV"} {exportMutation.isPending ? "Exporting..." : reportExplainability?.entity === "resource_month" ? "Export XLSX" : "Export CSV"}
</button> </button>
</div> </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 */} {/* Table */}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
{isLoading ? ( {isLoading ? (
@@ -868,21 +1140,37 @@ export function ReportBuilder() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-800/60"> <tbody className="divide-y divide-gray-100 dark:divide-slate-800/60">
{rows.map((row, idx) => ( {rows.map((row, idx) => {
<tr const group = groupStartByIndex.get(idx);
key={(row.id as string) ?? idx}
className="transition-colors hover:bg-gray-50/60 dark:hover:bg-slate-900/40" return (
> <Fragment key={`grouped-row:${String(row.id ?? idx)}:${idx}`}>
{outputColumns.map((col) => ( {group ? (
<td <tr key={`${group.key}:${idx}`} className="bg-brand-50/70 dark:bg-brand-950/20">
key={col} <td
className="whitespace-nowrap px-4 py-2.5 text-gray-700 dark:text-gray-300" 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"
> >
{formatCellValue(row[col])} {outputColumns.map((col) => (
</td> <td
))} key={col}
</tr> className="whitespace-nowrap px-4 py-2.5 text-gray-700 dark:text-gray-300"
))} >
{formatCellValue(row[col])}
</td>
))}
</tr>
</Fragment>
);
})}
</tbody> </tbody>
</table> </table>
)} )}