diff --git a/app/(app)/admin/_components/ProductFormDialog.tsx b/app/(app)/admin/_components/ProductFormDialog.tsx new file mode 100644 index 0000000..35690aa --- /dev/null +++ b/app/(app)/admin/_components/ProductFormDialog.tsx @@ -0,0 +1,122 @@ +'use client' + +import { useState, useTransition } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogClose, +} from '@/components/ui/dialog' +import { adminCreateProductAction, adminUpdateProductAction } from '@/actions/admin/products' + +type User = { id: string; username: string } + +type ProductData = { + id: string + name: string + description: string | null + repo_url: string | null + definition_of_done: string + auto_pr: boolean + user_id: string +} + +interface Props { + mode: 'create' | 'edit' + product?: ProductData + allUsers: User[] + trigger: React.ReactNode +} + +export function ProductFormDialog({ mode, product, allUsers, trigger }: Props) { + const [open, setOpen] = useState(false) + const [pending, startTransition] = useTransition() + const [error, setError] = useState(null) + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + const fd = new FormData(e.currentTarget) + const data = { + name: fd.get('name') as string, + description: fd.get('description') as string || undefined, + repo_url: fd.get('repo_url') as string || undefined, + definition_of_done: fd.get('definition_of_done') as string, + auto_pr: fd.get('auto_pr') === 'on', + owner_user_id: fd.get('owner_user_id') as string, + } + + setError(null) + startTransition(async () => { + try { + if (mode === 'create') { + await adminCreateProductAction(data) + } else { + await adminUpdateProductAction(product!.id, data) + } + setOpen(false) + } catch (err) { + setError(err instanceof Error ? err.message : 'Onbekende fout') + } + }) + } + + return ( + + }>{trigger} + + + {mode === 'create' ? 'Nieuw product' : 'Product bewerken'} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {mode === 'create' && ( +
+ + +
+ )} + {mode === 'edit' && ( + + )} +
+ + +
+ {error && ( +
{error}
+ )} + + }>Annuleer + + +
+
+
+ ) +} diff --git a/app/(app)/admin/products/[id]/_components/MemberActions.tsx b/app/(app)/admin/products/[id]/_components/MemberActions.tsx new file mode 100644 index 0000000..deb3445 --- /dev/null +++ b/app/(app)/admin/products/[id]/_components/MemberActions.tsx @@ -0,0 +1,54 @@ +'use client' + +import { useRef, useTransition } from 'react' +import { Button } from '@/components/ui/button' +import { adminAddMemberAction, adminRemoveMemberAction } from '@/actions/admin/products' + +type NonMember = { id: string; username: string } + +type Props = + | { productId: string; userId: string; action: 'remove'; label: string; nonMembers?: never } + | { productId: string; userId?: never; action: 'add'; label: string; nonMembers: NonMember[] } + +export function MemberActions({ productId, userId, action, label, nonMembers }: Props) { + const [pending, startTransition] = useTransition() + const selectRef = useRef(null) + + function handleRemove() { + startTransition(async () => { + await adminRemoveMemberAction(productId, userId!) + }) + } + + function handleAdd() { + const selectedId = selectRef.current?.value + if (!selectedId) return + startTransition(async () => { + await adminAddMemberAction(productId, selectedId) + }) + } + + if (action === 'remove') { + return ( + + ) + } + + return ( +
+ + +
+ ) +} diff --git a/app/(app)/admin/products/[id]/page.tsx b/app/(app)/admin/products/[id]/page.tsx new file mode 100644 index 0000000..30c6268 --- /dev/null +++ b/app/(app)/admin/products/[id]/page.tsx @@ -0,0 +1,104 @@ +import Link from 'next/link' +import { notFound } from 'next/navigation' +import { requireAdmin } from '@/lib/auth-guard' +import { prisma } from '@/lib/prisma' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { MemberActions } from './_components/MemberActions' + +export default async function AdminProductDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + await requireAdmin() + const { id } = await params + + const product = await prisma.product.findUnique({ + where: { id }, + include: { + user: { select: { username: true } }, + members: { + include: { user: { select: { id: true, username: true, email: true } } }, + orderBy: { created_at: 'asc' }, + }, + }, + }) + + if (!product) notFound() + + const nonMembers = await prisma.user.findMany({ + where: { NOT: { product_members: { some: { product_id: id } } } }, + select: { id: true, username: true }, + orderBy: { username: 'asc' }, + }) + + return ( +
+
+ + ← Terug naar producten + +
+ +
+

{product.name}

+

Eigenaar: {product.user.username}

+ {product.repo_url && ( + + {product.repo_url} + + )} +
+ +
+

Leden ({product.members.length})

+ + + + Gebruiker + Email + Acties + + + + {product.members.map(m => ( + + {m.user.username} + {m.user.email ?? '—'} + + + + + ))} + {product.members.length === 0 && ( + + + Geen leden. + + + )} + +
+ + {nonMembers.length > 0 && ( +
+ Lid toevoegen: + +
+ )} +
+
+ ) +} diff --git a/app/(app)/admin/products/_components/ProductActions.tsx b/app/(app)/admin/products/_components/ProductActions.tsx new file mode 100644 index 0000000..2a5e87b --- /dev/null +++ b/app/(app)/admin/products/_components/ProductActions.tsx @@ -0,0 +1,76 @@ +'use client' + +import { useTransition, useState } from 'react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogClose, +} from '@/components/ui/dialog' +import { adminArchiveProductAction, adminDeleteProductAction } from '@/actions/admin/products' +import { ProductFormDialog } from '../../_components/ProductFormDialog' + +type User = { id: string; username: string } +type Product = { + id: string + name: string + description: string | null + repo_url: string | null + definition_of_done: string + auto_pr: boolean + archived: boolean + user_id: string +} + +export function ProductActions({ product, allUsers }: { product: Product; allUsers: User[] }) { + const [deleteOpen, setDeleteOpen] = useState(false) + const [pending, startTransition] = useTransition() + + function handleArchive() { + startTransition(async () => { + await adminArchiveProductAction(product.id, !product.archived) + }) + } + + function handleDelete() { + startTransition(async () => { + await adminDeleteProductAction(product.id) + setDeleteOpen(false) + }) + } + + return ( +
+ Bewerken} + /> + + + }>Verwijder + + + Product verwijderen + +

