From 667b1484f69a032ccc5e4dcd48fba1fc9bd8442d Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Tue, 5 May 2026 14:51:36 +0200 Subject: [PATCH] feat(ST-xmwvqru1): /admin/jobs pagina met status-filter, cancel en delete-dialog - app/(app)/admin/layout.tsx: sidebar-nav (Gebruikers/Claude Jobs/Producten) - app/(app)/admin/page.tsx: redirect naar /admin/jobs - app/(app)/admin/jobs/page.tsx: server component, query jobs+user+product, status-filter via searchParams - components/admin/jobs-table.tsx: StatusFilter (router.push op select), JobsTable met status-badges (MD3 tokens), CancelButton (disabled in eindstatus), DeleteDialog --- app/(app)/admin/jobs/page.tsx | 42 ++++++++ app/(app)/admin/layout.tsx | 16 +++ app/(app)/admin/page.tsx | 5 + components/admin/jobs-table.tsx | 176 ++++++++++++++++++++++++++++++++ 4 files changed, 239 insertions(+) create mode 100644 app/(app)/admin/jobs/page.tsx create mode 100644 app/(app)/admin/layout.tsx create mode 100644 app/(app)/admin/page.tsx create mode 100644 components/admin/jobs-table.tsx diff --git a/app/(app)/admin/jobs/page.tsx b/app/(app)/admin/jobs/page.tsx new file mode 100644 index 0000000..cd91302 --- /dev/null +++ b/app/(app)/admin/jobs/page.tsx @@ -0,0 +1,42 @@ +import { requireAdmin } from '@/lib/auth-guard' +import { prisma } from '@/lib/prisma' +import type { ClaudeJobStatus } from '@prisma/client' +import { JobsTable, StatusFilter } from '@/components/admin/jobs-table' + +const VALID_STATUSES = new Set([ + 'QUEUED', 'CLAIMED', 'RUNNING', 'DONE', 'FAILED', 'CANCELLED', +]) + +export default async function AdminJobsPage({ + searchParams, +}: { + searchParams: Promise<{ status?: string }> +}) { + await requireAdmin() + + const params = await searchParams + const rawStatus = params.status ?? '' + const statusFilter = VALID_STATUSES.has(rawStatus as ClaudeJobStatus) + ? (rawStatus as ClaudeJobStatus) + : undefined + + const jobs = await prisma.claudeJob.findMany({ + where: statusFilter ? { status: statusFilter } : undefined, + include: { + user: { select: { username: true } }, + product: { select: { name: true } }, + }, + orderBy: { created_at: 'desc' }, + take: 200, + }) + + return ( +
+
+

Claude Jobs

+ +
+ +
+ ) +} diff --git a/app/(app)/admin/layout.tsx b/app/(app)/admin/layout.tsx new file mode 100644 index 0000000..6c2c912 --- /dev/null +++ b/app/(app)/admin/layout.tsx @@ -0,0 +1,16 @@ +import { requireAdmin } from '@/lib/auth-guard' +import Link from 'next/link' + +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + await requireAdmin() + return ( +
+ +
{children}
+
+ ) +} diff --git a/app/(app)/admin/page.tsx b/app/(app)/admin/page.tsx new file mode 100644 index 0000000..900d502 --- /dev/null +++ b/app/(app)/admin/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default function AdminPage() { + redirect('/admin/jobs') +} diff --git a/components/admin/jobs-table.tsx b/components/admin/jobs-table.tsx new file mode 100644 index 0000000..d25a683 --- /dev/null +++ b/components/admin/jobs-table.tsx @@ -0,0 +1,176 @@ +'use client' + +import { useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' +import type { ClaudeJobStatus } from '@prisma/client' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogClose, +} from '@/components/ui/dialog' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { cancelJobAction, deleteJobAction } from '@/actions/admin/jobs' + +const TERMINAL_STATUSES: ClaudeJobStatus[] = ['DONE', 'FAILED', 'CANCELLED'] + +const STATUS_CLASS: Record = { + QUEUED: 'bg-status-todo/15 text-status-todo border-status-todo/30', + CLAIMED: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30', + RUNNING: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30', + DONE: 'bg-status-done/15 text-status-done border-status-done/30', + FAILED: 'bg-destructive/15 text-destructive border-destructive/30', + CANCELLED: 'bg-secondary text-secondary-foreground border-border', +} + +type Job = { + id: string + status: ClaudeJobStatus + kind: string + user: { username: string } + product: { name: string } + created_at: Date + error: string | null +} + +const ALL_STATUSES: ClaudeJobStatus[] = ['QUEUED', 'CLAIMED', 'RUNNING', 'DONE', 'FAILED', 'CANCELLED'] + +function DeleteDialog({ job }: { job: Job }) { + const [open, setOpen] = useState(false) + const [pending, startTransition] = useTransition() + + function handleDelete() { + startTransition(async () => { + await deleteJobAction(job.id) + setOpen(false) + }) + } + + return ( + + }> + Verwijder + + + + Job verwijderen + +

+ Weet je zeker dat je job {job.id.slice(-8)} wilt verwijderen? +

+ + }>Annuleer + + +
+
+ ) +} + +function CancelButton({ job }: { job: Job }) { + const [pending, startTransition] = useTransition() + const isTerminal = TERMINAL_STATUSES.includes(job.status) + + function handleCancel() { + startTransition(async () => { + await cancelJobAction(job.id) + }) + } + + return ( + + ) +} + +export function StatusFilter({ current }: { current: string }) { + const router = useRouter() + return ( + + ) +} + +export function JobsTable({ jobs }: { jobs: Job[] }) { + return ( + + + + Status + Kind + User + Product + Aangemaakt + Error + Acties + + + + {jobs.map(job => ( + + + {job.status} + + {job.kind} + {job.user.username} + {job.product.name} + + {new Date(job.created_at).toLocaleString('nl-NL')} + + + {job.error ? ( + + {job.error.slice(0, 80)}{job.error.length > 80 ? '…' : ''} + + ) : ( + + )} + + +
+ + +
+
+
+ ))} + {jobs.length === 0 && ( + + + Geen jobs gevonden. + + + )} +
+
+ ) +}