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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 08:58:31 +02:00
parent 17f2de5f48
commit d1d33aa810
4 changed files with 1244 additions and 503 deletions
+206 -305
View File
@@ -1,6 +1,6 @@
"use client";
import { Fragment, useState, useMemo, useCallback } from "react";
import { useState, useMemo, useCallback } from "react";
import { keepPreviousData } from "@tanstack/react-query";
import { trpc } from "~/lib/trpc/client.js";
import { clsx } from "clsx";
@@ -9,6 +9,7 @@ import {
buildReportWorkbookSheets,
type ReportExplainability,
} from "./reportBuilderExplainability.js";
import { ReportResultsPanel } from "./ReportResultsPanel.js";
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -174,7 +175,8 @@ function normalizeTemplateConfig(config: TemplateConfig): TemplateConfig {
.sort((left, right) =>
`${left.field}:${left.op}:${left.value}`.localeCompare(
`${right.field}:${right.op}:${right.value}`,
)),
),
),
...(config.groupBy ? { groupBy: config.groupBy } : {}),
...(config.sortBy ? { sortBy: config.sortBy } : {}),
...(config.sortBy ? { sortDir: config.sortDir ?? "asc" } : {}),
@@ -186,19 +188,24 @@ function serializeTemplateConfig(config: TemplateConfig): string {
return JSON.stringify(normalizeTemplateConfig(config));
}
function buildResourceMonthCompleteness(columns: Iterable<string>): ResourceMonthTemplateCompleteness {
function buildResourceMonthCompleteness(
columns: Iterable<string>,
): ResourceMonthTemplateCompleteness {
const selectedColumns = new Set(columns);
const missingRecommendedColumns = RESOURCE_MONTH_RECOMMENDED_COLUMNS
.filter((column) => !selectedColumns.has(column));
const missingMinimumAuditColumns = RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS
.filter((column) => !selectedColumns.has(column));
const missingRecommendedColumns = RESOURCE_MONTH_RECOMMENDED_COLUMNS.filter(
(column) => !selectedColumns.has(column),
);
const missingMinimumAuditColumns = RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.filter(
(column) => !selectedColumns.has(column),
);
return {
scope: "resource_month",
isAuditReady: missingMinimumAuditColumns.length === 0,
isRecommendedComplete: missingRecommendedColumns.length === 0,
recommendedColumnCount: RESOURCE_MONTH_RECOMMENDED_COLUMNS.length,
selectedRecommendedColumnCount: RESOURCE_MONTH_RECOMMENDED_COLUMNS.length - missingRecommendedColumns.length,
selectedRecommendedColumnCount:
RESOURCE_MONTH_RECOMMENDED_COLUMNS.length - missingRecommendedColumns.length,
minimumAuditColumnCount: RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.length,
selectedMinimumAuditColumnCount:
RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.length - missingMinimumAuditColumns.length,
@@ -293,10 +300,10 @@ export function ReportBuilder() {
}, [runQuery, entity, selectedColumns, filters, groupBy, sortBy, sortDir, page, periodMonth]);
// Fetch report data
const reportQuery = trpc.report.getReportData.useQuery(
queryInput!,
{ enabled: queryInput !== null, placeholderData: keepPreviousData },
);
const reportQuery = trpc.report.getReportData.useQuery(queryInput!, {
enabled: queryInput !== null,
placeholderData: keepPreviousData,
});
const exportMutation = trpc.report.exportReport.useMutation();
@@ -319,22 +326,27 @@ export function ReportBuilder() {
setPage(0);
}, []);
const applyTemplate = useCallback((template: ReportTemplateSummary) => {
const config = template.config;
setSelectedTemplateId(template.id);
setTemplateName(template.name);
setTemplateDescription(template.description ?? "");
setTemplateIsShared(template.isShared);
setEntity(config.entity);
setSelectedColumns(new Set(config.columns));
setFilters(config.filters.map((filter: Omit<FilterRow, "id">) => ({ id: generateId(), ...filter })));
setGroupBy(config.groupBy ?? "");
setSortBy(config.sortBy ?? "");
setSortDir(config.sortDir ?? "asc");
setPeriodMonth(config.periodMonth ?? getCurrentPeriodMonth());
setRunQuery(false);
setPage(0);
}, [templatesQuery.data]);
const applyTemplate = useCallback(
(template: ReportTemplateSummary) => {
const config = template.config;
setSelectedTemplateId(template.id);
setTemplateName(template.name);
setTemplateDescription(template.description ?? "");
setTemplateIsShared(template.isShared);
setEntity(config.entity);
setSelectedColumns(new Set(config.columns));
setFilters(
config.filters.map((filter: Omit<FilterRow, "id">) => ({ id: generateId(), ...filter })),
);
setGroupBy(config.groupBy ?? "");
setSortBy(config.sortBy ?? "");
setSortDir(config.sortDir ?? "asc");
setPeriodMonth(config.periodMonth ?? getCurrentPeriodMonth());
setRunQuery(false);
setPage(0);
},
[templatesQuery.data],
);
const applyBlueprint = useCallback((blueprint: ReportBlueprint) => {
const config = blueprint.config;
@@ -344,7 +356,9 @@ export function ReportBuilder() {
setTemplateIsShared(false);
setEntity(config.entity);
setSelectedColumns(new Set(config.columns));
setFilters(config.filters.map((filter: Omit<FilterRow, "id">) => ({ id: generateId(), ...filter })));
setFilters(
config.filters.map((filter: Omit<FilterRow, "id">) => ({ id: generateId(), ...filter })),
);
setGroupBy(config.groupBy ?? "");
setSortBy(config.sortBy ?? "");
setSortDir(config.sortDir ?? "asc");
@@ -393,23 +407,26 @@ export function ReportBuilder() {
setRunQuery(true);
}, []);
const handleSort = useCallback((column: string) => {
if (!column.includes(".")) {
if (sortBy === column) {
setSortDir((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortBy(column);
setSortDir("asc");
const handleSort = useCallback(
(column: string) => {
if (!column.includes(".")) {
if (sortBy === column) {
setSortDir((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortBy(column);
setSortDir("asc");
}
// Re-run with new sort
setRunQuery(true);
}
// Re-run with new sort
setRunQuery(true);
}
}, [sortBy]);
},
[sortBy],
);
const handleExport = useCallback(async () => {
if (selectedColumns.size === 0) return;
try {
const result = await exportMutation.mutateAsync({
const result = (await exportMutation.mutateAsync({
entity,
columns: Array.from(selectedColumns),
filters: filters
@@ -419,7 +436,7 @@ export function ReportBuilder() {
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
limit: 5000,
}) as ExportReportResult;
})) as ExportReportResult;
if (result.explainability?.entity === "resource_month") {
await downloadWorkbookSheets(
@@ -447,7 +464,17 @@ export function ReportBuilder() {
} catch {
// Error handled by tRPC
}
}, [columnLabelMap, entity, selectedColumns, filters, groupBy, sortBy, sortDir, exportMutation, periodMonth]);
}, [
columnLabelMap,
entity,
selectedColumns,
filters,
groupBy,
sortBy,
sortDir,
exportMutation,
periodMonth,
]);
const handleSaveTemplate = useCallback(async () => {
if (!templateName.trim() || selectedColumns.size === 0) return;
@@ -502,48 +529,57 @@ export function ReportBuilder() {
const selectedTemplate =
templates.find((template: ReportTemplateSummary) => template.id === selectedTemplateId) ?? null;
const resourceMonthBlueprints = useMemo(
() => ((blueprintsQuery.data ?? []) as ReportBlueprint[]).filter((blueprint) => blueprint.entity === entity),
() =>
((blueprintsQuery.data ?? []) as ReportBlueprint[]).filter(
(blueprint) => blueprint.entity === entity,
),
[blueprintsQuery.data, entity],
);
const recommendedColumnSet = useMemo(
() => entity === "resource_month" ? new Set<string>(RESOURCE_MONTH_RECOMMENDED_COLUMNS) : new Set<string>(),
() =>
entity === "resource_month"
? new Set<string>(RESOURCE_MONTH_RECOMMENDED_COLUMNS)
: new Set<string>(),
[entity],
);
const currentTemplateConfig = useMemo<TemplateConfig>(() => ({
entity,
columns: Array.from(selectedColumns),
filters: filters
.filter((filter) => filter.field && filter.value)
.map(({ field, op, value }) => ({ field, op, value })),
...(entity === "resource_month" ? { periodMonth } : {}),
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
}), [entity, filters, groupBy, periodMonth, selectedColumns, sortBy, sortDir]);
const currentTemplateConfig = useMemo<TemplateConfig>(
() => ({
entity,
columns: Array.from(selectedColumns),
filters: filters
.filter((filter) => filter.field && filter.value)
.map(({ field, op, value }) => ({ field, op, value })),
...(entity === "resource_month" ? { periodMonth } : {}),
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
}),
[entity, filters, groupBy, periodMonth, selectedColumns, sortBy, sortDir],
);
const selectedTemplateFingerprint = selectedTemplate
? serializeTemplateConfig(selectedTemplate.config)
: null;
const currentTemplateFingerprint = serializeTemplateConfig(currentTemplateConfig);
const selectedTemplateMetadataFingerprint = selectedTemplate
? JSON.stringify({
name: selectedTemplate.name,
description: selectedTemplate.description ?? "",
isShared: selectedTemplate.isShared,
})
name: selectedTemplate.name,
description: selectedTemplate.description ?? "",
isShared: selectedTemplate.isShared,
})
: null;
const currentTemplateMetadataFingerprint = JSON.stringify({
name: templateName.trim(),
description: templateDescription.trim(),
isShared: templateIsShared,
});
const hasTemplateDraftChanges = selectedTemplateFingerprint !== null
&& (
selectedTemplateFingerprint !== currentTemplateFingerprint
|| selectedTemplateMetadataFingerprint !== currentTemplateMetadataFingerprint
);
const hasUnsavedLocalView = selectedTemplate === null
&& (selectedColumns.size > 0 || filters.some((filter) => filter.field && filter.value));
const hasTemplateDraftChanges =
selectedTemplateFingerprint !== null &&
(selectedTemplateFingerprint !== currentTemplateFingerprint ||
selectedTemplateMetadataFingerprint !== currentTemplateMetadataFingerprint);
const hasUnsavedLocalView =
selectedTemplate === null &&
(selectedColumns.size > 0 || filters.some((filter) => filter.field && filter.value));
const currentResourceMonthCompleteness = useMemo(
() => entity === "resource_month" ? buildResourceMonthCompleteness(selectedColumns) : null,
() => (entity === "resource_month" ? buildResourceMonthCompleteness(selectedColumns) : null),
[entity, selectedColumns],
);
@@ -552,19 +588,14 @@ export function ReportBuilder() {
return null;
}
if (
selectedTemplate
&& !hasTemplateDraftChanges
&& selectedTemplate.completeness?.scope === "resource_month"
selectedTemplate &&
!hasTemplateDraftChanges &&
selectedTemplate.completeness?.scope === "resource_month"
) {
return selectedTemplate.completeness;
}
return currentResourceMonthCompleteness;
}, [currentResourceMonthCompleteness, entity, hasTemplateDraftChanges, selectedTemplate]);
const groupStartByIndex = useMemo(
() => new Map(reportGroups.map((group) => [group.startIndex, group] as const)),
[reportGroups],
);
// ─── Render ───────────────────────────────────────────────────────────
return (
@@ -589,7 +620,9 @@ export function ReportBuilder() {
onChange={(e) => {
const nextId = e.target.value;
setSelectedTemplateId(nextId);
const template = templates.find((entry: ReportTemplateSummary) => entry.id === nextId);
const template = templates.find(
(entry: ReportTemplateSummary) => entry.id === nextId,
);
if (template) {
applyTemplate(template);
}
@@ -599,7 +632,8 @@ export function ReportBuilder() {
<option value="">Unsaved view</option>
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}{template.isShared && !template.isOwner ? " · shared" : ""}
{template.name}
{template.isShared && !template.isOwner ? " · shared" : ""}
</option>
))}
</select>
@@ -634,7 +668,8 @@ export function ReportBuilder() {
)}
{selectedTemplate && hasTemplateDraftChanges ? (
<p className="text-xs text-amber-700 dark:text-amber-300">
The current builder state differs from the saved template. Use Update template to persist these local changes.
The current builder state differs from the saved template. Use Update template to
persist these local changes.
</p>
) : null}
</div>
@@ -674,10 +709,16 @@ export function ReportBuilder() {
<button
type="button"
onClick={() => void handleSaveTemplate()}
disabled={!templateName.trim() || selectedColumns.size === 0 || saveTemplateMutation.isPending}
disabled={
!templateName.trim() || selectedColumns.size === 0 || saveTemplateMutation.isPending
}
className="self-end rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{saveTemplateMutation.isPending ? "Saving..." : selectedTemplateId ? "Update template" : "Save template"}
{saveTemplateMutation.isPending
? "Saving..."
: selectedTemplateId
? "Update template"
: "Save template"}
</button>
<button
type="button"
@@ -726,7 +767,9 @@ export function ReportBuilder() {
/>
</div>
<p className="max-w-2xl text-sm text-emerald-900/80 dark:text-emerald-200/80">
Resource Months uses the CapaKraken holiday and absence logic directly. SAH, booked hours and chargeability are calculated per resource and month with country, state and city context.
Resource Months uses the CapaKraken holiday and absence logic directly. SAH,
booked hours and chargeability are calculated per resource and month with country,
state and city context.
</p>
</div>
@@ -760,37 +803,46 @@ export function ReportBuilder() {
: "bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-200",
)}
>
{displayedResourceMonthCompleteness.isAuditReady ? "Audit ready" : "Audit gap"}
{displayedResourceMonthCompleteness.isAuditReady
? "Audit ready"
: "Audit gap"}
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
{displayedResourceMonthCompleteness.selectedMinimumAuditColumnCount}/
{displayedResourceMonthCompleteness.minimumAuditColumnCount} minimum audit columns
{displayedResourceMonthCompleteness.minimumAuditColumnCount} minimum audit
columns
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
{displayedResourceMonthCompleteness.selectedRecommendedColumnCount}/
{displayedResourceMonthCompleteness.recommendedColumnCount} recommended columns
{displayedResourceMonthCompleteness.recommendedColumnCount} recommended
columns
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] text-emerald-900/80 dark:bg-slate-950 dark:text-emerald-200/80">
{selectedTemplate && !hasTemplateDraftChanges ? "Saved template status" : "Current builder status"}
{selectedTemplate && !hasTemplateDraftChanges
? "Saved template status"
: "Current builder status"}
</span>
</div>
{displayedResourceMonthCompleteness.missingMinimumAuditColumns.length > 0 ? (
<p className="mt-3 text-xs text-amber-800 dark:text-amber-200">
Missing audit/export basis columns: {summarizeMissingColumns(
Missing audit/export basis columns:{" "}
{summarizeMissingColumns(
displayedResourceMonthCompleteness.missingMinimumAuditColumns,
columnLabelMap,
)}
</p>
) : displayedResourceMonthCompleteness.missingRecommendedColumns.length > 0 ? (
<p className="mt-3 text-xs text-emerald-900/80 dark:text-emerald-200/80">
Audit-ready, but still missing recommended basis columns: {summarizeMissingColumns(
Audit-ready, but still missing recommended basis columns:{" "}
{summarizeMissingColumns(
displayedResourceMonthCompleteness.missingRecommendedColumns,
columnLabelMap,
)}
</p>
) : (
<p className="mt-3 text-xs text-emerald-900/80 dark:text-emerald-200/80">
This view includes the full recommended audit/export basis set for monthly SAH and chargeability checks.
This view includes the full recommended audit/export basis set for monthly
SAH and chargeability checks.
</p>
)}
</div>
@@ -816,13 +868,17 @@ export function ReportBuilder() {
))}
</div>
<p className="mt-3 text-xs text-emerald-900/75 dark:text-emerald-200/75">
Formula reference: base available hours - holiday deduction - absence deduction = monthly SAH. Chargeability uses booked hours divided by monthly SAH.
Formula reference: base available hours - holiday deduction - absence deduction =
monthly SAH. Chargeability uses booked hours divided by monthly SAH.
</p>
<p className="mt-2 text-xs text-emerald-900/75 dark:text-emerald-200/75">
Export recommendation: include both basis columns and computed metrics in the CSV. That keeps Excel as a review layer instead of rebuilding CapaKraken logic outside the product.
Export recommendation: include both basis columns and computed metrics in the CSV.
That keeps Excel as a review layer instead of rebuilding CapaKraken logic outside
the product.
</p>
<p className="mt-2 text-xs text-emerald-900/75 dark:text-emerald-200/75">
Minimum audit set: month, location context, SAH, holiday deductions, absence deductions, target hours, booked hours and unassigned hours.
Minimum audit set: month, location context, SAH, holiday deductions, absence
deductions, target hours, booked hours and unassigned hours.
</p>
</div>
</div>
@@ -886,16 +942,19 @@ export function ReportBuilder() {
{/* Filter Builder */}
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Filters
</label>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Filters</label>
<button
type="button"
onClick={addFilter}
className="flex items-center gap-1 text-xs font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400"
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
Add filter
</button>
@@ -913,7 +972,9 @@ export function ReportBuilder() {
className="w-44 rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300"
>
{scalarColumns.map((col) => (
<option key={col.key} value={col.key}>{col.label}</option>
<option key={col.key} value={col.key}>
{col.label}
</option>
))}
</select>
@@ -924,7 +985,9 @@ export function ReportBuilder() {
className="w-36 rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300"
>
{OPERATOR_OPTIONS.map((op) => (
<option key={op.value} value={op.value}>{op.label}</option>
<option key={op.value} value={op.value}>
{op.label}
</option>
))}
</select>
@@ -945,7 +1008,12 @@ export function ReportBuilder() {
title="Remove filter"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
@@ -967,7 +1035,9 @@ export function ReportBuilder() {
>
<option value="">None</option>
{scalarColumns.map((col) => (
<option key={col.key} value={col.key}>{col.label}</option>
<option key={col.key} value={col.key}>
{col.label}
</option>
))}
</select>
</div>
@@ -982,7 +1052,9 @@ export function ReportBuilder() {
>
<option value="">Default</option>
{scalarColumns.map((col) => (
<option key={col.key} value={col.key}>{col.label}</option>
<option key={col.key} value={col.key}>
{col.label}
</option>
))}
</select>
</div>
@@ -1010,222 +1082,51 @@ export function ReportBuilder() {
className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Run Report
</button>
{selectedColumns.size === 0 && (
<span className="text-sm text-gray-400 dark:text-gray-500">Select at least one column</span>
<span className="text-sm text-gray-400 dark:text-gray-500">
Select at least one column
</span>
)}
</div>
</div>
{/* Results */}
{runQuery && (
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950">
{/* Results Header */}
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-slate-800">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Results</h2>
{!isLoading && (
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-slate-800 dark:text-gray-400">
{totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""}
</span>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
{reportExplainability?.entity === "resource_month"
? "Exports include the report sheet plus an Explainability sheet with location, holiday, absence and SAH basis."
: "CSV exports include the selected basis columns and computed CapaKraken metrics exactly as shown here."}
</p>
{groupBy && rows.length > 0 ? (
<p className="text-xs text-gray-500 dark:text-gray-400">
Grouped by {columnLabelMap.get(groupBy) ?? groupBy} with page-local section headers.
</p>
) : null}
</div>
<button
type="button"
onClick={() => void handleExport()}
disabled={exportMutation.isPending || totalCount === 0}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300 dark:hover:bg-slate-800"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{exportMutation.isPending ? "Exporting..." : reportExplainability?.entity === "resource_month" ? "Export XLSX" : "Export CSV"}
</button>
</div>
{reportExplainability?.entity === "resource_month" ? (
<div className="border-b border-emerald-100 bg-emerald-50/70 px-6 py-4 text-sm dark:border-emerald-950/60 dark:bg-emerald-950/20">
<div className="flex flex-wrap gap-2">
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
Month: {reportExplainability.periodMonth ?? "current"}
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
Location: {(reportExplainability.locationContextColumns.length > 0
? reportExplainability.locationContextColumns
: ["none"]).map((column) => columnLabelMap.get(column) ?? column).join(", ")}
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
Holidays: {(reportExplainability.holidayMetricColumns.length > 0
? reportExplainability.holidayMetricColumns
: ["none"]).map((column) => columnLabelMap.get(column) ?? column).join(", ")}
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
Absences: {(reportExplainability.absenceMetricColumns.length > 0
? reportExplainability.absenceMetricColumns
: ["none"]).map((column) => columnLabelMap.get(column) ?? column).join(", ")}
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
Capacity: {(reportExplainability.capacityMetricColumns.length > 0
? reportExplainability.capacityMetricColumns
: ["none"]).map((column) => columnLabelMap.get(column) ?? column).join(", ")}
</span>
</div>
<p className="mt-3 text-xs text-emerald-900/80 dark:text-emerald-200/80">
{reportExplainability.notes.join(" ")}
</p>
{reportExplainability.missingRecommendedColumns.length > 0 ? (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
Missing recommended audit columns: {summarizeMissingColumns(
reportExplainability.missingRecommendedColumns,
columnLabelMap,
)}
</p>
) : null}
</div>
) : null}
{/* Table */}
<div className="overflow-x-auto">
{isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-600 border-t-transparent" />
</div>
) : rows.length === 0 ? (
<div className="py-16 text-center text-sm text-gray-400 dark:text-gray-500">
No data found. Try adjusting your filters.
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50/80 dark:border-slate-800 dark:bg-slate-900/50">
{outputColumns.map((col) => {
const isSortable = !col.includes(".");
const isSorted = sortBy === col;
return (
<th
key={col}
onClick={isSortable ? () => handleSort(col) : undefined}
className={clsx(
"whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400",
isSortable && "cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200",
)}
>
<span className="inline-flex items-center gap-1">
{columnLabelMap.get(col) ?? col}
{isSorted && (
<svg className={clsx("h-3 w-3", sortDir === "desc" && "rotate-180")} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
)}
</span>
</th>
);
})}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-800/60">
{rows.map((row, idx) => {
const group = groupStartByIndex.get(idx);
return (
<Fragment key={`grouped-row:${String(row.id ?? idx)}:${idx}`}>
{group ? (
<tr key={`${group.key}:${idx}`} className="bg-brand-50/70 dark:bg-brand-950/20">
<td
colSpan={outputColumns.length}
className="px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-brand-700 dark:text-brand-200"
>
{columnLabelMap.get(groupBy) ?? groupBy}: {group.label} · {group.rowCount} row{group.rowCount === 1 ? "" : "s"}
</td>
</tr>
) : null}
<tr
key={(row.id as string) ?? idx}
className="transition-colors hover:bg-gray-50/60 dark:hover:bg-slate-900/40"
>
{outputColumns.map((col) => (
<td
key={col}
className="whitespace-nowrap px-4 py-2.5 text-gray-700 dark:text-gray-300"
>
{formatCellValue(row[col])}
</td>
))}
</tr>
</Fragment>
);
})}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-gray-200 px-6 py-3 dark:border-slate-800">
<span className="text-sm text-gray-500 dark:text-gray-400">
Page {page + 1} of {totalPages}
</span>
<div className="flex gap-2">
<button
type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-gray-300 dark:hover:bg-slate-800"
>
Previous
</button>
<button
type="button"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-gray-300 dark:hover:bg-slate-800"
>
Next
</button>
</div>
</div>
)}
</div>
<ReportResultsPanel
rows={rows}
totalCount={totalCount}
outputColumns={outputColumns}
groups={reportGroups}
explainability={reportExplainability}
groupBy={groupBy}
sortBy={sortBy}
sortDir={sortDir}
isLoading={isLoading}
page={page}
pageSize={PAGE_SIZE}
columnLabelMap={columnLabelMap}
exportPending={exportMutation.isPending}
onSort={handleSort}
onExport={() => void handleExport()}
onPageChange={setPage}
summarizeMissing={summarizeMissingColumns}
/>
)}
</div>
);
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function formatCellValue(value: unknown): string {
if (value === null || value === undefined) return "--";
if (typeof value === "boolean") return value ? "Yes" : "No";
if (typeof value === "string") {
// ISO date detection
if (/^\d{4}-\d{2}-\d{2}T/.test(value)) {
return new Date(value).toLocaleDateString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
return value;
}
if (typeof value === "number") {
return value.toLocaleString("de-DE");
}
return String(value);
}