+ Weet je zeker dat je {product.name} wilt verwijderen? Alle PBIs, stories en taken worden ook verwijderd. +

+ + }>Annuleer + + +
+
+
+ ) +} diff --git a/app/(app)/admin/products/page.tsx b/app/(app)/admin/products/page.tsx new file mode 100644 index 0000000..1e63208 --- /dev/null +++ b/app/(app)/admin/products/page.tsx @@ -0,0 +1,93 @@ +import Link from 'next/link' +import { requireAdmin } from '@/lib/auth-guard' +import { prisma } from '@/lib/prisma' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { ProductFormDialog } from '../_components/ProductFormDialog' +import { ProductActions } from './_components/ProductActions' + +export default async function AdminProductsPage() { + await requireAdmin() + + const [products, allUsers] = await Promise.all([ + prisma.product.findMany({ + include: { + user: { select: { username: true } }, + _count: { select: { members: true, pbis: true } }, + }, + orderBy: { created_at: 'desc' }, + }), + prisma.user.findMany({ + select: { id: true, username: true }, + orderBy: { username: 'asc' }, + }), + ]) + + return ( +
+
+

Producten

+ Nieuw product} + /> +
+ + + + Naam + Eigenaar + Leden + PBIs + Status + Aangemaakt + Acties + + + + {products.map(p => ( + + + + {p.name} + + + {p.user.username} + {p._count.members} + {p._count.pbis} + + {p.archived ? ( + Gearchiveerd + ) : ( + Actief + )} + + + {new Date(p.created_at).toLocaleDateString('nl-NL')} + + + + + + ))} + {products.length === 0 && ( + + + Geen producten gevonden. + + + )} + +
+
+ ) +}