feat: Sprint 4 — scenario planner, report builder, comments, dashboard widgets
What-If Scenario Planner (G5): - New /projects/[id]/scenario page with side-by-side baseline vs scenario - simulate mutation: pure cost/hours/headcount/utilization computation - apply mutation: creates real PROPOSED assignments from scenario - Impact cards: cost delta, hours delta, headcount, skill coverage % - Per-resource utilization impact table with over-allocation warnings - "What-If" button added to project detail page Custom Report Builder (G7): - New /reports/builder page with full config panel - Entity selector (resource/project/assignment), column picker, filter builder - Dynamic Prisma query with eq/neq/gt/lt/contains/in operators - Sortable results table with pagination (50/page) - CSV export via exportReport mutation - Sidebar nav link under Analytics Collaboration Layer (G8): - Comment model in Prisma (entityType/entityId, replies, @mentions, resolved) - comment router: list, count, create, resolve, delete - @mention parsing with notification creation + SSE delivery - CommentInput with @mention autocomplete (arrow nav, Enter/Tab confirm) - CommentThread with avatar, timestamp, reply, resolve, delete - Integrated as "Comments" tab in estimate workspace with count badge Dashboard Widgets: - BudgetForecastWidget: progress bars per project, burn rate, exhaustion date - SkillGapWidget: supply vs demand per skill, shortage/surplus indicators - ProjectHealthWidget: 3-dimension health circles + composite score - 3 new application use-cases + dashboard router queries - All registered in widget-registry with lazy imports Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,564 @@
|
||||
"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<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 [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<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 })),
|
||||
...(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<string, string>();
|
||||
for (const col of availableColumns) {
|
||||
map.set(col.key, col.label);
|
||||
}
|
||||
return map;
|
||||
}, [availableColumns]);
|
||||
|
||||
// ─── 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">
|
||||
{/* 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>
|
||||
</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>
|
||||
<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 && (
|
||||
<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="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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleExport()}
|
||||
disabled={exportMutation.isPending || 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>
|
||||
{exportMutation.isPending ? "Exporting..." : "Export CSV"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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 ? () => 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",
|
||||
)}
|
||||
>
|
||||
<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) => (
|
||||
<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>
|
||||
))}
|
||||
</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={() => setPage((p) => Math.max(0, p - 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={() => setPage((p) => Math.min(totalPages - 1, p + 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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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);
|
||||
}
|
||||
Reference in New Issue
Block a user