refactor(design): adopt TanStack DataTable on /veeam-analysis, fill gaps

Follow-on polish for the nav-design overhaul (#9bfb575).

- /veeam-analysis migrates the bespoke TicketRow + custom pagination to
  the new DataTable using getRowCanExpand + renderSubRow. Drops ~85
  lines of fragment/colspan markup in favor of the standard pattern.

- PageHeader on the last common stragglers — /settings,
  /settings/security, /sentinelone/coverage, /sentinelone/mappings.
  Settings is reachable from the new top-bar UserMenu so it had to
  match the rest of the visual system.

- /dashboard and /status load with the new Skeleton helpers
  (SkeletonRows, SkeletonChart, SkeletonTable) so loading shells now
  approximate the post-load layout instead of a single h-NN bar.

- DESIGN.md: closed the straggler PageHeader item; deprioritized the
  hard-coded palette audit with a note that ~770 references are mostly
  semantic via the documented bg-{hue}-500/15 / text-{hue}-700 recipe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lorentz 2026-05-03 09:42:04 -04:00
parent 9bfb57553d
commit ab78e7bd4f
8 changed files with 242 additions and 250 deletions

View file

@ -311,8 +311,10 @@ below is the working backlog; expand as we go.
### Page shell ### Page shell
- [x] ~~Adopt `PageHeader` everywhere~~`/dashboard`, `/status`, all - [x] ~~Adopt `PageHeader` everywhere~~`/dashboard`, `/status`, all
`/admin/*` sub-pages, `/addigy-devices`, `/analyzer/ticket/[…]`, `/admin/*` sub-pages, `/addigy-devices`, `/analyzer/ticket/[…]`,
`/analyzer/analysis/[…]` now use it. A handful of pages still need `/analyzer/analysis/[…]`, `/settings`, `/settings/security`,
it (kiosk, settings, sentinelone/* — low priority). `/sentinelone/coverage`, `/sentinelone/mappings` now use it. Kiosk
uses its own shell; the only stragglers are very small/internal
pages.
- [ ] Standardize container + padding (`container mx-auto px-6 py-6`). Pages - [ ] Standardize container + padding (`container mx-auto px-6 py-6`). Pages
using `px-4`, `py-8`, `max-w-2xl` etc. should justify the deviation. using `px-4`, `py-8`, `max-w-2xl` etc. should justify the deviation.
- [ ] Standardize section spacing (`space-y-6` between cards; pick `gap-6` - [ ] Standardize section spacing (`space-y-6` between cards; pick `gap-6`
@ -363,9 +365,11 @@ below is the working backlog; expand as we go.
scheduler heartbeats via `/api/status/workers` and `WorkerPulse`. scheduler heartbeats via `/api/status/workers` and `WorkerPulse`.
### Tokens & theming ### Tokens & theming
- [ ] Audit places that hard-code Tailwind palette colors (`text-orange-600`, - [-] Hard-coded Tailwind palette colors are extensive (~770 references)
`bg-blue-500/15`) and either keep them as semantic status colors or but most are **semantic** (status reds, info blues, warning ambers
move them behind a token. via the `bg-{hue}-500/15 text-{hue}-700` recipe documented above).
Surveyed and deprioritized — case-by-case cleanup as new work
touches a page.
- [ ] Verify dark-mode contrast on status badges and chart legends; the 10%- - [ ] Verify dark-mode contrast on status badges and chart legends; the 10%-
opacity borders in dark mode are subtle and may need lifting. opacity borders in dark mode are subtle and may need lifting.

View file

@ -16,7 +16,7 @@ import { useEffect, useState } from 'react';
import { PageHeader } from '@/components/navigation/page-header'; import { PageHeader } from '@/components/navigation/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton'; import { SkeletonRows, SkeletonChart } from '@/components/ui/skeleton-helpers';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { EmptyState } from '@/components/ui/empty-state'; import { EmptyState } from '@/components/ui/empty-state';
import { KpiCard } from '@/components/dashboard/kpi-card'; import { KpiCard } from '@/components/dashboard/kpi-card';
@ -275,7 +275,7 @@ export default function DashboardPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!trends ? ( {!trends ? (
<Skeleton className="h-48" /> <SkeletonChart height={196} />
) : ( ) : (
<QueueHeatmap data={trends.queueHeatmap} /> <QueueHeatmap data={trends.queueHeatmap} />
)} )}
@ -290,7 +290,7 @@ export default function DashboardPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!trends ? ( {!trends ? (
<Skeleton className="h-48" /> <SkeletonChart height={196} />
) : ( ) : (
<ActiveEngineers data={trends.activeEngineers} /> <ActiveEngineers data={trends.activeEngineers} />
)} )}
@ -309,7 +309,7 @@ export default function DashboardPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!trends ? ( {!trends ? (
<Skeleton className="h-44" /> <SkeletonChart height={180} />
) : ( ) : (
<VolumeTrend data={trends.volumeByDay} /> <VolumeTrend data={trends.volumeByDay} />
)} )}
@ -324,7 +324,7 @@ export default function DashboardPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!trends ? ( {!trends ? (
<Skeleton className="h-44" /> <SkeletonChart height={180} />
) : ( ) : (
<ResolutionTrend data={trends.resolutionByDay} /> <ResolutionTrend data={trends.resolutionByDay} />
)} )}
@ -343,7 +343,7 @@ export default function DashboardPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!data ? ( {!data ? (
<RowSkeletons /> <SkeletonRows count={4} />
) : data.observations.length === 0 ? ( ) : data.observations.length === 0 ? (
<EmptyState <EmptyState
icon={Activity} icon={Activity}
@ -384,7 +384,7 @@ export default function DashboardPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!data ? ( {!data ? (
<RowSkeletons /> <SkeletonRows count={4} />
) : data.audits.length === 0 ? ( ) : data.audits.length === 0 ? (
<EmptyState <EmptyState
icon={Sparkles} icon={Sparkles}
@ -435,12 +435,3 @@ export default function DashboardPage() {
); );
} }
function RowSkeletons() {
return (
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
);
}

View file

@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/navigation/page-header';
import { import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
@ -64,19 +65,20 @@ export default function S1CoveragePage() {
}); });
return ( return (
<div className="container mx-auto py-8 space-y-6"> <>
<div className="flex items-center justify-between"> <PageHeader
<div> title="SentinelOne coverage"
<h1 className="text-3xl font-bold flex items-center gap-3"> description="AV agent coverage and threat status per site"
<Shield className="w-8 h-8 text-purple-600" /> breadcrumbs={[{ label: 'SentinelOne' }, { label: 'Coverage' }]}
SentinelOne Coverage accent
</h1> actions={
<p className="text-muted-foreground mt-1">AV agent coverage and threat status per site</p> <Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
</div> <RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}> Refresh
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />Refresh </Button>
</Button> }
</div> />
<div className="container mx-auto px-6 py-6 space-y-6">
{/* Summary cards */} {/* Summary cards */}
{summary && ( {summary && (
@ -182,6 +184,7 @@ export default function S1CoveragePage() {
</Table> </Table>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</>
); );
} }

View file

@ -3,6 +3,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { PageHeader } from '@/components/navigation/page-header';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { import {
@ -95,26 +96,26 @@ export default function S1MappingsPage() {
}; };
return ( return (
<div className="container mx-auto py-8 space-y-6"> <>
<div className="flex items-center justify-between"> <PageHeader
<div> title="SentinelOne site mappings"
<h1 className="text-3xl font-bold flex items-center gap-3"> description="Map SentinelOne sites to Autotask companies"
<Shield className="w-8 h-8 text-purple-600" /> breadcrumbs={[{ label: 'SentinelOne' }, { label: 'Mappings' }]}
SentinelOne Site Mappings accent
</h1> actions={
<p className="text-muted-foreground mt-2">Map SentinelOne sites to Autotask companies</p> <>
</div> <Button onClick={handleSync} variant="outline" size="sm" disabled={syncing}>
<div className="flex gap-2"> <RefreshCw className={`w-4 h-4 mr-2 ${syncing ? 'animate-spin' : ''}`} />
<Button onClick={handleSync} variant="outline" size="sm" disabled={syncing}> {syncing ? 'Syncing…' : 'Sync S1'}
{syncing </Button>
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Syncing...</> <Button onClick={fetchData} variant="outline" size="sm">
: <><RefreshCw className="w-4 h-4 mr-2" />Sync S1</>} <RefreshCw className="w-4 h-4 mr-2" />
</Button> Refresh
<Button onClick={fetchData} variant="outline" size="sm"> </Button>
<RefreshCw className="w-4 h-4 mr-2" />Refresh </>
</Button> }
</div> />
</div> <div className="container mx-auto px-6 py-6 space-y-6">
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
@ -182,7 +183,8 @@ export default function S1MappingsPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</>
); );
} }

View file

@ -1,7 +1,14 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth-utils"; import { getSession } from "@/lib/auth-utils";
import { ProfileForm } from "@/components/settings/profile-form"; import { ProfileForm } from "@/components/settings/profile-form";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { PageHeader } from "@/components/navigation/page-header";
export default async function SettingsPage() { export default async function SettingsPage() {
const session = await getSession(); const session = await getSession();
@ -11,25 +18,24 @@ export default async function SettingsPage() {
} }
return ( return (
<div className="container mx-auto py-8 px-4 max-w-2xl"> <>
<div className="mb-8"> <PageHeader
<h1 className="text-3xl font-bold">Settings</h1> title="Settings"
<p className="text-muted-foreground mt-2"> description="Manage your account settings"
Manage your account settings breadcrumbs={[{ label: "Settings" }]}
</p> accent
/>
<div className="container mx-auto px-6 py-6 max-w-2xl">
<Card>
<CardHeader>
<CardTitle>Profile</CardTitle>
<CardDescription>Update your personal information</CardDescription>
</CardHeader>
<CardContent>
<ProfileForm user={session.user} />
</CardContent>
</Card>
</div> </div>
</>
<Card>
<CardHeader>
<CardTitle>Profile</CardTitle>
<CardDescription>
Update your personal information
</CardDescription>
</CardHeader>
<CardContent>
<ProfileForm user={session.user} />
</CardContent>
</Card>
</div>
); );
} }

View file

@ -2,7 +2,14 @@ import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth-utils"; import { getSession } from "@/lib/auth-utils";
import { TwoFactorSetup } from "@/components/settings/two-factor-setup"; import { TwoFactorSetup } from "@/components/settings/two-factor-setup";
import { ActiveSessions } from "@/components/settings/active-sessions"; import { ActiveSessions } from "@/components/settings/active-sessions";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { PageHeader } from "@/components/navigation/page-header";
export default async function SecuritySettingsPage() { export default async function SecuritySettingsPage() {
const session = await getSession(); const session = await getSession();
@ -12,21 +19,21 @@ export default async function SecuritySettingsPage() {
} }
return ( return (
<div className="container mx-auto py-8 px-4 max-w-2xl"> <>
<div className="mb-8"> <PageHeader
<h1 className="text-3xl font-bold">Security Settings</h1> title="Security"
<p className="text-muted-foreground mt-2"> description="Manage your security preferences"
Manage your security preferences breadcrumbs={[
</p> { label: "Settings", href: "/settings" },
</div> { label: "Security" },
]}
<div className="space-y-6"> accent
/>
<div className="container mx-auto px-6 py-6 max-w-2xl space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Two-Factor Authentication</CardTitle> <CardTitle>Two-factor authentication</CardTitle>
<CardDescription> <CardDescription>Add an extra layer of security to your account.</CardDescription>
Add an extra layer of security to your account
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<TwoFactorSetup /> <TwoFactorSetup />
@ -35,16 +42,14 @@ export default async function SecuritySettingsPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Active Sessions</CardTitle> <CardTitle>Active sessions</CardTitle>
<CardDescription> <CardDescription>Manage your active sessions across devices.</CardDescription>
Manage your active sessions across devices
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ActiveSessions userId={session.user.id} /> <ActiveSessions userId={session.user.id} />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </>
); );
} }

View file

@ -15,6 +15,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { SkeletonTable } from '@/components/ui/skeleton-helpers';
import { EmptyState } from '@/components/ui/empty-state'; import { EmptyState } from '@/components/ui/empty-state';
import { StatusLight, type StatusLightState } from '@/components/ui/status-light'; import { StatusLight, type StatusLightState } from '@/components/ui/status-light';
import { StatusBadge } from '@/components/ui/status-badge'; import { StatusBadge } from '@/components/ui/status-badge';
@ -409,9 +410,7 @@ export default function StatusPage() {
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{!overview ? ( {!overview ? (
<div className="px-6 py-6"> <SkeletonTable rows={6} cols={5} />
<Skeleton className="h-32" />
</div>
) : overview.syncHealth.length === 0 ? ( ) : overview.syncHealth.length === 0 ? (
<div className="px-6 py-6"> <div className="px-6 py-6">
<EmptyState <EmptyState

View file

@ -6,14 +6,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { import DataTable, { type Column } from '@/components/admin/DataTable';
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
} from 'recharts'; } from 'recharts';
@ -140,100 +133,127 @@ function timeAgo(d: string | null) {
return `${Math.floor(h / 24)}d ago`; return `${Math.floor(h / 24)}d ago`;
} }
// ── Expandable ticket row ───────────────────────────────────────────────────── // ── Column defs + sub-row renderer ────────────────────────────────────────────
function TicketRow({ t, categoryFilter, onFilter }: { const TICKET_COLUMNS: Column<TicketRow>[] = [
t: TicketRow; {
categoryFilter: string; key: 'ticket_number',
onFilter: (cat: string) => void; label: 'Ticket',
}) { render: (v) => <span className="num font-medium">{v}</span>,
const [open, setOpen] = useState(false); },
const catCfg = CATEGORY_CFG[t.problem_category]; {
key: 'company_name',
return ( label: 'Client',
<> render: (v) => <span className="max-w-[140px] truncate inline-block">{v ?? '—'}</span>,
<TableRow },
className="cursor-pointer text-xs align-middle" {
onClick={() => setOpen(o => !o)} key: 'device_hostname',
label: 'Device',
render: (v) => <span className="num text-[11px]">{v ?? '—'}</span>,
},
{
key: 'problem_category',
label: 'Category',
render: (v) => {
const cfg = CATEGORY_CFG[v as string];
return (
<Badge
variant="outline"
style={{ borderColor: cfg?.color, color: cfg?.color }}
className="text-[10px] h-4 px-1.5 whitespace-nowrap"
>
{catLabel(v as string)}
</Badge>
);
},
},
{
key: 'resolution_type',
label: 'Resolution',
render: (v) => <span className="text-muted-foreground">{resLabel(v as string)}</span>,
},
{
key: 'same_day_close',
label: 'Same-day',
render: (v) =>
v ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
) : (
<span className="text-muted-foreground"></span>
),
},
{
key: 'hours_worked',
label: 'Hours',
render: (v) => <span className="num">{parseFloat(v as string).toFixed(2)}h</span>,
},
{
key: 'complexity',
label: 'Complexity',
render: (v) => (
<Badge
variant="outline"
style={{
borderColor: COMPLEXITY_COLOR[v as string] ?? '#6b7280',
color: COMPLEXITY_COLOR[v as string] ?? '#6b7280',
}}
className="text-[10px] h-4 px-1.5"
> >
<TableCell className="w-6 pl-3"> {v}
{open </Badge>
? <ChevronDown className="h-3 w-3 text-muted-foreground" /> ),
: <ChevronRight className="h-3 w-3 text-muted-foreground" />} },
</TableCell> {
<TableCell className="num font-medium">{t.ticket_number}</TableCell> key: 'ticket_created_at',
<TableCell className="max-w-[140px] truncate">{t.company_name ?? '—'}</TableCell> label: 'Age',
<TableCell className="num text-[11px]">{t.device_hostname ?? '—'}</TableCell> render: (v) => <span className="text-muted-foreground num">{timeAgo(v as string)}</span>,
<TableCell> },
<Badge ];
variant="outline"
style={{ borderColor: catCfg?.color, color: catCfg?.color }} function renderTicketSubRow(t: TicketRow) {
className="text-[10px] h-4 px-1.5 whitespace-nowrap" return (
> <div className="grid grid-cols-2 gap-4 text-xs">
{catLabel(t.problem_category)} <div className="space-y-1.5">
</Badge> {t.work_summary && (
</TableCell> <div>
<TableCell className="text-muted-foreground">{resLabel(t.resolution_type)}</TableCell> <span className="text-muted-foreground uppercase tracking-wide text-[10px] font-medium">Summary</span>
<TableCell className="text-center"> <p className="mt-0.5">{t.work_summary}</p>
{t.same_day_close </div>
? <CheckCircle2 className="h-3.5 w-3.5 text-emerald-500 mx-auto" /> )}
: <span className="text-muted-foreground"></span>} <div className="flex flex-wrap gap-3 text-muted-foreground">
</TableCell> {t.device_was_offline != null && (
<TableCell className="text-right num">{parseFloat(t.hours_worked).toFixed(2)}h</TableCell> <span className="flex items-center gap-1">
<TableCell> <WifiOff className="h-3 w-3" />
<Badge {t.device_was_offline ? 'Device was offline' : 'Device was online'}
variant="outline" </span>
style={{ borderColor: COMPLEXITY_COLOR[t.complexity] ?? '#6b7280', color: COMPLEXITY_COLOR[t.complexity] ?? '#6b7280' }} )}
className="text-[10px] h-4 px-1.5" {t.backup_completed_before_tech != null && (
> <span className="flex items-center gap-1">
{t.complexity} {t.backup_completed_before_tech ? (
</Badge> <>
</TableCell> <CheckCircle2 className="h-3 w-3 text-emerald-500" /> Backup auto-completed
<TableCell className="text-muted-foreground num">{timeAgo(t.ticket_created_at)}</TableCell> </>
</TableRow> ) : (
{open && ( <>
<TableRow className="bg-muted/5"> <Wrench className="h-3 w-3" /> Tech action required
<TableCell colSpan={10} className="px-8 pb-3 pt-2"> </>
<div className="grid grid-cols-2 gap-4 text-xs">
<div className="space-y-1.5">
{t.work_summary && (
<div>
<span className="text-muted-foreground uppercase tracking-wide text-[10px] font-medium">Summary</span>
<p className="mt-0.5">{t.work_summary}</p>
</div>
)}
<div className="flex flex-wrap gap-3 text-muted-foreground">
{t.device_was_offline != null && (
<span className="flex items-center gap-1">
<WifiOff className="h-3 w-3" />
{t.device_was_offline ? 'Device was offline' : 'Device was online'}
</span>
)}
{t.backup_completed_before_tech != null && (
<span className="flex items-center gap-1">
{t.backup_completed_before_tech
? <><CheckCircle2 className="h-3 w-3 text-green-500" /> Backup auto-completed</>
: <><Wrench className="h-3 w-3" /> Tech action required</>}
</span>
)}
{t.preventable != null && (
<span className={t.preventable ? 'text-amber-500' : ''}>
{t.preventable ? '⚠ Preventable' : '✓ Not preventable'}
</span>
)}
</div>
</div>
{t.recommended_procedure && (
<div>
<span className="text-muted-foreground uppercase tracking-wide text-[10px] font-medium">Recommended SOP</span>
<p className="mt-0.5 text-muted-foreground italic">{t.recommended_procedure}</p>
</div>
)} )}
</div> </span>
</TableCell> )}
</TableRow> {t.preventable != null && (
<span className={t.preventable ? 'text-amber-500' : ''}>
{t.preventable ? '⚠ Preventable' : '✓ Not preventable'}
</span>
)}
</div>
</div>
{t.recommended_procedure && (
<div>
<span className="text-muted-foreground uppercase tracking-wide text-[10px] font-medium">Recommended SOP</span>
<p className="mt-0.5 text-muted-foreground italic">{t.recommended_procedure}</p>
</div>
)} )}
</> </div>
); );
} }
@ -675,60 +695,22 @@ export default function VeeamAnalysisPage() {
<RefreshCw className="h-3.5 w-3.5" /> <RefreshCw className="h-3.5 w-3.5" />
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="px-0 pb-3">
<Table> <DataTable<TicketRow>
<TableHeader className="bg-muted/50"> columns={TICKET_COLUMNS}
<TableRow> data={data.tickets}
<TableHead className="w-6" /> totalCount={data.total}
<TableHead>Ticket</TableHead> page={page}
<TableHead>Client</TableHead> pageSize={data.limit ?? 50}
<TableHead>Device</TableHead> onPageChange={(next) => {
<TableHead>Category</TableHead> setPage(next);
<TableHead>Resolution</TableHead> fetchData(catFilter, next);
<TableHead className="text-center">Same-day</TableHead> }}
<TableHead className="text-right">Hours</TableHead> emptyTitle="No analyzed tickets yet"
<TableHead>Complexity</TableHead> emptyDescription="Run the analysis above to populate this list."
<TableHead>Age</TableHead> getRowCanExpand={() => true}
</TableRow> renderSubRow={renderTicketSubRow}
</TableHeader> />
<TableBody>
{data.tickets.length > 0
? data.tickets.map(t => (
<TicketRow
key={t.ticket_number}
t={t}
categoryFilter={catFilter}
onFilter={handleCatFilter}
/>
))
: (
<TableRow>
<TableCell colSpan={10} className="px-4 py-10 text-center text-sm text-muted-foreground">
No analyzed tickets yet run the analysis above.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t text-xs text-muted-foreground">
<span>Page {page} of {totalPages}</span>
<div className="flex gap-2">
<Button
variant="outline" size="sm"
disabled={page <= 1}
onClick={() => { setPage(p => p - 1); fetchData(catFilter, page - 1); }}
>Previous</Button>
<Button
variant="outline" size="sm"
disabled={page >= totalPages}
onClick={() => { setPage(p => p + 1); fetchData(catFilter, page + 1); }}
>Next</Button>
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
)} )}