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 ( +
+

Producten

+ +
+ ) +} 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 ( + + }> + Verwijder + + + + Product verwijderen + +

+ Weet je zeker dat je {product.name} wilt verwijderen? + Dit verwijdert ook alle PBI's, stories en taken. Dit kan niet ongedaan worden gemaakt. +

+ + }>Annuleer + + +
+
+ ) +} + +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')} + + +
+ + +
+
+
+ ))} +
+
+ ) +}