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
- @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>
294 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|