Files
CapaKraken/apps/web/src/components/reports/ReportBuilder.tsx
T
Hartmut bfcadd2c52 refactor(web): decompose TimelineView, ReportBuilder, and ResourceModal into focused components
Extract overlay/popover JSX from TimelineView (1268→1037 lines) into TimelineDragOverlays and
TimelinePopovers. Extract ResourceMonthConfigSection from ReportBuilder (1132→1018 lines).
Extract ResourceSkillsEditor and ResourceOrgClassification from ResourceModal (1035→714 lines).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 23:16:38 +02:00

1019 lines
39 KiB
TypeScript

"use client";
import { useState, useMemo, useCallback } from "react";
import { keepPreviousData } from "@tanstack/react-query";
import { trpc } from "~/lib/trpc/client.js";
import { clsx } from "clsx";
import { downloadWorkbookSheets } from "~/lib/workbook-export.js";
import {
buildReportWorkbookSheets,
type ReportExplainability,
} from "./reportBuilderExplainability.js";
import { ReportResultsPanel } from "./ReportResultsPanel.js";
import { ResourceMonthConfigSection } from "./ResourceMonthConfigSection.js";
// ─── Types ──────────────────────────────────────────────────────────────────
type EntityType = "resource" | "project" | "assignment" | "resource_month";
type FilterOp = "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in";
interface FilterRow {
id: string;
field: string;
op: FilterOp;
value: string;
}
interface AvailableColumn {
key: string;
label: string;
dataType: "string" | "number" | "date" | "boolean";
}
interface TemplateConfig {
entity: EntityType;
columns: string[];
filters: Omit<FilterRow, "id">[];
groupBy?: string;
sortBy?: string;
sortDir?: "asc" | "desc";
periodMonth?: string;
}
interface ReportTemplateSummary {
id: string;
name: string;
description?: string | null;
entity: EntityType;
config: TemplateConfig;
isShared: boolean;
isOwner: boolean;
completeness: ResourceMonthTemplateCompleteness | null;
updatedAt: string | Date;
}
interface ResourceMonthTemplateCompleteness {
scope: "resource_month";
isAuditReady: boolean;
isRecommendedComplete: boolean;
recommendedColumnCount: number;
selectedRecommendedColumnCount: number;
minimumAuditColumnCount: number;
selectedMinimumAuditColumnCount: number;
missingRecommendedColumns: string[];
missingMinimumAuditColumns: string[];
}
interface ReportGroupSummary {
key: string;
label: string;
rowCount: number;
startIndex: number;
}
interface ExportReportResult {
csv: string;
rowCount: number;
rows: Record<string, unknown>[];
columns: string[];
groups: ReportGroupSummary[];
explainability?: ReportExplainability;
}
interface ReportBlueprint {
id: string;
label: string;
description: string;
entity: EntityType;
templateName: string;
config: TemplateConfig;
}
const ENTITY_OPTIONS: { value: EntityType; label: string }[] = [
{ value: "resource", label: "Resources" },
{ value: "project", label: "Projects" },
{ value: "assignment", label: "Assignments" },
{ value: "resource_month", label: "Resource Months" },
];
const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
{ value: "eq", label: "equals" },
{ value: "neq", label: "not equals" },
{ value: "gt", label: "greater than" },
{ value: "lt", label: "less than" },
{ value: "gte", label: ">= (gte)" },
{ value: "lte", label: "<= (lte)" },
{ value: "contains", label: "contains" },
{ value: "in", label: "in (comma-sep)" },
];
const PAGE_SIZE = 50;
const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [
"monthKey",
"displayName",
"eid",
"chapter",
"countryCode",
"countryName",
"federalState",
"metroCityName",
"orgUnitName",
"managementLevelGroupName",
"monthlyBaseWorkingDays",
"monthlyEffectiveWorkingDays",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
"monthlyActualBookedHours",
"monthlyExpectedBookedHours",
"monthlyActualChargeabilityPct",
"monthlyExpectedChargeabilityPct",
"monthlyUnassignedHours",
] as const;
const RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS = [
"monthKey",
"displayName",
"countryName",
"federalState",
"metroCityName",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyTargetHours",
"monthlyActualBookedHours",
"monthlyUnassignedHours",
] as const;
function generateId(): string {
return Math.random().toString(36).slice(2, 10);
}
function getCurrentPeriodMonth(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
function normalizeTemplateConfig(config: TemplateConfig): TemplateConfig {
return {
entity: config.entity,
columns: [...config.columns].sort(),
filters: [...config.filters]
.map((filter) => ({
field: filter.field,
op: filter.op,
value: filter.value,
}))
.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" } : {}),
...(config.entity === "resource_month" ? { periodMonth: config.periodMonth ?? "" } : {}),
};
}
function serializeTemplateConfig(config: TemplateConfig): string {
return JSON.stringify(normalizeTemplateConfig(config));
}
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),
);
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,
minimumAuditColumnCount: RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.length,
selectedMinimumAuditColumnCount:
RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.length - missingMinimumAuditColumns.length,
missingRecommendedColumns,
missingMinimumAuditColumns,
};
}
function summarizeMissingColumns(
columns: string[],
columnLabelMap: Map<string, string>,
limit = 6,
): string {
const labels = columns.map((column) => columnLabelMap.get(column) ?? column);
if (labels.length <= limit) {
return labels.join(", ");
}
return `${labels.slice(0, limit).join(", ")} +${labels.length - limit} more`;
}
// ─── Component ──────────────────────────────────────────────────────────────
export function ReportBuilder() {
// Config state
const [entity, setEntity] = useState<EntityType>("resource");
const [selectedColumns, setSelectedColumns] = useState<Set<string>>(new Set());
const [filters, setFilters] = useState<FilterRow[]>([]);
const [groupBy, setGroupBy] = useState<string>("");
const [sortBy, setSortBy] = useState<string>("");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [periodMonth, setPeriodMonth] = useState<string>(getCurrentPeriodMonth());
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
const [templateName, setTemplateName] = useState<string>("");
const [templateDescription, setTemplateDescription] = useState<string>("");
const [templateIsShared, setTemplateIsShared] = useState(false);
const [page, setPage] = useState(0);
const [runQuery, setRunQuery] = useState(false);
// Fetch available columns when entity changes
const columnsQuery = trpc.report.getAvailableColumns.useQuery(
{ entity },
{ placeholderData: keepPreviousData },
);
const availableColumns: AvailableColumn[] = columnsQuery.data ?? [];
const templatesQuery = trpc.report.listTemplates.useQuery();
const blueprintsQuery = trpc.report.listBlueprints.useQuery({ entity });
const saveTemplateMutation = trpc.report.saveTemplate.useMutation({
onSuccess: async (result) => {
setSelectedTemplateId(result.id);
await templatesQuery.refetch();
},
});
const deleteTemplateMutation = trpc.report.deleteTemplate.useMutation({
onSuccess: async () => {
setSelectedTemplateId("");
setTemplateName("");
setTemplateDescription("");
setTemplateIsShared(false);
await templatesQuery.refetch();
},
});
// Scalar columns (for filter/sort/group — only non-relation columns)
const scalarColumns = useMemo(
() => availableColumns.filter((c) => !c.key.includes(".")),
[availableColumns],
);
const columnLabelMap = useMemo(() => {
const map = new Map<string, string>();
for (const col of availableColumns) {
map.set(col.key, col.label);
}
return map;
}, [availableColumns]);
// Build query input
const queryInput = useMemo(() => {
if (!runQuery || selectedColumns.size === 0) return null;
return {
entity,
columns: Array.from(selectedColumns),
filters: filters
.filter((f) => f.field && f.value)
.map(({ field, op, value }) => ({ field, op, value })),
...(entity === "resource_month" ? { periodMonth } : {}),
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
};
}, [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 exportMutation = trpc.report.exportReport.useMutation();
// ─── Handlers ───────────────────────────────────────────────────────────
const handleEntityChange = useCallback((newEntity: EntityType) => {
setEntity(newEntity);
setSelectedTemplateId("");
setTemplateName("");
setTemplateDescription("");
setTemplateIsShared(false);
setSelectedColumns(new Set());
setFilters([]);
setGroupBy("");
setSortBy("");
if (newEntity === "resource_month") {
setPeriodMonth((current) => current || getCurrentPeriodMonth());
}
setRunQuery(false);
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 applyBlueprint = useCallback((blueprint: ReportBlueprint) => {
const config = blueprint.config;
setSelectedTemplateId("");
setTemplateName(blueprint.templateName);
setTemplateDescription(blueprint.description);
setTemplateIsShared(false);
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");
if (config.entity === "resource_month") {
setPeriodMonth(config.periodMonth ?? getCurrentPeriodMonth());
}
setRunQuery(false);
setPage(0);
}, []);
const toggleColumn = useCallback((key: string) => {
setSelectedColumns((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}, []);
const selectAllColumns = useCallback(() => {
setSelectedColumns(new Set(availableColumns.map((c) => c.key)));
}, [availableColumns]);
const clearAllColumns = useCallback(() => {
setSelectedColumns(new Set());
}, []);
const addFilter = useCallback(() => {
const firstField = scalarColumns[0]?.key ?? "";
setFilters((prev) => [...prev, { id: generateId(), field: firstField, op: "eq", value: "" }]);
}, [scalarColumns]);
const updateFilter = useCallback((id: string, patch: Partial<FilterRow>) => {
setFilters((prev) => prev.map((f) => (f.id === id ? { ...f, ...patch } : f)));
}, []);
const removeFilter = useCallback((id: string) => {
setFilters((prev) => prev.filter((f) => f.id !== id));
}, []);
const handleRun = useCallback(() => {
setPage(0);
setRunQuery(true);
}, []);
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);
}
},
[sortBy],
);
const handleExport = useCallback(async () => {
if (selectedColumns.size === 0) return;
try {
const result = (await exportMutation.mutateAsync({
entity,
columns: Array.from(selectedColumns),
filters: filters
.filter((f) => f.field && f.value)
.map(({ field, op, value }) => ({ field, op, value })),
...(entity === "resource_month" ? { periodMonth } : {}),
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
limit: 5000,
})) as ExportReportResult;
if (result.explainability?.entity === "resource_month") {
await downloadWorkbookSheets(
`report-${entity}-${new Date().toISOString().slice(0, 10)}.xlsx`,
buildReportWorkbookSheets({
columns: result.columns,
rows: result.rows,
groups: result.groups,
...(groupBy ? { groupBy } : {}),
explainability: result.explainability,
resolveColumnLabel: (column) => columnLabelMap.get(column) ?? column,
}),
);
return;
}
// Download CSV
const blob = new Blob([result.csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `report-${entity}-${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(url);
} catch {
// Error handled by tRPC
}
}, [
columnLabelMap,
entity,
selectedColumns,
filters,
groupBy,
sortBy,
sortDir,
exportMutation,
periodMonth,
]);
const handleSaveTemplate = useCallback(async () => {
if (!templateName.trim() || selectedColumns.size === 0) return;
const normalizedDescription = templateDescription.trim();
await saveTemplateMutation.mutateAsync({
...(selectedTemplateId ? { id: selectedTemplateId } : {}),
name: templateName.trim(),
description: normalizedDescription,
isShared: templateIsShared,
config: {
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,
saveTemplateMutation,
selectedColumns,
selectedTemplateId,
sortBy,
sortDir,
templateDescription,
templateIsShared,
templateName,
]);
const handleDeleteTemplate = useCallback(async () => {
if (!selectedTemplateId) return;
await deleteTemplateMutation.mutateAsync({ id: selectedTemplateId });
}, [deleteTemplateMutation, selectedTemplateId]);
// ─── Derived ──────────────────────────────────────────────────────────
const rows = reportQuery.data?.rows ?? [];
const totalCount = reportQuery.data?.totalCount ?? 0;
const outputColumns = reportQuery.data?.columns ?? [];
const reportGroups = (reportQuery.data?.groups ?? []) as ReportGroupSummary[];
const reportExplainability = reportQuery.data?.explainability as ReportExplainability | undefined;
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
const isLoading = reportQuery.isFetching;
const templates = templatesQuery.data ?? [];
const selectedTemplate =
templates.find((template: ReportTemplateSummary) => template.id === selectedTemplateId) ?? null;
const resourceMonthBlueprints = useMemo(
() =>
((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],
);
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,
})
: 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 currentResourceMonthCompleteness = useMemo(
() => (entity === "resource_month" ? buildResourceMonthCompleteness(selectedColumns) : null),
[entity, selectedColumns],
);
const displayedResourceMonthCompleteness = useMemo(() => {
if (entity !== "resource_month") {
return null;
}
if (
selectedTemplate &&
!hasTemplateDraftChanges &&
selectedTemplate.completeness?.scope === "resource_month"
) {
return selectedTemplate.completeness;
}
return currentResourceMonthCompleteness;
}, [currentResourceMonthCompleteness, entity, hasTemplateDraftChanges, selectedTemplate]);
// ─── Render ───────────────────────────────────────────────────────────
return (
<div className="mx-auto max-w-[1600px] space-y-6 p-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Report Builder</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Build custom reports by selecting an entity, columns, and filters.
</p>
</div>
{/* Config Panel */}
<div className="space-y-5 rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-950">
<div className="grid gap-3 rounded-2xl border border-gray-200 bg-gray-50/70 p-4 dark:border-slate-800 dark:bg-slate-900/60 lg:grid-cols-[minmax(0,1fr)_220px_auto_auto]">
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Template
</label>
<select
value={selectedTemplateId}
onChange={(e) => {
const nextId = e.target.value;
setSelectedTemplateId(nextId);
const template = templates.find(
(entry: ReportTemplateSummary) => entry.id === nextId,
);
if (template) {
applyTemplate(template);
}
}}
className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300"
>
<option value="">Unsaved view</option>
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
{template.isShared && !template.isOwner ? " · shared" : ""}
</option>
))}
</select>
{selectedTemplate ? (
<div className="flex flex-wrap gap-2 text-[11px]">
<span className="rounded-full bg-gray-200 px-2 py-0.5 font-medium text-gray-700 dark:bg-slate-800 dark:text-gray-200">
{selectedTemplate.isShared ? "Shared template" : "Private template"}
</span>
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-gray-600 dark:bg-slate-900 dark:text-gray-300">
{selectedTemplate.isOwner ? "Owned by you" : "Shared by another user"}
</span>
<span
className={clsx(
"rounded-full px-2 py-0.5 font-medium",
hasTemplateDraftChanges
? "bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-200"
: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-200",
)}
>
{hasTemplateDraftChanges ? "Modified locally" : "Saved state"}
</span>
</div>
) : (
<div className="flex flex-wrap gap-2 text-[11px]">
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-gray-600 dark:bg-slate-900 dark:text-gray-300">
{hasUnsavedLocalView ? "Unsaved local view" : "No template selected"}
</span>
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-gray-600 dark:bg-slate-900 dark:text-gray-300">
Save the current builder state as a reusable template or shared view
</span>
</div>
)}
{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.
</p>
) : null}
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Template name
</label>
<input
type="text"
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
placeholder="Monthly SAH by location"
className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 placeholder:text-gray-400 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300"
/>
</div>
<div className="space-y-2 lg:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Description
</label>
<input
type="text"
value={templateDescription}
onChange={(e) => setTemplateDescription(e.target.value)}
placeholder="Explains which basis columns and metrics this template is meant to audit"
className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 placeholder:text-gray-400 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300"
/>
</div>
<label className="flex items-center gap-2 self-end rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300">
<input
type="checkbox"
checked={templateIsShared}
onChange={(e) => setTemplateIsShared(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-slate-600"
/>
Shared with controllers
</label>
<button
type="button"
onClick={() => void handleSaveTemplate()}
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"}
</button>
<button
type="button"
onClick={() => void handleDeleteTemplate()}
disabled={!selectedTemplateId || deleteTemplateMutation.isPending}
className="self-end rounded-xl border border-gray-300 bg-white px-4 py-2 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:bg-slate-950 dark:text-gray-300 dark:hover:bg-slate-900"
>
{deleteTemplateMutation.isPending ? "Deleting..." : "Delete"}
</button>
</div>
{/* Entity Selector */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Entity
</label>
<div className="flex gap-3">
{ENTITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handleEntityChange(opt.value)}
className={clsx(
"rounded-xl px-4 py-2 text-sm font-medium transition-colors",
entity === opt.value
? "bg-brand-600 text-white shadow-sm"
: "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-slate-800 dark:text-gray-300 dark:hover:bg-slate-700",
)}
>
{opt.label}
</button>
))}
</div>
{entity === "resource_month" && (
<ResourceMonthConfigSection
periodMonth={periodMonth}
onPeriodMonthChange={setPeriodMonth}
blueprints={resourceMonthBlueprints}
onApplyBlueprint={applyBlueprint}
completeness={displayedResourceMonthCompleteness}
selectedTemplate={selectedTemplate}
hasTemplateDraftChanges={hasTemplateDraftChanges}
selectedColumns={selectedColumns}
onToggleColumn={toggleColumn}
columnLabelMap={columnLabelMap}
recommendedColumns={RESOURCE_MONTH_RECOMMENDED_COLUMNS}
summarizeMissing={summarizeMissingColumns}
/>
)}
</div>
{/* Column Picker */}
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Columns ({selectedColumns.size} selected)
</label>
<div className="flex gap-2">
<button
type="button"
onClick={selectAllColumns}
className="text-xs font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400"
>
Select all
</button>
<span className="text-xs text-gray-300 dark:text-gray-600">|</span>
<button
type="button"
onClick={clearAllColumns}
className="text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear
</button>
</div>
</div>
{columnsQuery.isLoading ? (
<div className="py-3 text-sm text-gray-400">Loading columns...</div>
) : (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{availableColumns.map((col) => (
<label
key={col.key}
className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition-colors hover:bg-gray-50 dark:hover:bg-slate-900"
>
<input
type="checkbox"
checked={selectedColumns.has(col.key)}
onChange={() => toggleColumn(col.key)}
className="h-3.5 w-3.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-slate-600"
/>
<span className="text-gray-700 dark:text-gray-300">{col.label}</span>
{recommendedColumnSet.has(col.key) && (
<span className="rounded-full bg-emerald-100 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.14em] text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-300">
Rec
</span>
)}
<span className="ml-auto text-[10px] uppercase tracking-wider text-gray-400 dark:text-gray-600">
{col.dataType}
</span>
</label>
))}
</div>
)}
</div>
{/* 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>
<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"
/>
</svg>
Add filter
</button>
</div>
{filters.length === 0 ? (
<p className="text-sm text-gray-400 dark:text-gray-500">No filters applied.</p>
) : (
<div className="space-y-2">
{filters.map((filter) => (
<div key={filter.id} className="flex items-center gap-2">
{/* Field */}
<select
value={filter.field}
onChange={(e) => updateFilter(filter.id, { field: e.target.value })}
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>
))}
</select>
{/* Operator */}
<select
value={filter.op}
onChange={(e) => updateFilter(filter.id, { op: e.target.value as FilterOp })}
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>
))}
</select>
{/* Value */}
<input
type="text"
value={filter.value}
onChange={(e) => updateFilter(filter.id, { value: e.target.value })}
placeholder="Value..."
className="min-w-0 flex-1 rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 placeholder:text-gray-400 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300 dark:placeholder:text-gray-600"
/>
{/* Remove */}
<button
type="button"
onClick={() => removeFilter(filter.id)}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950 dark:hover:text-red-400"
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"
/>
</svg>
</button>
</div>
))}
</div>
)}
</div>
{/* Sort & Group */}
<div className="flex flex-wrap gap-4">
<div className="min-w-[160px]">
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Group by
</label>
<select
value={groupBy}
onChange={(e) => setGroupBy(e.target.value)}
className="w-full 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"
>
<option value="">None</option>
{scalarColumns.map((col) => (
<option key={col.key} value={col.key}>
{col.label}
</option>
))}
</select>
</div>
<div className="min-w-[160px]">
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Sort by
</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="w-full 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"
>
<option value="">Default</option>
{scalarColumns.map((col) => (
<option key={col.key} value={col.key}>
{col.label}
</option>
))}
</select>
</div>
<div className="min-w-[120px]">
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Direction
</label>
<select
value={sortDir}
onChange={(e) => setSortDir(e.target.value as "asc" | "desc")}
className="w-full 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"
>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
</div>
</div>
{/* Run button */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleRun}
disabled={selectedColumns.size === 0}
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"
/>
</svg>
Run Report
</button>
{selectedColumns.size === 0 && (
<span className="text-sm text-gray-400 dark:text-gray-500">
Select at least one column
</span>
)}
</div>
</div>
{/* Results */}
{runQuery && (
<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>
);
}