- 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
176 lines
5.4 KiB
TypeScript
176 lines
5.4 KiB
TypeScript
'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>
|
|
)
|
|
}
|