feat(admin): add product dependencies and clean up labels
All checks were successful
Staging Build / build (push) Successful in 2m43s
All checks were successful
Staging Build / build (push) Successful in 2m43s
- Add addProductDependency and removeProductDependency actions - Create DependencyPopover and integrate in live editor UI - Render green requirements and red exclusions badges - Remove technical parentheses from category checkbox labels
This commit is contained in:
@@ -40,7 +40,7 @@ export default async function AdminLayout({
|
|||||||
<span className="font-bold text-xl tracking-tighter">Admin Panel</span>
|
<span className="font-bold text-xl tracking-tighter">Admin Panel</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 px-4 space-y-2 py-4">
|
<nav className="flex-1 px-4 space-y-2 py-4">
|
||||||
<AdminNavLink href="/admin">
|
<AdminNavLink href="/admin">
|
||||||
<LayoutDashboard className="w-5 h-5" />
|
<LayoutDashboard className="w-5 h-5" />
|
||||||
@@ -56,7 +56,7 @@ export default async function AdminLayout({
|
|||||||
</AdminNavLink>
|
</AdminNavLink>
|
||||||
<AdminNavLink href="/admin/wysiwyg">
|
<AdminNavLink href="/admin/wysiwyg">
|
||||||
<Sparkles className="w-5 h-5" />
|
<Sparkles className="w-5 h-5" />
|
||||||
Order-Wizard (Live)
|
Order-Wizard
|
||||||
</AdminNavLink>
|
</AdminNavLink>
|
||||||
<AdminNavLink href="/admin/orders">
|
<AdminNavLink href="/admin/orders">
|
||||||
<ShoppingCart className="w-5 h-5" />
|
<ShoppingCart className="w-5 h-5" />
|
||||||
|
|||||||
130
shop/app/admin/wysiwyg/components/dependency-popover.tsx
Normal file
130
shop/app/admin/wysiwyg/components/dependency-popover.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { Product, ProductModule } from '@/lib/types'
|
||||||
|
import { Plus, Search, Package, Layers } from 'lucide-react'
|
||||||
|
|
||||||
|
interface DependencyPopoverProps {
|
||||||
|
currentProductId: string
|
||||||
|
assignedIds: string[]
|
||||||
|
allProducts: Product[]
|
||||||
|
allModules: ProductModule[]
|
||||||
|
onAddDependency: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DependencyPopover({
|
||||||
|
currentProductId,
|
||||||
|
assignedIds,
|
||||||
|
allProducts,
|
||||||
|
allModules,
|
||||||
|
onAddDependency
|
||||||
|
}: DependencyPopoverProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Filter out the current product, and items already assigned
|
||||||
|
const otherProducts = allProducts.filter(p => p.id !== currentProductId && !assignedIds.includes(p.id))
|
||||||
|
const otherModules = allModules.filter(m => m.product_id !== currentProductId && !assignedIds.includes(m.id))
|
||||||
|
|
||||||
|
// Apply search
|
||||||
|
const filteredProducts = otherProducts.filter(p => p.name.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
const filteredModules = otherModules.filter(m => m.name.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && inputRef.current) {
|
||||||
|
inputRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-block" ref={popoverRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="p-1 hover:bg-white/10 rounded transition-all text-slate-400 hover:text-white"
|
||||||
|
title="Abhängigkeit hinzufügen"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute left-0 mt-2 z-50 w-72 bg-slate-900 border border-white/10 rounded-xl shadow-2xl p-3 space-y-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Suchen nach Produkt/Modul..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg pl-9 pr-3 py-1.5 text-xs text-white placeholder-slate-400 outline-none focus:border-primary/50 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-60 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
|
||||||
|
{filteredProducts.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider px-1">Produkte</span>
|
||||||
|
{filteredProducts.map(p => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => {
|
||||||
|
onAddDependency(p.id)
|
||||||
|
setIsOpen(false)
|
||||||
|
setSearch('')
|
||||||
|
}}
|
||||||
|
className="w-full text-left p-1.5 hover:bg-white/5 rounded-lg transition-colors flex items-center gap-2 group"
|
||||||
|
>
|
||||||
|
<Package className="w-3 h-3 text-slate-500 group-hover:text-primary transition-colors" />
|
||||||
|
<span className="text-xs text-white group-hover:text-primary transition-colors">{p.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredModules.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider px-1">Module</span>
|
||||||
|
{filteredModules.map(m => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => {
|
||||||
|
onAddDependency(m.id)
|
||||||
|
setIsOpen(false)
|
||||||
|
setSearch('')
|
||||||
|
}}
|
||||||
|
className="w-full text-left p-1.5 hover:bg-white/5 rounded-lg transition-colors flex items-center gap-2 group"
|
||||||
|
>
|
||||||
|
<Layers className="w-3 h-3 text-slate-500 group-hover:text-primary transition-colors" />
|
||||||
|
<span className="text-xs text-white group-hover:text-primary transition-colors">{m.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredProducts.length === 0 && filteredModules.length === 0 && (
|
||||||
|
<div className="text-[11px] text-slate-400 text-center py-4">
|
||||||
|
Keine Ergebnisse gefunden.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { useState, useTransition } from 'react'
|
import { useState, useTransition } from 'react'
|
||||||
import { Category, Product, ProductModule } from '@/lib/types'
|
import { Category, Product, ProductModule } from '@/lib/types'
|
||||||
import { updateProduct, createProduct, updateCategory, createCategory, deleteProduct, deleteCategory } from '@/lib/actions/products'
|
import { updateProduct, createProduct, updateCategory, createCategory, deleteProduct, deleteCategory, addProductDependency, removeProductDependency } from '@/lib/actions/products'
|
||||||
import { InlineInput } from './components/inline-input'
|
import { InlineInput } from './components/inline-input'
|
||||||
import { ModulePopover } from './components/module-popover'
|
import { ModulePopover } from './components/module-popover'
|
||||||
|
import { DependencyPopover } from './components/dependency-popover'
|
||||||
import { Trash2 } from 'lucide-react'
|
import { Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
export function WysiwygAdminClient({
|
export function WysiwygAdminClient({
|
||||||
@@ -60,6 +61,14 @@ export function WysiwygAdminClient({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Filterung der Kategorien nach aktivem Modus
|
||||||
const filteredCategories = categories.filter(cat =>
|
const filteredCategories = categories.filter(cat =>
|
||||||
mode === 'purchase' ? cat.show_in_kauf : cat.show_in_abo
|
mode === 'purchase' ? cat.show_in_kauf : cat.show_in_abo
|
||||||
@@ -181,7 +190,7 @@ export function WysiwygAdminClient({
|
|||||||
onChange={(e) => handleCategoryToggle(cat.id, { is_required: e.target.checked })}
|
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"
|
className="rounded border-white/10 bg-white/5 text-primary focus:ring-0 focus:ring-offset-0"
|
||||||
/>
|
/>
|
||||||
<span>Pflichtauswahl (is_required)</span>
|
<span>Pflichtauswahl</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||||
<input
|
<input
|
||||||
@@ -190,7 +199,7 @@ export function WysiwygAdminClient({
|
|||||||
onChange={(e) => handleCategoryToggle(cat.id, { allow_multiselect: e.target.checked })}
|
onChange={(e) => handleCategoryToggle(cat.id, { allow_multiselect: e.target.checked })}
|
||||||
className="rounded border-white/10 bg-white/5 text-primary focus:ring-0 focus:ring-offset-0"
|
className="rounded border-white/10 bg-white/5 text-primary focus:ring-0 focus:ring-offset-0"
|
||||||
/>
|
/>
|
||||||
<span>Mehrfachauswahl (allow_multiselect)</span>
|
<span>Mehrfachauswahl</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -274,6 +283,84 @@ export function WysiwygAdminClient({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Modul hinzufügen Popover */}
|
{/* Modul hinzufügen Popover */}
|
||||||
|
|||||||
@@ -206,3 +206,61 @@ export async function transferModules(
|
|||||||
revalidatePath('/admin/products')
|
revalidatePath('/admin/products')
|
||||||
revalidatePath('/order')
|
revalidatePath('/order')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function addProductDependency(
|
||||||
|
productId: string,
|
||||||
|
type: 'requirements' | 'exclusions',
|
||||||
|
dependencyId: string
|
||||||
|
) {
|
||||||
|
const supabase = await createClient()
|
||||||
|
|
||||||
|
const { data: product, error: fetchError } = await supabase
|
||||||
|
.from('products')
|
||||||
|
.select('requirements, exclusions')
|
||||||
|
.eq('id', productId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (fetchError) throw fetchError
|
||||||
|
|
||||||
|
const currentDeps = (product[type] || []) as string[]
|
||||||
|
if (!currentDeps.includes(dependencyId)) {
|
||||||
|
const updatedDeps = [...currentDeps, dependencyId]
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('products')
|
||||||
|
.update({ [type]: updatedDeps })
|
||||||
|
.eq('id', productId)
|
||||||
|
if (updateError) throw updateError
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/admin/products')
|
||||||
|
revalidatePath('/order')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeProductDependency(
|
||||||
|
productId: string,
|
||||||
|
type: 'requirements' | 'exclusions',
|
||||||
|
dependencyId: string
|
||||||
|
) {
|
||||||
|
const supabase = await createClient()
|
||||||
|
|
||||||
|
const { data: product, error: fetchError } = await supabase
|
||||||
|
.from('products')
|
||||||
|
.select('requirements, exclusions')
|
||||||
|
.eq('id', productId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (fetchError) throw fetchError
|
||||||
|
|
||||||
|
const currentDeps = (product[type] || []) as string[]
|
||||||
|
if (currentDeps.includes(dependencyId)) {
|
||||||
|
const updatedDeps = currentDeps.filter(id => id !== dependencyId)
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('products')
|
||||||
|
.update({ [type]: updatedDeps })
|
||||||
|
.eq('id', productId)
|
||||||
|
if (updateError) throw updateError
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/admin/products')
|
||||||
|
revalidatePath('/order')
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user