"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"; // ─── Types ────────────────────────────────────────────────────────────────── type EntityType = "resource" | "project" | "assignment"; type FilterOp = "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in"; interface FilterRow { id: string; field: string; op: FilterOp; value: string; } const ENTITY_OPTIONS: { value: EntityType; label: string }[] = [ { value: "resource", label: "Resources" }, { value: "project", label: "Projects" }, { value: "assignment", label: "Assignments" }, ]; 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; function generateId(): string { return Math.random().toString(36).slice(2, 10); } // ─── 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 [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 = columnsQuery.data ?? []; // Scalar columns (for filter/sort/group — only non-relation columns) const scalarColumns = useMemo( () => availableColumns.filter((c) => !c.key.includes(".")), [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 })), ...(groupBy ? { groupBy } : {}), ...(sortBy ? { sortBy, sortDir } : {}), limit: PAGE_SIZE, offset: page * PAGE_SIZE, }; }, [runQuery, entity, selectedColumns, filters, groupBy, sortBy, sortDir, page]); // 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); setSelectedColumns(new Set()); setFilters([]); setGroupBy(""); setSortBy(""); 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 })), ...(groupBy ? { groupBy } : {}), ...(sortBy ? { sortBy, sortDir } : {}), limit: 5000, }); // 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 } }, [entity, selectedColumns, filters, groupBy, sortBy, sortDir, exportMutation]); // ─── Derived ────────────────────────────────────────────────────────── const rows = reportQuery.data?.rows ?? []; const totalCount = reportQuery.data?.totalCount ?? 0; const outputColumns = reportQuery.data?.columns ?? []; const totalPages = Math.ceil(totalCount / PAGE_SIZE); const isLoading = reportQuery.isFetching; // Column label lookup const columnLabelMap = useMemo(() => { const map = new Map(); for (const col of availableColumns) { map.set(col.key, col.label); } return map; }, [availableColumns]); // ─── Render ─────────────────────────────────────────────────────────── return (
{/* Header */}

Report Builder

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

{/* Config Panel */}
{/* Entity Selector */}
{ENTITY_OPTIONS.map((opt) => ( ))}
{/* 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 && (
{/* Results Header */}

Results

{!isLoading && ( {totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""} )}
{/* Table */}
{isLoading ? (
) : rows.length === 0 ? (
No data found. Try adjusting your filters.
) : ( {outputColumns.map((col) => { const isSortable = !col.includes("."); const isSorted = sortBy === col; return ( ); })} {rows.map((row, idx) => ( {outputColumns.map((col) => ( ))} ))}
handleSort(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", )} > {columnLabelMap.get(col) ?? col} {isSorted && ( )}
{formatCellValue(row[col])}
)}
{/* Pagination */} {totalPages > 1 && (
Page {page + 1} of {totalPages}
)}
)}
); } // ─── Helpers ──────────────────────────────────────────────────────────────── function formatCellValue(value: unknown): string { if (value === null || value === undefined) return "--"; if (typeof value === "boolean") return value ? "Yes" : "No"; if (typeof value === "string") { // ISO date detection 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); }