"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[]; 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[]; 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, ): 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, 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("resource"); const [selectedColumns, setSelectedColumns] = useState>(new Set()); const [filters, setFilters] = useState([]); const [groupBy, setGroupBy] = useState(""); const [sortBy, setSortBy] = useState(""); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); const [periodMonth, setPeriodMonth] = useState(getCurrentPeriodMonth()); const [selectedTemplateId, setSelectedTemplateId] = useState(""); const [templateName, setTemplateName] = useState(""); const [templateDescription, setTemplateDescription] = useState(""); 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(); 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) => ({ 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) => ({ 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) => { 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(RESOURCE_MONTH_RECOMMENDED_COLUMNS) : new Set(), [entity], ); const currentTemplateConfig = useMemo( () => ({ 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 (
{/* Header */}

Report Builder

Build custom reports by selecting an entity, columns, and filters.

{/* Config Panel */}
{selectedTemplate ? (
{selectedTemplate.isShared ? "Shared template" : "Private template"} {selectedTemplate.isOwner ? "Owned by you" : "Shared by another user"} {hasTemplateDraftChanges ? "Modified locally" : "Saved state"}
) : (
{hasUnsavedLocalView ? "Unsaved local view" : "No template selected"} Save the current builder state as a reusable template or shared view
)} {selectedTemplate && hasTemplateDraftChanges ? (

The current builder state differs from the saved template. Use “Update template” to persist these local changes.

) : null}
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" />
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" />
{/* Entity Selector */}
{ENTITY_OPTIONS.map((opt) => ( ))}
{entity === "resource_month" && ( )}
{/* Column Picker */}
|
{columnsQuery.isLoading ? (
Loading columns...
) : (
{availableColumns.map((col) => ( ))}
)}
{/* Filter Builder */}
{filters.length === 0 ? (

No filters applied.

) : (
{filters.map((filter) => (
{/* Field */} {/* Operator */} {/* Value */} 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 */}
))}
)}
{/* Sort & Group */}
{/* Run button */}
{selectedColumns.size === 0 && ( Select at least one column )}
{/* Results */} {runQuery && ( void handleExport()} onPageChange={setPage} summarizeMissing={summarizeMissingColumns} /> )}
); }