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
This commit is contained in:
parent
788920b790
commit
667b1484f6
4 changed files with 239 additions and 0 deletions
42
app/(app)/admin/jobs/page.tsx
Normal file
42
app/(app)/admin/jobs/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
app/(app)/admin/layout.tsx
Normal file
16
app/(app)/admin/layout.tsx
Normal 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
5
app/(app)/admin/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function AdminPage() {
|
||||
redirect('/admin/jobs')
|
||||
}
|
||||
176
components/admin/jobs-table.tsx
Normal file
176
components/admin/jobs-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue