diff --git a/app/(app)/admin/jobs/page.tsx b/app/(app)/admin/jobs/page.tsx
new file mode 100644
index 0000000..b1c9920
--- /dev/null
+++ b/app/(app)/admin/jobs/page.tsx
@@ -0,0 +1,30 @@
+import { requireAdmin } from '@/lib/auth-guard'
+import { prisma } from '@/lib/prisma'
+import { JobsTable } from '@/components/admin/jobs-table'
+
+export default async function AdminJobsPage() {
+ await requireAdmin()
+
+ const jobs = await prisma.claudeJob.findMany({
+ orderBy: { created_at: 'desc' },
+ take: 100,
+ select: {
+ id: true,
+ kind: true,
+ status: true,
+ created_at: true,
+ branch: true,
+ pr_url: true,
+ error: true,
+ user: { select: { username: true } },
+ product: { select: { name: true } },
+ },
+ })
+
+ return (
+
+
Claude Jobs
+
+
+ )
+}
diff --git a/app/(app)/admin/products/page.tsx b/app/(app)/admin/products/page.tsx
new file mode 100644
index 0000000..11081d3
--- /dev/null
+++ b/app/(app)/admin/products/page.tsx
@@ -0,0 +1,26 @@
+import { requireAdmin } from '@/lib/auth-guard'
+import { prisma } from '@/lib/prisma'
+import { ProductsTable } from '@/components/admin/products-table'
+
+export default async function AdminProductsPage() {
+ await requireAdmin()
+
+ const products = await prisma.product.findMany({
+ orderBy: { created_at: 'desc' },
+ select: {
+ id: true,
+ name: true,
+ archived: true,
+ created_at: true,
+ user: { select: { username: true } },
+ _count: { select: { members: true, pbis: true } },
+ },
+ })
+
+ return (
+
+ )
+}
diff --git a/components/admin/jobs-table.tsx b/components/admin/jobs-table.tsx
new file mode 100644
index 0000000..faca242
--- /dev/null
+++ b/components/admin/jobs-table.tsx
@@ -0,0 +1,124 @@
+'use client'
+
+import { useTransition } from 'react'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { cancelJobAction, deleteJobAction } from '@/actions/admin/jobs'
+
+type Job = {
+ id: string
+ kind: string
+ status: string
+ created_at: Date
+ user: { username: string }
+ product: { name: string }
+ branch: string | null
+ pr_url: string | null
+ error: string | null
+}
+
+const STATUS_CLASS: Record = {
+ QUEUED: 'bg-secondary text-secondary-foreground',
+ CLAIMED: 'bg-status-in-progress text-white border-transparent',
+ RUNNING: 'bg-warning text-warning-foreground border-transparent',
+ DONE: 'bg-status-done text-white border-transparent',
+ FAILED: 'bg-priority-high text-white border-transparent',
+ CANCELLED: 'bg-muted text-muted-foreground',
+}
+
+const KIND_LABEL: Record = {
+ TASK_IMPLEMENTATION: 'Taak',
+ IDEA_GRILL: 'Idee Grill',
+ IDEA_MAKE_PLAN: 'Idee Plan',
+}
+
+const ACTIVE_STATUSES = new Set(['QUEUED', 'CLAIMED', 'RUNNING'])
+
+function JobRow({ job }: { job: Job }) {
+ const [pending, startTransition] = useTransition()
+
+ function handleCancel() {
+ startTransition(() => cancelJobAction(job.id))
+ }
+
+ function handleDelete() {
+ startTransition(() => deleteJobAction(job.id))
+ }
+
+ return (
+
+ {job.id.slice(0, 8)}
+ {job.user.username}
+ {job.product.name}
+ {KIND_LABEL[job.kind] ?? job.kind}
+
+ {job.status}
+
+
+ {job.branch ?? '—'}
+
+
+ {new Date(job.created_at).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })}
+
+
+ {job.error && (
+
+ {job.error}
+
+ )}
+
+
+
+ {ACTIVE_STATUSES.has(job.status) && (
+
+ )}
+
+
+
+
+ )
+}
+
+export function JobsTable({ jobs }: { jobs: Job[] }) {
+ return (
+
+
+
+ ID
+ Gebruiker
+ Product
+ Type
+ Status
+ Branch
+ Aangemaakt
+ Fout
+ Acties
+
+
+
+ {jobs.length === 0 && (
+
+
+ Geen jobs gevonden
+
+
+ )}
+ {jobs.map(job => (
+
+ ))}
+
+
+ )
+}
diff --git a/components/admin/products-table.tsx b/components/admin/products-table.tsx
new file mode 100644
index 0000000..f66a8a1
--- /dev/null
+++ b/components/admin/products-table.tsx
@@ -0,0 +1,128 @@
+'use client'
+
+import { useTransition } from 'react'
+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 { adminArchiveProductAction, adminDeleteProductAction } from '@/actions/admin/products'
+
+type Product = {
+ id: string
+ name: string
+ archived: boolean
+ created_at: Date
+ user: { username: string }
+ _count: { members: number; pbis: number }
+}
+
+function ArchiveButton({ product }: { product: Product }) {
+ const [pending, startTransition] = useTransition()
+
+ function handleToggle() {
+ startTransition(() => adminArchiveProductAction(product.id, !product.archived))
+ }
+
+ return (
+
+ )
+}
+
+function DeleteDialog({ product }: { product: Product }) {
+ const [pending, startTransition] = useTransition()
+
+ function handleDelete() {
+ startTransition(() => adminDeleteProductAction(product.id))
+ }
+
+ return (
+
+ )
+}
+
+export function ProductsTable({ products }: { products: Product[] }) {
+ return (
+
+
+
+ Naam
+ Eigenaar
+ Leden
+ PBI's
+ Status
+ Aangemaakt
+ Acties
+
+
+
+ {products.length === 0 && (
+
+
+ Geen producten gevonden
+
+
+ )}
+ {products.map(product => (
+
+ {product.name}
+ {product.user.username}
+ {product._count.members}
+ {product._count.pbis}
+
+ {product.archived ? (
+ Gearchiveerd
+ ) : (
+ Actief
+ )}
+
+
+ {new Date(product.created_at).toLocaleDateString('nl-NL')}
+
+
+
+
+
+ ))}
+
+
+ )
+}