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:
@@ -0,0 +1,293 @@
|
||||
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 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={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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user