feat: Add action and dialog to copy/transfer modules between products
All checks were successful
Staging Build / build (push) Successful in 2m39s

This commit is contained in:
DanielS
2026-06-25 14:03:26 +02:00
parent 9246fb7d77
commit 5a292a8ddc
2 changed files with 244 additions and 74 deletions

View File

@@ -11,18 +11,57 @@ import {
} from '@/components/ui/table'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Edit2, Trash2, Layers, ArrowUpDown } from 'lucide-react'
import { Edit2, Trash2, Layers, ArrowUpDown, Copy } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { deleteProduct } from '@/lib/actions/products'
import { deleteProduct, transferModules } from '@/lib/actions/products'
import { useState, useEffect, useMemo, Fragment } from 'react'
import { CreateProductDialog } from './create-product-dialog'
import { useRouter } from 'next/navigation'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
export function ProductList({ initialProducts, categories }: { initialProducts: Product[], categories: Category[] }) {
const [products, setProducts] = useState(initialProducts)
const [isGroupedByCategory, setIsGroupedByCategory] = useState(false)
const [showKauf, setShowKauf] = useState(true)
const [showAbo, setShowAbo] = useState(true)
const [copySourceProduct, setCopySourceProduct] = useState<Product | null>(null)
const [copyTargetProductId, setCopyTargetProductId] = useState<string>('')
const [copyMode, setCopyMode] = useState<'overwrite' | 'append'>('overwrite')
const [isCopying, setIsCopying] = useState(false)
const router = useRouter()
const handleCopyModules = async () => {
if (!copySourceProduct || !copyTargetProductId) return
setIsCopying(true)
try {
await transferModules(copySourceProduct.id, copyTargetProductId, copyMode)
setCopySourceProduct(null)
setCopyTargetProductId('')
router.refresh()
} catch (err) {
console.error('Error copying modules:', err)
alert('Fehler beim Kopieren der Module.')
} finally {
setIsCopying(false)
}
}
useEffect(() => {
setProducts(initialProducts)
@@ -103,6 +142,18 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
onClick={() => {
setCopySourceProduct(product)
setCopyTargetProductId('')
}}
title="Module kopieren"
>
<Copy className="h-4 w-4" />
</Button>
<CreateProductDialog categories={categories} product={product}>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
<Edit2 className="h-4 w-4" />
@@ -122,78 +173,139 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
)
return (
<Card className="bg-white dark:bg-slate-900/50 border border-slate-200 dark:border-white/10 shadow-sm overflow-hidden text-slate-900 dark:text-white">
<CardHeader className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div>
<CardTitle>Produktübersicht</CardTitle>
<CardDescription className="text-slate-500 dark:text-slate-400">
Alle konfigurierten Software-Lösungen und Erweiterungen.
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-6">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300 cursor-pointer select-none">
<Checkbox
checked={showKauf}
onCheckedChange={(checked) => setShowKauf(!!checked)}
/>
In Kaufauswahl
</label>
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300 cursor-pointer select-none">
<Checkbox
checked={showAbo}
onCheckedChange={(checked) => setShowAbo(!!checked)}
/>
In Aboauswahl
</label>
<Button
variant="outline"
size="sm"
onClick={toggleGroupByCategory}
className={`border-slate-200 dark:border-white/10 hover:bg-slate-100 dark:hover:bg-white/10 text-slate-700 dark:text-white ${
isGroupedByCategory ? 'bg-primary/10 border-primary text-primary hover:bg-primary/20 dark:bg-primary/20 dark:border-primary dark:text-primary-foreground' : ''
}`}
>
<ArrowUpDown className="w-4 h-4 mr-2" />
{isGroupedByCategory ? 'Kategoriengruppierung aktiv' : 'Nach Kategorien gruppieren'}
</Button>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow className="border-slate-200 dark:border-white/10 hover:bg-slate-50 dark:hover:bg-white/5">
<TableHead className="text-slate-700 dark:text-white font-bold">Name</TableHead>
<TableHead className="text-slate-700 dark:text-white font-bold">Basispreis</TableHead>
<TableHead className="text-slate-700 dark:text-white font-bold">Module</TableHead>
<TableHead className="text-slate-700 dark:text-white font-bold">Status</TableHead>
<TableHead className="text-right text-slate-700 dark:text-white font-bold">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isGroupedByCategory ? (
groupedProducts.map((group) => (
<Fragment key={group.category?.id || 'no-category'}>
<TableRow className="bg-slate-100 dark:bg-slate-800/40 hover:bg-slate-100 dark:hover:bg-slate-800/40 border-b border-slate-200 dark:border-white/10">
<TableCell colSpan={5} className="text-primary font-bold py-2.5 text-xs uppercase tracking-wider">
{group.category ? group.category.name : 'Keine Kategorie'}
</TableCell>
</TableRow>
{group.products.map((product) => renderProductRow(product))}
</Fragment>
))
) : (
filteredProducts.map((product) => renderProductRow(product))
)}
{filteredProducts.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-10 text-slate-500 dark:text-slate-400">
Keine Produkte gefunden. Passen Sie Ihre Filter an.
</TableCell>
<>
<Card className="bg-white dark:bg-slate-900/50 border border-slate-200 dark:border-white/10 shadow-sm overflow-hidden text-slate-900 dark:text-white">
<CardHeader className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div>
<CardTitle>Produktübersicht</CardTitle>
<CardDescription className="text-slate-500 dark:text-slate-400">
Alle konfigurierten Software-Lösungen und Erweiterungen.
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-6">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300 cursor-pointer select-none">
<Checkbox
checked={showKauf}
onCheckedChange={(checked) => setShowKauf(!!checked)}
/>
In Kaufauswahl
</label>
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300 cursor-pointer select-none">
<Checkbox
checked={showAbo}
onCheckedChange={(checked) => setShowAbo(!!checked)}
/>
In Aboauswahl
</label>
<Button
variant="outline"
size="sm"
onClick={toggleGroupByCategory}
className={`border-slate-200 dark:border-white/10 hover:bg-slate-100 dark:hover:bg-white/10 text-slate-700 dark:text-white ${
isGroupedByCategory ? 'bg-primary/10 border-primary text-primary hover:bg-primary/20 dark:bg-primary/20 dark:border-primary dark:text-primary-foreground' : ''
}`}
>
<ArrowUpDown className="w-4 h-4 mr-2" />
{isGroupedByCategory ? 'Kategoriengruppierung aktiv' : 'Nach Kategorien gruppieren'}
</Button>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow className="border-slate-200 dark:border-white/10 hover:bg-slate-50 dark:hover:bg-white/5">
<TableHead className="text-slate-700 dark:text-white font-bold">Name</TableHead>
<TableHead className="text-slate-700 dark:text-white font-bold">Basispreis</TableHead>
<TableHead className="text-slate-700 dark:text-white font-bold">Module</TableHead>
<TableHead className="text-slate-700 dark:text-white font-bold">Status</TableHead>
<TableHead className="text-right text-slate-700 dark:text-white font-bold">Aktionen</TableHead>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TableHeader>
<TableBody>
{isGroupedByCategory ? (
groupedProducts.map((group) => (
<Fragment key={group.category?.id || 'no-category'}>
<TableRow className="bg-slate-100 dark:bg-slate-800/40 hover:bg-slate-100 dark:hover:bg-slate-800/40 border-b border-slate-200 dark:border-white/10">
<TableCell colSpan={5} className="text-primary font-bold py-2.5 text-xs uppercase tracking-wider">
{group.category ? group.category.name : 'Keine Kategorie'}
</TableCell>
</TableRow>
{group.products.map((product) => renderProductRow(product))}
</Fragment>
))
) : (
filteredProducts.map((product) => renderProductRow(product))
)}
{filteredProducts.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-10 text-slate-500 dark:text-slate-400">
Keine Produkte gefunden. Passen Sie Ihre Filter an.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={!!copySourceProduct} onOpenChange={(open) => { if (!open) setCopySourceProduct(null) }}>
<DialogContent className="sm:max-w-[425px] bg-white dark:bg-slate-950 text-slate-900 dark:text-white border-slate-200 dark:border-white/10 shadow-2xl">
<DialogHeader>
<DialogTitle>Module übertragen</DialogTitle>
<DialogDescription className="text-slate-500 dark:text-slate-400">
Kopieren Sie alle Module von <strong>{copySourceProduct?.name}</strong> auf ein anderes Produkt.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="flex flex-col gap-2">
<Label htmlFor="target-product">Ziel-Produkt</Label>
<Select value={copyTargetProductId} onValueChange={setCopyTargetProductId}>
<SelectTrigger id="target-product" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
<SelectValue placeholder="Ziel-Produkt auswählen" />
</SelectTrigger>
<SelectContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
{products
.filter(p => p.id !== copySourceProduct?.id)
.map(p => (
<SelectItem key={p.id} value={p.id} className="focus:bg-slate-100 dark:focus:bg-white/5">
{p.name}
</SelectItem>
))
}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Übertragungsmodus</Label>
<RadioGroup value={copyMode} onValueChange={(val: any) => setCopyMode(val)} className="flex flex-col gap-2">
<div className="flex items-center space-x-2">
<RadioGroupItem value="overwrite" id="mode-overwrite" />
<Label htmlFor="mode-overwrite" className="font-normal cursor-pointer text-slate-700 dark:text-slate-300">
Bestehende Module überschreiben
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="append" id="mode-append" />
<Label htmlFor="mode-append" className="font-normal cursor-pointer text-slate-700 dark:text-slate-300">
Module anfügen (bestehende behalten)
</Label>
</div>
</RadioGroup>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCopySourceProduct(null)} disabled={isCopying} className="border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
Abbrechen
</Button>
<Button onClick={handleCopyModules} disabled={!copyTargetProductId || isCopying} className="bg-primary text-white hover:bg-primary/90">
{isCopying ? 'Wird kopiert...' : 'Module übertragen'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -148,3 +148,61 @@ export async function deleteProduct(id: string) {
revalidatePath('/admin/products')
revalidatePath('/order')
}
export async function transferModules(
sourceProductId: string,
targetProductId: string,
mode: 'overwrite' | 'append'
) {
const supabase = await createClient()
// 1. Fetch modules of source product
const { data: sourceModules, error: fetchError } = await supabase
.from('product_modules')
.select('*')
.eq('product_id', sourceProductId)
if (fetchError) throw fetchError
if (!sourceModules || sourceModules.length === 0) return
// 2. If overwrite, delete existing modules of target product
if (mode === 'overwrite') {
const { error: deleteError } = await supabase
.from('product_modules')
.delete()
.eq('product_id', targetProductId)
if (deleteError) throw deleteError
}
// 3. Map IDs to new UUIDs and fix requirements/exclusions
const idMap: Record<string, string> = {}
sourceModules.forEach(m => {
idMap[m.id] = randomUUID()
})
const newModules = sourceModules.map(m => {
const newId = idMap[m.id]
const requirements = (m.requirements || []).map((id: string) => idMap[id] || id)
const exclusions = (m.exclusions || []).map((id: string) => idMap[id] || id)
return {
id: newId,
product_id: targetProductId,
name: m.name,
description: m.description,
price: m.price,
requirements,
exclusions,
has_quantity: m.has_quantity ?? false,
}
})
// 4. Insert into DB
const { error: insertError } = await supabase
.from('product_modules')
.insert(newModules)
if (insertError) throw insertError
revalidatePath('/admin/products')
revalidatePath('/order')
}