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:
parent
9bfb57553d
commit
ab78e7bd4f
8 changed files with 242 additions and 250 deletions
|
|
@ -6,14 +6,7 @@ import { Badge } from '@/components/ui/badge';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import DataTable, { type Column } from '@/components/admin/DataTable';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
|
||||
} from 'recharts';
|
||||
|
|
@ -140,100 +133,127 @@ function timeAgo(d: string | null) {
|
|||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
// ── Expandable ticket row ─────────────────────────────────────────────────────
|
||||
// ── Column defs + sub-row renderer ────────────────────────────────────────────
|
||||
|
||||
function TicketRow({ t, categoryFilter, onFilter }: {
|
||||
t: TicketRow;
|
||||
categoryFilter: string;
|
||||
onFilter: (cat: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const catCfg = CATEGORY_CFG[t.problem_category];
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
className="cursor-pointer text-xs align-middle"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
const TICKET_COLUMNS: Column<TicketRow>[] = [
|
||||
{
|
||||
key: 'ticket_number',
|
||||
label: 'Ticket',
|
||||
render: (v) => <span className="num font-medium">{v}</span>,
|
||||
},
|
||||
{
|
||||
key: 'company_name',
|
||||
label: 'Client',
|
||||
render: (v) => <span className="max-w-[140px] truncate inline-block">{v ?? '—'}</span>,
|
||||
},
|
||||
{
|
||||
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">
|
||||
{open
|
||||
? <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>
|
||||
<TableCell className="max-w-[140px] truncate">{t.company_name ?? '—'}</TableCell>
|
||||
<TableCell className="num text-[11px]">{t.device_hostname ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
style={{ borderColor: catCfg?.color, color: catCfg?.color }}
|
||||
className="text-[10px] h-4 px-1.5 whitespace-nowrap"
|
||||
>
|
||||
{catLabel(t.problem_category)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{resLabel(t.resolution_type)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{t.same_day_close
|
||||
? <CheckCircle2 className="h-3.5 w-3.5 text-emerald-500 mx-auto" />
|
||||
: <span className="text-muted-foreground">—</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-right num">{parseFloat(t.hours_worked).toFixed(2)}h</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
style={{ borderColor: COMPLEXITY_COLOR[t.complexity] ?? '#6b7280', color: COMPLEXITY_COLOR[t.complexity] ?? '#6b7280' }}
|
||||
className="text-[10px] h-4 px-1.5"
|
||||
>
|
||||
{t.complexity}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground num">{timeAgo(t.ticket_created_at)}</TableCell>
|
||||
</TableRow>
|
||||
{open && (
|
||||
<TableRow className="bg-muted/5">
|
||||
<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>
|
||||
{v}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ticket_created_at',
|
||||
label: 'Age',
|
||||
render: (v) => <span className="text-muted-foreground num">{timeAgo(v as string)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
function renderTicketSubRow(t: TicketRow) {
|
||||
return (
|
||||
<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-emerald-500" /> Backup auto-completed
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wrench className="h-3 w-3" /> Tech action required
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -675,60 +695,22 @@ export default function VeeamAnalysisPage() {
|
|||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead className="w-6" />
|
||||
<TableHead>Ticket</TableHead>
|
||||
<TableHead>Client</TableHead>
|
||||
<TableHead>Device</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Resolution</TableHead>
|
||||
<TableHead className="text-center">Same-day</TableHead>
|
||||
<TableHead className="text-right">Hours</TableHead>
|
||||
<TableHead>Complexity</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</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 className="px-0 pb-3">
|
||||
<DataTable<TicketRow>
|
||||
columns={TICKET_COLUMNS}
|
||||
data={data.tickets}
|
||||
totalCount={data.total}
|
||||
page={page}
|
||||
pageSize={data.limit ?? 50}
|
||||
onPageChange={(next) => {
|
||||
setPage(next);
|
||||
fetchData(catFilter, next);
|
||||
}}
|
||||
emptyTitle="No analyzed tickets yet"
|
||||
emptyDescription="Run the analysis above to populate this list."
|
||||
getRowCanExpand={() => true}
|
||||
renderSubRow={renderTicketSubRow}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue