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:
parent
972e415fc9
commit
98526f9f20
4 changed files with 127 additions and 2 deletions
|
|
@ -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' }
|
||||
|
|
|
|||
78
components/dashboard/delete-product-confirm.tsx
Normal file
78
components/dashboard/delete-product-confirm.tsx
Normal 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>
|
||||
“{productName}” wordt permanent verwijderd, inclusief
|
||||
alle PBI'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -243,6 +243,7 @@ export function ProductsTable({
|
|||
>
|
||||
<ProductRowActions
|
||||
productId={product.id}
|
||||
productName={product.name}
|
||||
isActive={product.id === activeProductId}
|
||||
isArchived={product.archived}
|
||||
isDemo={isDemo}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue