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