451 lines
22 KiB
TypeScript
451 lines
22 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useTransition } from 'react'
|
||
import { Category, Product, ProductModule } from '@/lib/types'
|
||
import { updateProduct, createProduct, updateCategory, createCategory, deleteProduct, deleteCategory, addProductDependency, removeProductDependency } from '@/lib/actions/products'
|
||
import { InlineInput } from './components/inline-input'
|
||
import { ModulePopover } from './components/module-popover'
|
||
import { DependencyPopover } from './components/dependency-popover'
|
||
import { Trash2 } from 'lucide-react'
|
||
|
||
export function WysiwygAdminClient({
|
||
initialCategories,
|
||
initialProducts,
|
||
allModules
|
||
}: {
|
||
initialCategories: Category[]
|
||
initialProducts: Product[]
|
||
allModules: ProductModule[]
|
||
}) {
|
||
const [mode, setMode] = useState<'purchase' | 'subscription'>('purchase')
|
||
const [categories, setCategories] = useState(initialCategories)
|
||
const [products, setProducts] = useState(initialProducts)
|
||
const [isPending, startTransition] = useTransition()
|
||
|
||
const handleDeleteCategory = async (catId: string) => {
|
||
// Cascade-Check: check if there are products belonging to this category
|
||
const categoryProducts = products.filter(p => p.category_id === catId)
|
||
if (categoryProducts.length > 0) {
|
||
alert(`Diese Kategorie enthält noch ${categoryProducts.length} Produkte. Bitte lösche oder verschiebe diese zuerst!`)
|
||
return
|
||
}
|
||
|
||
if (!window.confirm('Möchten Sie diese Kategorie wirklich löschen?')) {
|
||
return
|
||
}
|
||
|
||
startTransition(async () => {
|
||
try {
|
||
await deleteCategory(catId)
|
||
setCategories(categories.filter(c => c.id !== catId))
|
||
} catch (e) {
|
||
console.error(e)
|
||
alert('Fehler beim Löschen der Kategorie.')
|
||
}
|
||
})
|
||
}
|
||
|
||
const handleDeleteProduct = async (prodId: string) => {
|
||
if (!window.confirm('Möchten Sie dieses Produkt wirklich löschen?')) {
|
||
return
|
||
}
|
||
|
||
startTransition(async () => {
|
||
try {
|
||
await deleteProduct(prodId)
|
||
setProducts(products.filter(p => p.id !== prodId))
|
||
} catch (e) {
|
||
console.error(e)
|
||
alert('Fehler beim Löschen des Produkts.')
|
||
}
|
||
})
|
||
}
|
||
|
||
const getDependencyName = (depId: string) => {
|
||
const matchedProduct = products.find(p => p.id === depId)
|
||
if (matchedProduct) return matchedProduct.name
|
||
const matchedModule = allModules.find(m => m.id === depId)
|
||
if (matchedModule) return `${matchedModule.name} (Modul)`
|
||
return depId
|
||
}
|
||
|
||
// Filterung der Kategorien nach aktivem Modus
|
||
const filteredCategories = categories.filter(cat =>
|
||
mode === 'purchase' ? cat.show_in_kauf : cat.show_in_abo
|
||
)
|
||
|
||
// Filterung der Produkte nach aktivem Modus
|
||
const filteredProducts = products.filter(p =>
|
||
mode === 'purchase' ? p.billing_interval === 'one_time' : p.billing_interval === 'monthly'
|
||
)
|
||
|
||
const handleAddCategory = async () => {
|
||
startTransition(async () => {
|
||
try {
|
||
const newCat = await createCategory({
|
||
name: 'Neue Kategorie',
|
||
description: 'Beschreibung hier...',
|
||
sort_order: categories.length + 1,
|
||
is_required: false,
|
||
allow_multiselect: false,
|
||
free_items_limit: 0,
|
||
preselect: false,
|
||
show_in_kauf: mode === 'purchase',
|
||
show_in_abo: mode === 'subscription',
|
||
resets_others: false
|
||
})
|
||
setCategories([...categories, newCat])
|
||
} catch (e) {
|
||
console.error(e)
|
||
}
|
||
})
|
||
}
|
||
|
||
const handleAddProduct = async (categoryId: string) => {
|
||
startTransition(async () => {
|
||
try {
|
||
const newProd = await createProduct({
|
||
name: 'Neues Produkt',
|
||
description: 'Produktbeschreibung...',
|
||
base_price: 19.99,
|
||
tax_rate: 19,
|
||
billing_interval: mode === 'purchase' ? 'one_time' : 'monthly',
|
||
category_id: categoryId,
|
||
show_in_kauf: mode === 'purchase',
|
||
show_in_abo: mode === 'subscription'
|
||
}, [])
|
||
setProducts([...products, newProd])
|
||
} catch (e) {
|
||
console.error(e)
|
||
}
|
||
})
|
||
}
|
||
|
||
const handleCategoryToggle = async (catId: string, fields: Partial<Category>) => {
|
||
startTransition(async () => {
|
||
try {
|
||
setCategories(categories.map(c => c.id === catId ? { ...c, ...fields } : c))
|
||
await updateCategory(catId, fields)
|
||
} catch (e) {
|
||
console.error(e)
|
||
}
|
||
})
|
||
}
|
||
|
||
return (
|
||
<div className={`p-6 max-w-7xl mx-auto space-y-6 ${isPending ? 'opacity-70 pointer-events-none' : ''}`}>
|
||
<div className="flex justify-between items-center border-b border-white/10 pb-4">
|
||
<div className="flex space-x-4">
|
||
<button
|
||
onClick={() => setMode('purchase')}
|
||
className={`px-4 py-2 rounded-lg font-medium transition ${
|
||
mode === 'purchase' ? 'bg-primary text-black' : 'bg-white/5 text-white hover:bg-white/10'
|
||
}`}
|
||
>
|
||
Kauf-Modus (Einmalig)
|
||
</button>
|
||
<button
|
||
onClick={() => setMode('subscription')}
|
||
className={`px-4 py-2 rounded-lg font-medium transition ${
|
||
mode === 'subscription' ? 'bg-primary text-black' : 'bg-white/5 text-white hover:bg-white/10'
|
||
}`}
|
||
>
|
||
Abo-Modus (Monatlich)
|
||
</button>
|
||
</div>
|
||
{isPending && <span className="text-xs text-primary animate-pulse font-medium">Speichere Änderungen...</span>}
|
||
</div>
|
||
|
||
<div className="space-y-8">
|
||
{filteredCategories
|
||
.sort((a, b) => a.sort_order - b.sort_order)
|
||
.map(cat => {
|
||
const catProducts = filteredProducts.filter(p => p.category_id === cat.id)
|
||
return (
|
||
<div key={cat.id} className="p-6 bg-white/5 border border-white/10 rounded-2xl space-y-4 shadow-xl">
|
||
<div className="flex justify-between items-start border-b border-white/5 pb-4">
|
||
<div className="w-full">
|
||
<InlineInput
|
||
value={cat.name}
|
||
onSave={async (newName) => {
|
||
await updateCategory(cat.id, { name: newName })
|
||
setCategories(categories.map(c => c.id === cat.id ? { ...c, name: newName } : c))
|
||
}}
|
||
className="text-2xl font-bold text-white block w-full"
|
||
/>
|
||
<InlineInput
|
||
value={cat.description || ''}
|
||
onSave={async (newDesc) => {
|
||
await updateCategory(cat.id, { description: newDesc })
|
||
setCategories(categories.map(c => c.id === cat.id ? { ...c, description: newDesc } : c))
|
||
}}
|
||
className="text-slate-400 text-sm mt-1 block w-full"
|
||
/>
|
||
|
||
{/* Kategorie Attribute (Toggles) */}
|
||
<div className="flex flex-wrap items-center gap-6 mt-3 text-xs text-slate-400">
|
||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||
<input
|
||
type="checkbox"
|
||
checked={cat.is_required}
|
||
onChange={(e) => handleCategoryToggle(cat.id, { is_required: e.target.checked })}
|
||
className="rounded border-white/10 bg-white/5 text-primary focus:ring-0 focus:ring-offset-0"
|
||
/>
|
||
<span>Pflichtauswahl</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||
<input
|
||
type="checkbox"
|
||
checked={cat.allow_multiselect}
|
||
onChange={(e) => {
|
||
const checked = e.target.checked
|
||
handleCategoryToggle(cat.id, {
|
||
allow_multiselect: checked,
|
||
...(checked ? { preselect: false } : {})
|
||
})
|
||
}}
|
||
className="rounded border-white/10 bg-white/5 text-primary focus:ring-0 focus:ring-offset-0"
|
||
/>
|
||
<span>Mehrfachauswahl</span>
|
||
</label>
|
||
{!cat.allow_multiselect && (
|
||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||
<input
|
||
type="checkbox"
|
||
checked={cat.preselect}
|
||
onChange={(e) => handleCategoryToggle(cat.id, { preselect: e.target.checked })}
|
||
className="rounded border-white/10 bg-white/5 text-primary focus:ring-0 focus:ring-offset-0"
|
||
/>
|
||
<span>Vorauswahl</span>
|
||
</label>
|
||
)}
|
||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||
<input
|
||
type="checkbox"
|
||
checked={cat.resets_others}
|
||
onChange={(e) => handleCategoryToggle(cat.id, { resets_others: e.target.checked })}
|
||
className="rounded border-white/10 bg-white/5 text-primary focus:ring-0 focus:ring-offset-0"
|
||
/>
|
||
<span>Rest zurücksetzen</span>
|
||
</label>
|
||
{cat.allow_multiselect && (
|
||
<div className="flex items-center gap-2">
|
||
<span>Freie Artikel Limit:</span>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
value={cat.free_items_limit}
|
||
onChange={(e) => handleCategoryToggle(cat.id, { free_items_limit: parseInt(e.target.value) || 0 })}
|
||
className="w-12 rounded border-white/10 bg-white/5 text-white focus:ring-0 focus:ring-offset-0 px-1 py-0.5 text-center"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => handleDeleteCategory(cat.id)}
|
||
className="p-2 text-slate-500 hover:text-red-500 hover:bg-white/5 rounded-lg transition-all ml-4 flex-shrink-0"
|
||
title="Kategorie löschen"
|
||
>
|
||
<Trash2 className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
||
{catProducts.map(prod => (
|
||
<div key={prod.id} className="p-4 bg-white/5 border border-white/10 rounded-xl space-y-3 relative group flex flex-col justify-between">
|
||
<div className="space-y-2">
|
||
<div className="flex justify-between items-start gap-2">
|
||
<div className="flex-1">
|
||
<InlineInput
|
||
value={prod.name}
|
||
onSave={async (newName) => {
|
||
await updateProduct(prod.id, { name: newName }, prod.modules || [])
|
||
setProducts(products.map(p => p.id === prod.id ? { ...p, name: newName } : p))
|
||
}}
|
||
className="font-semibold text-white block"
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={() => handleDeleteProduct(prod.id)}
|
||
className="p-1 text-slate-500 hover:text-red-500 hover:bg-white/5 rounded transition-all opacity-0 group-hover:opacity-100 flex-shrink-0"
|
||
title="Produkt löschen"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
<InlineInput
|
||
value={prod.description || ''}
|
||
onSave={async (newDesc) => {
|
||
await updateProduct(prod.id, { description: newDesc }, prod.modules || [])
|
||
setProducts(products.map(p => p.id === prod.id ? { ...p, description: newDesc } : p))
|
||
}}
|
||
className="text-xs text-slate-400 block"
|
||
/>
|
||
<div className="text-primary font-mono text-sm flex items-center space-x-1">
|
||
<InlineInput
|
||
value={prod.base_price.toString()}
|
||
type="number"
|
||
onSave={async (newPrice) => {
|
||
const priceNum = parseFloat(newPrice) || 0
|
||
await updateProduct(prod.id, { base_price: priceNum }, prod.modules || [])
|
||
setProducts(products.map(p => p.id === prod.id ? { ...p, base_price: priceNum } : p))
|
||
}}
|
||
className="inline-block"
|
||
/>
|
||
<span>€</span>
|
||
</div>
|
||
|
||
{/* Modul-Badges */}
|
||
<div className="space-y-1">
|
||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold block">Module:</span>
|
||
<div className="flex flex-wrap gap-1">
|
||
{(prod.modules || []).length > 0 ? (
|
||
(prod.modules || []).map((m) => (
|
||
<span key={m.id} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-white/10 text-slate-300 font-medium">
|
||
{m.name}
|
||
<button
|
||
onClick={async () => {
|
||
const updatedModules = (prod.modules || []).filter(mod => mod.id !== m.id)
|
||
setProducts(products.map(p => p.id === prod.id ? { ...p, modules: updatedModules } : p))
|
||
await updateProduct(prod.id, {}, updatedModules)
|
||
}}
|
||
className="text-slate-500 hover:text-white font-bold transition-colors ml-0.5"
|
||
title="Modul entfernen"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))
|
||
) : (
|
||
<span className="text-[10px] text-slate-500 italic">Keine Module</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Requirements */}
|
||
<div className="space-y-1">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold block">Benötigt:</span>
|
||
<DependencyPopover
|
||
currentProductId={prod.id}
|
||
assignedIds={prod.requirements || []}
|
||
allProducts={products}
|
||
allModules={allModules}
|
||
onAddDependency={async (depId) => {
|
||
const newReqs = [...(prod.requirements || []), depId]
|
||
setProducts(products.map(p => p.id === prod.id ? { ...p, requirements: newReqs } : p))
|
||
await addProductDependency(prod.id, 'requirements', depId)
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
{(prod.requirements || []).length > 0 ? (
|
||
(prod.requirements || []).map((reqId) => (
|
||
<span key={reqId} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-green-500/10 text-green-400 border border-green-500/20 font-medium animate-fade-in">
|
||
{getDependencyName(reqId)}
|
||
<button
|
||
onClick={async () => {
|
||
const newReqs = (prod.requirements || []).filter(id => id !== reqId)
|
||
setProducts(products.map(p => p.id === prod.id ? { ...p, requirements: newReqs } : p))
|
||
await removeProductDependency(prod.id, 'requirements', reqId)
|
||
}}
|
||
className="text-green-600 hover:text-green-400 font-bold transition-colors ml-0.5"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))
|
||
) : (
|
||
<span className="text-[10px] text-slate-500 italic">Keine Anforderungen</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Exclusions */}
|
||
<div className="space-y-1">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold block">Schließt aus:</span>
|
||
<DependencyPopover
|
||
currentProductId={prod.id}
|
||
assignedIds={prod.exclusions || []}
|
||
allProducts={products}
|
||
allModules={allModules}
|
||
onAddDependency={async (depId) => {
|
||
const newExcs = [...(prod.exclusions || []), depId]
|
||
setProducts(products.map(p => p.id === prod.id ? { ...p, exclusions: newExcs } : p))
|
||
await addProductDependency(prod.id, 'exclusions', depId)
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
{(prod.exclusions || []).length > 0 ? (
|
||
(prod.exclusions || []).map((exId) => (
|
||
<span key={exId} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-red-500/10 text-red-400 border border-red-500/20 font-medium animate-fade-in">
|
||
{getDependencyName(exId)}
|
||
<button
|
||
onClick={async () => {
|
||
const newExcs = (prod.exclusions || []).filter(id => id !== exId)
|
||
setProducts(products.map(p => p.id === prod.id ? { ...p, exclusions: newExcs } : p))
|
||
await removeProductDependency(prod.id, 'exclusions', exId)
|
||
}}
|
||
className="text-red-600 hover:text-red-400 font-bold transition-colors ml-0.5"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))
|
||
) : (
|
||
<span className="text-[10px] text-slate-500 italic">Keine Ausschlüsse</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Modul hinzufügen Popover */}
|
||
<div className="mt-3 pt-2 border-t border-white/5">
|
||
<ModulePopover
|
||
assignedModules={prod.modules || []}
|
||
allModules={allModules}
|
||
onAddModule={async (mod) => {
|
||
const newMod = {
|
||
name: mod.name,
|
||
description: mod.description,
|
||
price: mod.price,
|
||
requirements: mod.requirements || [],
|
||
exclusions: mod.exclusions || [],
|
||
has_quantity: mod.has_quantity || false
|
||
}
|
||
const updatedModules = [...(prod.modules || []), newMod]
|
||
setProducts(products.map(p => p.id === prod.id ? { ...p, modules: updatedModules as any } : p))
|
||
await updateProduct(prod.id, {}, updatedModules)
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
<button
|
||
onClick={() => handleAddProduct(cat.id)}
|
||
className="flex flex-col items-center justify-center p-4 border border-dashed border-white/20 hover:border-primary/50 rounded-xl text-slate-400 hover:text-primary transition h-full min-h-[140px]"
|
||
>
|
||
<span className="text-2xl mb-1">+</span>
|
||
<span className="text-xs font-medium">Produkt hinzufügen</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
|
||
<button
|
||
onClick={handleAddCategory}
|
||
className="w-full py-4 border border-dashed border-white/20 hover:border-primary/50 rounded-2xl flex items-center justify-center text-slate-400 hover:text-primary transition"
|
||
>
|
||
<span className="text-xl mr-2">+</span>
|
||
<span className="font-medium">Neue Kategorie hinzufügen</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|