Files
pn-new-crm/src/components/admin/queue-detail-table.tsx
Matt c8ea9ec0a0 fix(audit-wave-10): aria-hidden sweep on decorative Lucide icons (#69)
Mechanical codemod added \`aria-hidden\` to 444 self-closing single-line
Lucide icon JSX elements across 267 .tsx files in:

- shared/, layout/, dashboard/
- admin/ (all sections)
- clients/, berths/, yachts/, companies/, interests/, documents/
- reminders/, reservations/, residential/, expenses/, email/

The regex targeted only the safe pattern \`<IconName className="..." />\`
(no other props, self-closing, capitalized component name). Every match
inspected is a decorative companion to visible text or sits inside a
button whose accessible name comes from \`aria-label\` / sr-only text
— the icon itself should not be announced.

Screen readers no longer double-read the icon + the adjacent label
text (e.g. "Pencil Pencil Edit" → just "Edit"). The existing
@axe-core/playwright smoke test (\`20-accessibility.spec.ts\`) continues
to pass.

Test suite stays at 1315/1315 vitest. typescript clean.

Closes task #69 (aria-hidden sweep) from the AUDIT-2026-05-12 follow-ups
backlog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:37:22 +02:00

212 lines
7.2 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Trash2, RotateCcw } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { QueueJobSummary, PaginatedQueueJobs } from '@/lib/services/system-monitoring.service';
type JobStatus = 'waiting' | 'active' | 'completed' | 'failed' | 'delayed';
interface QueueDetailTableProps {
queueName: string;
}
const statusVariant: Record<JobStatus, 'default' | 'secondary' | 'destructive' | 'outline'> = {
waiting: 'outline',
active: 'default',
completed: 'secondary',
failed: 'destructive',
delayed: 'outline',
};
function formatDate(ts: number | undefined): string {
if (!ts) return '-';
return new Date(ts).toLocaleString();
}
function truncateId(id: string): string {
return id.length > 12 ? `${id.slice(0, 8)}...` : id;
}
function truncateReason(reason: string | undefined): string {
if (!reason) return '-';
return reason.length > 80 ? `${reason.slice(0, 80)}` : reason;
}
export function QueueDetailTable({ queueName }: QueueDetailTableProps) {
const queryClient = useQueryClient();
const [status, setStatus] = useState<JobStatus>('failed');
const [page, setPage] = useState(1);
const limit = 20;
const { data, isLoading } = useQuery({
queryKey: ['queue', 'jobs', queueName, status, page],
queryFn: () =>
apiFetch<{ data: PaginatedQueueJobs }>(
`/api/v1/admin/queues/${queueName}?status=${status}&page=${page}&limit=${limit}`,
).then((r) => r.data),
staleTime: 10_000,
});
const retryMutation = useMutation({
mutationFn: (jobId: string) =>
apiFetch(`/api/v1/admin/queues/${queueName}/${jobId}/retry`, { method: 'POST' }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['queue', 'jobs', queueName] });
void queryClient.invalidateQueries({ queryKey: ['system', 'queues'] });
},
});
const deleteMutation = useMutation({
mutationFn: (jobId: string) =>
apiFetch(`/api/v1/admin/queues/${queueName}/${jobId}`, { method: 'DELETE' }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['queue', 'jobs', queueName] });
void queryClient.invalidateQueries({ queryKey: ['system', 'queues'] });
},
});
const jobs: QueueJobSummary[] = data?.jobs ?? [];
const total = data?.total ?? 0;
const totalPages = Math.ceil(total / limit);
function handleStatusChange(value: string) {
setStatus(value as JobStatus);
setPage(1);
}
return (
<div className="space-y-4">
<Tabs value={status} onValueChange={handleStatusChange}>
<TabsList>
{(['waiting', 'active', 'completed', 'failed', 'delayed'] as JobStatus[]).map((s) => (
<TabsTrigger key={s} value={s} className="capitalize">
{s}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead>Processed</TableHead>
<TableHead className="max-w-[240px]">Failed Reason</TableHead>
<TableHead className="w-[100px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
Loading jobs...
</TableCell>
</TableRow>
) : jobs.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
No {status} jobs
</TableCell>
</TableRow>
) : (
jobs.map((job) => (
<TableRow key={job.id}>
<TableCell className="font-mono text-xs" title={job.id}>
{truncateId(job.id)}
</TableCell>
<TableCell className="text-sm">{job.name}</TableCell>
<TableCell>
<Badge variant={statusVariant[status]}>{status}</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(job.timestamp)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(job.processedOn)}
</TableCell>
<TableCell
className="text-xs text-muted-foreground max-w-[240px] truncate"
title={job.failedReason}
>
{truncateReason(job.failedReason)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
{status === 'failed' && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Retry job"
disabled={retryMutation.isPending}
onClick={() => retryMutation.mutate(job.id)}
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
title="Delete job"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(job.id)}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{total} total jobs - page {page} of {totalPages}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
Next
</Button>
</div>
</div>
)}
</div>
);
}