Files
Nexus/apps/web/src/components/reports/ReportResultsPanel.tsx
T
Hartmut 4a5edeef3e
CI / Unit Tests (pull_request) Successful in 5m46s
CI / Lint (pull_request) Failing after 3m49s
CI / E2E Tests (pull_request) Has been skipped
CI / Fresh-Linux Docker Deploy (pull_request) Has been skipped
CI / Assistant Split Regression (pull_request) Failing after 35s
CI / Architecture Guardrails (pull_request) Failing after 2m14s
CI / Typecheck (pull_request) Successful in 4m22s
CI / Build (pull_request) Has been skipped
CI / Release Images (pull_request) Has been skipped
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI
- @capakraken/* → @nexus/* across 12 packages (root + 11 workspaces),
  1551 import lines migrated via codemod
- User-visible brand strings renamed (emails, page titles, PWA
  manifest, mobile header, MFA backup-codes header, tooltips, signin
  page, invite page, weekly digest, install prompt)
- TOTP issuer "CapaKraken" → "Nexus" (existing secrets still valid;
  re-enrollment relabels them in users' authenticator apps)
- Function rename: assertCapaKrakenDbTarget → assertNexusDbTarget
- LocalStorage migration shim in apps/web/src/app/layout.tsx copies
  capakraken_* → nexus_* on first load (guarded by nexus_migrated_v1
  sentinel; runs once per browser, then never again)
- Service-worker cache name capakraken-v2 → nexus-v2 with one-time
  caches.delete('capakraken-v2') from the same shim
- Email-domain fixtures @capakraken.{dev,app} → @nexus.{dev,app} in
  seed data, e2e specs, SMTP default fallback
- Dockerfile.dev / Dockerfile.prod / all .github/workflows/*.yml
  pnpm --filter @capakraken/* → @nexus/*
- README, CLAUDE.md, LEARNINGS.md, all docs/*.md, .env.example,
  tooling/deploy/.env.production.example brand sweep

Phase 1 deliberately leaves untouched (handled in Phase 3 cutover):
- PostgreSQL DB name "capakraken" and POSTGRES_USER "capakraken"
- Volume names capakraken_pgdata etc.
- Compose project name "capakraken" / "capakraken-prod"
- db-target-guard default expectedDatabase
- env-var CAPAKRAKEN_EXPECTED_DB_NAME
- Container DNS names in docker-compose.ci.yml

Quality gates green: pnpm typecheck (7/7), pnpm test:unit (7/7),
pnpm lint (0 errors), check:exports/imports/architecture all pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:10:44 +02:00

294 lines
12 KiB
TypeScript

import { Fragment } from "react";
import { clsx } from "clsx";
import type { ReportExplainability } from "./reportBuilderExplainability.js";
interface ReportGroupSummary {
key: string;
label: string;
rowCount: number;
startIndex: number;
}
type ReportResultsPanelProps = {
rows: Record<string, unknown>[];
totalCount: number;
outputColumns: string[];
groups: ReportGroupSummary[];
explainability: ReportExplainability | undefined;
groupBy: string;
sortBy: string;
sortDir: "asc" | "desc";
isLoading: boolean;
page: number;
pageSize: number;
columnLabelMap: Map<string, string>;
exportPending: boolean;
onSort: (column: string) => void;
onExport: () => void;
onPageChange: (page: number) => void;
summarizeMissing: (columns: string[], labelMap: Map<string, string>, limit?: number) => string;
};
function formatCellValue(value: unknown): string {
if (value === null || value === undefined) return "--";
if (typeof value === "boolean") return value ? "Yes" : "No";
if (typeof value === "string") {
if (/^\d{4}-\d{2}-\d{2}T/.test(value)) {
return new Date(value).toLocaleDateString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
return value;
}
if (typeof value === "number") {
return value.toLocaleString("de-DE");
}
return String(value);
}
export function ReportResultsPanel({
rows,
totalCount,
outputColumns,
groups,
explainability,
groupBy,
sortBy,
sortDir,
isLoading,
page,
pageSize,
columnLabelMap,
exportPending,
onSort,
onExport,
onPageChange,
summarizeMissing,
}: ReportResultsPanelProps) {
const totalPages = Math.ceil(totalCount / pageSize);
const groupStartByIndex = new Map(groups.map((group) => [group.startIndex, group] as const));
return (
<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">
{explainability?.entity === "resource_month"
? "Exports include the report sheet plus an Explainability sheet with location, holiday, absence and SAH basis."
: "CSV exports include the selected basis columns and computed Nexus 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={onExport}
disabled={exportPending || 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>
{exportPending
? "Exporting..."
: explainability?.entity === "resource_month"
? "Export XLSX"
: "Export CSV"}
</button>
</div>
{explainability?.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: {explainability.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:{" "}
{(explainability.locationContextColumns.length > 0
? explainability.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:{" "}
{(explainability.holidayMetricColumns.length > 0
? explainability.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:{" "}
{(explainability.absenceMetricColumns.length > 0
? explainability.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:{" "}
{(explainability.capacityMetricColumns.length > 0
? explainability.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">
{explainability.notes.join(" ")}
</p>
{explainability.missingRecommendedColumns.length > 0 ? (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
Missing recommended audit columns:{" "}
{summarizeMissing(explainability.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 ? () => onSort(col) : undefined}
className={clsx(
"whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400",
isSortable &&
"cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200",
)}
>
<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={() => onPageChange(Math.max(0, page - 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={() => onPageChange(Math.min(totalPages - 1, page + 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>
);
}