feat(PBI-98/T-1089): deleteProductAction + DeleteProductConfirm

- actions/products.ts: nieuwe owner-only deleteProductAction (demo-403,
  scope-check via user_id, transaction: null active_product_id + delete).
  Cascade-deletes voor PBI/Story/Task/Doc gebeuren via Prisma onDelete:
  Cascade in schema.
- components/dashboard/delete-product-confirm.tsx: controlled AlertDialog
  (open/onOpenChange) zodat dropdown-item kan triggeren. Bevestiging
  roept deleteProductAction; success → toast + router.refresh.
- ProductRowActions: Verwijderen-item toegevoegd in dropdown (na
  Separator), text-destructive styling. Opent DeleteProductConfirm via
  lokale state.
- ProductsTable doorgeeft product.name aan ProductRowActions zodat de
  confirm-dialog de naam kan tonen.
- 1028 tests blijven groen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-16 15:38:19 +02:00
parent 972e415fc9
commit 98526f9f20
4 changed files with 127 additions and 2 deletions

View file

@ -306,6 +306,33 @@ export async function restoreProductAction(id: string) {
return { success: true }
}
// PBI-98 / T-1089: owner-only product-delete vanuit de dashboard-tabel.
// Cascade-delete wordt afgehandeld door Prisma (onDelete: Cascade op PBI,
// Story, Task, ClaudeJob, ProductDoc etc.). Bestaande active_product_id
// referenties worden eerst genulleerd.
export async function deleteProductAction(id: string) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const product = await prisma.product.findFirst({
where: { id, user_id: session.userId },
select: { id: true },
})
if (!product) return { error: 'Product niet gevonden' }
await prisma.$transaction([
prisma.user.updateMany({
where: { active_product_id: id },
data: { active_product_id: null },
}),
prisma.product.delete({ where: { id } }),
])
revalidatePath('/dashboard')
return { success: true }
}
export async function addProductMemberAction(_prevState: unknown, formData: FormData) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }

View file

@ -0,0 +1,78 @@
'use client'
// AlertDialog-confirm voor product-delete vanuit de Dashboard-tabel.
// Controlled component — open/onOpenChange via parent zodat we 'm vanuit
// een DropdownMenuItem-klik kunnen openen. Patroon gespiegeld op
// components/product-docs/delete-product-doc-button.tsx (PBI-96).
import { useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { deleteProductAction } from '@/actions/products'
interface Props {
productId: string
productName: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function DeleteProductConfirm({
productId,
productName,
open,
onOpenChange,
}: Props) {
const router = useRouter()
const [submitting, startSubmit] = useTransition()
function handleConfirm() {
startSubmit(async () => {
const r = await deleteProductAction(productId)
if (r && 'error' in r && r.error) {
toast.error(r.error)
return
}
toast.success('Product verwijderd')
onOpenChange(false)
router.refresh()
})
}
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Product verwijderen?</AlertDialogTitle>
<AlertDialogDescription>
&ldquo;{productName}&rdquo; wordt permanent verwijderd, inclusief
alle PBI&apos;s, stories, taken, jobs en docs. Niet ongedaan te
maken.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={submitting}>Annuleer</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={submitting}
variant="destructive"
data-debug-id="delete-product-confirm__action"
>
Ja, verwijder
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View file

@ -8,7 +8,7 @@
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useTransition } from 'react'
import { useState, useTransition } from 'react'
import { ArrowRight, BookOpen, MoreHorizontal } from 'lucide-react'
import { toast } from 'sonner'
@ -18,6 +18,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { ActivateProductButton } from '@/components/shared/activate-product-button'
@ -26,10 +27,12 @@ import {
archiveProductAction,
restoreProductAction,
} from '@/actions/products'
import { DeleteProductConfirm } from '@/components/dashboard/delete-product-confirm'
import { debugProps } from '@/lib/debug'
interface Props {
productId: string
productName: string
isActive: boolean
isArchived: boolean
isDemo: boolean
@ -37,12 +40,14 @@ interface Props {
export function ProductRowActions({
productId,
productName,
isActive,
isArchived,
isDemo,
}: Props) {
const router = useRouter()
const [submitting, startSubmit] = useTransition()
const [deleteOpen, setDeleteOpen] = useState(false)
function handleArchiveToggle() {
startSubmit(async () => {
@ -131,9 +136,23 @@ export function ProductRowActions({
>
{isArchived ? 'Herstel' : 'Archiveer'}
</DropdownMenuItem>
{/* TODO T-1089 (C2): Verwijderen-item met DeleteProductConfirm */}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setDeleteOpen(true)}
data-debug-id="product-row-actions__delete"
className="text-destructive focus:text-destructive"
>
Verwijderen
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DeleteProductConfirm
productId={productId}
productName={productName}
open={deleteOpen}
onOpenChange={setDeleteOpen}
/>
</div>
)
}

View file

@ -243,6 +243,7 @@ export function ProductsTable({
>
<ProductRowActions
productId={product.id}
productName={product.name}
isActive={product.id === activeProductId}
isArchived={product.archived}
isDemo={isDemo}