feat: status popover + CSV export
Two follow-ons after the ⌘K palette:
StatusIndicator → Popover
- The top-bar status light is no longer a direct link to /status.
Clicking it opens a popover with grouped issues (failing
integrations, expired tokens, expiring tokens) so a quick glance
answers "what's broken" without leaving the current page. A "View
full status" link at the bottom routes to /status when needed.
- The trigger keeps the same color rollup so the visual hint is
visible without opening the popover.
DataTable → CSV export
- Optional `exportable` + `exportFilename` props add an "Export CSV"
button next to the search bar. Default behavior exports the current
page; pass `onExportAll` for server-side full-result downloads.
- Built client-side from column defs (label → header, raw value →
cell). BOM-prefixed UTF-8 so Excel decodes correctly. Quoting +
escape handled.
- Enabled on /admin/data-browser/{companies,tickets} as initial demos.
Other data-browser pages opt in by adding two props.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a0894fe946
commit
c97e5fc45c
5 changed files with 317 additions and 33 deletions
|
|
@ -62,6 +62,7 @@ import {
|
|||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Download,
|
||||
Loader2,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
|
|
@ -94,6 +95,14 @@ export interface DataTableProps<TData = any> {
|
|||
emptyDescription?: string;
|
||||
/** Pin the first column when the table scrolls horizontally. Default true. */
|
||||
stickyFirstColumn?: boolean;
|
||||
/** When true, render an "Export CSV" button. Exports the current page only
|
||||
* unless `onExportAll` is provided (which receives all records server-side). */
|
||||
exportable?: boolean;
|
||||
/** Filename prefix for the CSV download (e.g. "tickets" → "tickets-2026-05-03.csv"). */
|
||||
exportFilename?: string;
|
||||
/** Optional async callback for full-result export — called instead of building
|
||||
* the CSV from `data`. Should resolve with the full row set. */
|
||||
onExportAll?: () => Promise<TData[]>;
|
||||
}
|
||||
|
||||
export default function DataTable<TData = any>({
|
||||
|
|
@ -112,6 +121,9 @@ export default function DataTable<TData = any>({
|
|||
emptyTitle = 'No data found',
|
||||
emptyDescription = 'Try adjusting your search or filters.',
|
||||
stickyFirstColumn = true,
|
||||
exportable = false,
|
||||
exportFilename = 'export',
|
||||
onExportAll,
|
||||
}: DataTableProps<TData>) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
|
@ -191,24 +203,59 @@ export default function DataTable<TData = any>({
|
|||
onSearch?.(searchQuery);
|
||||
};
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
async function handleExport() {
|
||||
setExporting(true);
|
||||
try {
|
||||
const rows = onExportAll ? await onExportAll() : data;
|
||||
const csv = toCsv(columns, rows);
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${exportFilename}-${today}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{onSearch && (
|
||||
{(onSearch || exportable) && (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSearch} disabled={isLoading}>
|
||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||||
<span className="ml-2">Search</span>
|
||||
</Button>
|
||||
{onSearch && (
|
||||
<>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSearch} disabled={isLoading}>
|
||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||||
<span className="ml-2">Search</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{exportable && (
|
||||
<Button variant="outline" onClick={handleExport} disabled={exporting || data.length === 0} className="ml-auto">
|
||||
{exporting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-4 h-4" />
|
||||
)}
|
||||
<span className="ml-2">Export CSV</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -383,3 +430,26 @@ function renderLoadingRows<TData>(table: ReturnType<typeof useReactTable<TData>>
|
|||
</TableRow>
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a CSV string from columns + rows. Cell values come from
|
||||
* `row[col.key]` (raw, not column.render — render returns ReactNode
|
||||
* which doesn't serialize). Quotes are doubled, fields containing
|
||||
* comma / quote / newline are wrapped in quotes; everything else is
|
||||
* left bare. Booleans render true/false, null/undefined render empty.
|
||||
*/
|
||||
function toCsv<T = any>(columns: Column<T>[], rows: T[]): string {
|
||||
const escape = (v: unknown): string => {
|
||||
if (v === null || v === undefined) return '';
|
||||
const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
||||
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
};
|
||||
const header = columns.map((c) => escape(c.label)).join(',');
|
||||
const body = rows
|
||||
.map((row) =>
|
||||
columns.map((c) => escape((row as Record<string, unknown>)[c.key])).join(','),
|
||||
)
|
||||
.join('\n');
|
||||
// BOM so Excel reads UTF-8 correctly.
|
||||
return '' + header + '\n' + body + '\n';
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue