Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
Scrum4Me Agent
667b1484f6 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
2026-05-05 14:51:36 +02:00
4 changed files with 239 additions and 0 deletions

View file

@ -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<ClaudeJobStatus>([
'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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-foreground">Claude Jobs</h1>
<StatusFilter current={rawStatus} />
</div>
<JobsTable jobs={jobs} />
</div>
)
}

View file

@ -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 (
<div className="flex min-h-screen">
<nav className="w-48 border-r p-4 flex flex-col gap-2">
<Link href="/admin/users" className="text-sm font-medium text-foreground hover:text-primary">Gebruikers</Link>
<Link href="/admin/jobs" className="text-sm font-medium text-foreground hover:text-primary">Claude Jobs</Link>
<Link href="/admin/products" className="text-sm font-medium text-foreground hover:text-primary">Producten</Link>
</nav>
<main className="flex-1 p-6">{children}</main>
</div>
)
}

5
app/(app)/admin/page.tsx Normal file
View file

@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function AdminPage() {
redirect('/admin/jobs')
}

View file

@ -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<ClaudeJobStatus, string> = {
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button variant="destructive" size="sm" />}>
Verwijder
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Job verwijderen</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Weet je zeker dat je job <strong className="font-mono text-xs">{job.id.slice(-8)}</strong> wilt verwijderen?
</p>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>Annuleer</DialogClose>
<Button variant="destructive" onClick={handleDelete} disabled={pending}>
{pending ? 'Verwijderen…' : 'Verwijderen'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
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 (
<Button
variant="outline"
size="sm"
disabled={isTerminal || pending}
onClick={handleCancel}
title={isTerminal ? 'Job is al in eindstatus' : undefined}
>
{pending ? '…' : 'Annuleer'}
</Button>
)
}
export function StatusFilter({ current }: { current: string }) {
const router = useRouter()
return (
<select
value={current}
onChange={e => router.push(e.target.value ? `?status=${e.target.value}` : '?')}
className="text-sm border border-border rounded-md px-2 py-1 bg-background text-foreground"
>
<option value="">Alle statussen</option>
{ALL_STATUSES.map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
)
}
export function JobsTable({ jobs }: { jobs: Job[] }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Status</TableHead>
<TableHead>Kind</TableHead>
<TableHead>User</TableHead>
<TableHead>Product</TableHead>
<TableHead>Aangemaakt</TableHead>
<TableHead>Error</TableHead>
<TableHead className="text-right">Acties</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map(job => (
<TableRow key={job.id}>
<TableCell>
<Badge className={STATUS_CLASS[job.status]}>{job.status}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{job.kind}</TableCell>
<TableCell className="font-medium">{job.user.username}</TableCell>
<TableCell className="text-muted-foreground">{job.product.name}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{new Date(job.created_at).toLocaleString('nl-NL')}
</TableCell>
<TableCell className="max-w-[200px]">
{job.error ? (
<span className="text-xs text-destructive truncate block" title={job.error}>
{job.error.slice(0, 80)}{job.error.length > 80 ? '…' : ''}
</span>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</TableCell>
<TableCell>
<div className="flex gap-2 justify-end">
<CancelButton job={job} />
<DeleteDialog job={job} />
</div>
</TableCell>
</TableRow>
))}
{jobs.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
Geen jobs gevonden.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)
}