diff --git a/DESIGN.md b/DESIGN.md index ecb137f..7cae375 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -349,6 +349,20 @@ below is the working backlog; expand as we go. expandable-row patterns (useful for `/veeam-analysis`-style drill-downs). +### Status indicator popover (2026-05-03) +- [x] Top-bar StatusIndicator now opens a Popover instead of routing + directly to `/status`. The popover groups inline issues by tone + (failing integrations, expired tokens, expiring tokens) so a quick + glance answers "what's broken." A "View full status" link at the + bottom routes to `/status`. + +### CSV export on DataTable (2026-05-03) +- [x] Optional `exportable` + `exportFilename` props add an "Export CSV" + button next to the search bar. Defaults to current-page export; + provide `onExportAll` for server-side full-result downloads. + Enabled on `/admin/data-browser/companies` and + `/admin/data-browser/tickets`. + ### Command palette (2026-05-03) - [x] **Cmd+K / Ctrl+K** opens a global launcher (`components/navigation/command-palette.tsx`). Three sections: diff --git a/app/admin/data-browser/companies/page.tsx b/app/admin/data-browser/companies/page.tsx index 3ba644f..2202c94 100644 --- a/app/admin/data-browser/companies/page.tsx +++ b/app/admin/data-browser/companies/page.tsx @@ -198,6 +198,8 @@ export default function CompaniesBrowserPage() { onSearch={(query) => fetchCompanies(1, query)} onRowClick={handleRowClick} isLoading={isLoading} + exportable + exportFilename="companies" /> diff --git a/app/admin/data-browser/tickets/page.tsx b/app/admin/data-browser/tickets/page.tsx index 374aa7e..fc00eef 100644 --- a/app/admin/data-browser/tickets/page.tsx +++ b/app/admin/data-browser/tickets/page.tsx @@ -171,6 +171,8 @@ export default function TicketsBrowserPage() { onSearch={(query) => fetchTickets(1, query)} onRowClick={handleRowClick} isLoading={isLoading} + exportable + exportFilename="tickets" /> diff --git a/components/admin/DataTable.tsx b/components/admin/DataTable.tsx index fb9a917..cd7a3fa 100644 --- a/components/admin/DataTable.tsx +++ b/components/admin/DataTable.tsx @@ -62,6 +62,7 @@ import { ChevronRight, ChevronsLeft, ChevronsRight, + Download, Loader2, Search, } from 'lucide-react'; @@ -94,6 +95,14 @@ export interface DataTableProps { 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; } export default function DataTable({ @@ -112,6 +121,9 @@ export default function DataTable({ emptyTitle = 'No data found', emptyDescription = 'Try adjusting your search or filters.', stickyFirstColumn = true, + exportable = false, + exportFilename = 'export', + onExportAll, }: DataTableProps) { const [searchQuery, setSearchQuery] = useState(''); const [sorting, setSorting] = useState([]); @@ -191,24 +203,59 @@ export default function DataTable({ 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 (
- {onSearch && ( + {(onSearch || exportable) && (
-
- - setSearchQuery(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleSearch()} - className="pl-10" - /> -
- + {onSearch && ( + <> +
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + className="pl-10" + /> +
+ + + )} + {exportable && ( + + )}
)} @@ -383,3 +430,26 @@ function renderLoadingRows(table: ReturnType> )); } + +/** + * 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(columns: Column[], 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)[c.key])).join(','), + ) + .join('\n'); + // BOM so Excel reads UTF-8 correctly. + return '' + header + '\n' + body + '\n'; +} diff --git a/components/navigation/status-indicator.tsx b/components/navigation/status-indicator.tsx index 41df563..f1d047b 100644 --- a/components/navigation/status-indicator.tsx +++ b/components/navigation/status-indicator.tsx @@ -1,26 +1,56 @@ -/* StatusIndicator — top-bar entry point to /status. +/* StatusIndicator — top-bar status pill with inline issue summary. * - * Polls /api/dashboard/integration-health every 60 s, rolls up overall - * state into a single StatusLight, and links to /status. Title attribute - * gives a quick textual hint; click goes to the full page. */ + * Click to open a Popover that lists what's wrong (failed integrations, + * expiring/expired tokens, recent sync failures) with deep-links to the + * relevant tools. When everything is healthy the popover just confirms + * "All systems operational". A "View full status" link at the bottom + * routes to /status. + * + * Polls /api/dashboard/integration-health every 60 s. Rolls up the + * summary into a single StatusLight color in the trigger so the visual + * hint is visible without opening the popover. */ 'use client'; import { useEffect, useState } from 'react'; import Link from 'next/link'; +import { ArrowRight, KeyRound, Power, ShieldCheck, XCircle } from 'lucide-react'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; import { StatusLight, type StatusLightState } from '@/components/ui/status-light'; +import { Separator } from '@/components/ui/separator'; + +interface IntegrationHealthItem { + key: string; + name: string; + status: 'ok' | 'auth_failed' | 'unreachable' | 'not_configured' | 'unknown' | 'disabled'; + error?: string | null; + tokenExpiry?: { envVar: string; daysRemaining: number } | null; +} interface HealthSummary { + total: number; + ok: number; failed: number; - expired: number; + notConfigured: number; + disabled: number; expiringWithin14Days: number; + expired: number; hasIssues: boolean; } +interface HealthResponse { + items: IntegrationHealthItem[]; + summary: HealthSummary; +} + const POLL_MS = 60_000; export function StatusIndicator() { - const [summary, setSummary] = useState(null); + const [data, setData] = useState(null); useEffect(() => { let cancelled = false; @@ -28,10 +58,10 @@ export function StatusIndicator() { try { const res = await fetch('/api/dashboard/integration-health', { cache: 'no-store' }); if (!res.ok || cancelled) return; - const j = (await res.json()) as { summary: HealthSummary }; - if (!cancelled) setSummary(j.summary); + const j = (await res.json()) as HealthResponse; + if (!cancelled) setData(j); } catch { - /* leave summary null — light renders idle */ + /* leave null — trigger renders idle */ } } void load(); @@ -42,6 +72,7 @@ export function StatusIndicator() { }; }, []); + const summary = data?.summary; const state: StatusLightState = !summary ? 'idle' : summary.failed > 0 || summary.expired > 0 @@ -50,7 +81,7 @@ export function StatusIndicator() { ? 'warn' : 'ok'; - const title = !summary + const triggerTitle = !summary ? 'System status' : state === 'error' ? `${summary.failed + summary.expired} integration issue(s)` @@ -59,13 +90,178 @@ export function StatusIndicator() { : 'All systems operational'; return ( - - - + + + + + + + + + ); +} + +function StatusSummary({ data }: { data: HealthResponse | null }) { + if (!data) { + return ( +
+

Loading system status…

+
+ ); + } + + const failing = data.items.filter( + (i) => i.status === 'auth_failed' || i.status === 'unreachable', + ); + const expired = data.items.filter( + (i) => i.tokenExpiry && i.tokenExpiry.daysRemaining <= 0, + ); + const expiring = data.items.filter( + (i) => + i.tokenExpiry && + i.tokenExpiry.daysRemaining > 0 && + i.tokenExpiry.daysRemaining <= 14, + ); + + const allOk = failing.length === 0 && expired.length === 0 && expiring.length === 0; + + return ( + <> +
+
+ {allOk ? ( + <> + +

All systems operational

+ + ) : ( + <> + +

+ {failing.length + expired.length + expiring.length} item(s) need attention +

+ + )} +
+

+ {data.summary.ok} healthy ·{' '} + {data.summary.disabled} disabled ·{' '} + {data.summary.notConfigured} unconfigured +

+
+ + {failing.length > 0 && ( + <> + + } + > + {failing.map((i) => ( + + ))} + + + )} + + {expired.length > 0 && ( + <> + + } + > + {expired.map((i) => ( + + ))} + + + )} + + {expiring.length > 0 && ( + <> + + } + > + {expiring.map((i) => ( + + ))} + + + )} + + + + View full status + + + + ); +} + +function IssueGroup({ + title, + icon, + children, +}: { + title: string; + icon: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+

+ {icon} + {title} +

+
    {children}
+
+ ); +} + +function IssueRow({ + primary, + secondary, + tone, +}: { + primary: string; + secondary: string; + tone: 'error' | 'warn'; +}) { + const toneClass = + tone === 'error' + ? 'text-destructive' + : 'text-amber-700 dark:text-amber-400'; + return ( +
  • + {primary} + {secondary} +
  • ); }