feat: Add action and dialog to copy/transfer modules between products
All checks were successful
Staging Build / build (push) Successful in 2m39s
All checks were successful
Staging Build / build (push) Successful in 2m39s
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user