feat(multiselect): support multi-select categories and free items limits in category config and order wizard
All checks were successful
Staging Build / build (push) Successful in 2m38s

This commit is contained in:
DanielS
2026-07-07 16:56:24 +02:00
parent 00d1be3e4f
commit cc7445e4c5
9 changed files with 451 additions and 212 deletions

View File

@@ -17,9 +17,9 @@ import { createEndCustomer } from '@/lib/actions/end-customers'
import { Category } from '@/lib/types'
import { Badge } from '@/components/ui/badge'
// ─── Types ────────────────────────────────────────────────────────────────────
type CategorySelection = {
productId: string | null // selected product in this category
productIds?: string[] // selected product IDs for multi-select
moduleIds: string[] // selected module IDs for this product
}
@@ -195,22 +195,28 @@ export function OrderWizard({
}
}, [lastLicenseMonths, selectedBillingInterval])
// Per-category selection map: categoryId -> { productId, moduleIds }
// Per-category selection map: categoryId -> { productId, productIds, moduleIds }
const [selections, setSelections] = useState<Record<string, CategorySelection>>(() => {
const init: Record<string, CategorySelection> = {}
categories.forEach(cat => {
if (initialOrder) {
const item = initialOrder.order_data?.items?.find(i => i.category_id === cat.id)
if (item) {
// Multi-select might have multiple items matching this category
const matchingItems = initialOrder.order_data?.items?.filter(i => i.category_id === cat.id) || []
if (matchingItems.length > 0) {
init[cat.id] = {
productId: item.product_id,
moduleIds: item.selected_modules?.map(m => m.module_id) ?? []
productId: matchingItems[0].product_id,
productIds: matchingItems.map(i => i.product_id),
moduleIds: matchingItems.flatMap(i => i.selected_modules?.map(m => m.module_id) ?? [])
}
return
}
}
const first = products.find(p => p.category_id === cat.id && p.show_in_abo !== false)
init[cat.id] = { productId: first?.id ?? null, moduleIds: [] }
if (cat.allow_multiselect) {
init[cat.id] = { productId: null, productIds: [], moduleIds: [] }
} else {
const first = products.find(p => p.category_id === cat.id && p.show_in_abo !== false)
init[cat.id] = { productId: first?.id ?? null, productIds: first ? [first.id] : [], moduleIds: [] }
}
})
return init
})
@@ -221,6 +227,14 @@ export function OrderWizard({
.filter(cat => cat.is_required)
.every(cat => {
const sel = selections[cat.id]
if (cat.allow_multiselect) {
if (!sel?.productIds || sel.productIds.length === 0) return false
return sel.productIds.every(pId => {
const prod = products.find(p => p.id === pId)
if (!prod) return false
return selectedBillingInterval === 'one_time' ? prod.show_in_kauf !== false : prod.show_in_abo !== false
})
}
if (!sel?.productId) return false
const prod = products.find(p => p.id === sel.productId)
if (!prod) return false
@@ -234,9 +248,16 @@ export function OrderWizard({
// Product validation errors (requirements and exclusions)
const productValidationErrors = useMemo(() => {
const errors: string[] = []
const selectedProductIds = Object.values(selections)
.map(s => s.productId)
.filter((id): id is string => !!id)
const selectedProductIds: string[] = []
Object.values(selections).forEach(s => {
if (s.productIds && s.productIds.length > 0) {
s.productIds.forEach(id => {
if (id) selectedProductIds.push(id)
})
} else if (s.productId) {
selectedProductIds.push(s.productId)
}
})
selectedProductIds.forEach(prodId => {
const prod = products.find(p => p.id === prodId)
@@ -276,24 +297,42 @@ export function OrderWizard({
let oneTime = 0
for (const cat of categories) {
const sel = selections[cat.id]
if (!sel?.productId) continue
const prod = products.find(p => p.id === sel.productId)
if (!prod) continue
const base = Number(prod.base_price)
let modulesSum = 0
sel.moduleIds.forEach(mId => {
const mod = prod.modules?.find(m => m.id === mId)
if (mod) {
const qty = moduleQuantities[mId] || 1
modulesSum += Number(mod.price) * qty
if (!sel) continue
const selectedProds: Product[] = []
if (cat.allow_multiselect && sel.productIds && sel.productIds.length > 0) {
sel.productIds.forEach(pId => {
const p = products.find(prod => prod.id === pId)
if (p) selectedProds.push(p)
})
} else if (sel.productId) {
const p = products.find(prod => prod.id === sel.productId)
if (p) selectedProds.push(p)
}
if (selectedProds.length === 0) continue
// Apply free items limit: sort selected products by base_price ascending so that the cheapest are free
const sortedProds = [...selectedProds].sort((a, b) => a.base_price - b.base_price)
const freeLimit = cat.allow_multiselect ? cat.free_items_limit : 0
sortedProds.forEach((prod, sortedIndex) => {
const base = sortedIndex < freeLimit ? 0 : Number(prod.base_price)
let modulesSum = 0
sel.moduleIds.forEach(mId => {
const mod = prod.modules?.find(m => m.id === mId)
if (mod) {
const qty = moduleQuantities[mId] || 1
modulesSum += Number(mod.price) * qty
}
})
const totalItem = base + modulesSum
if (prod.billing_interval === 'one_time') {
oneTime += totalItem
} else {
monthly += totalItem
}
})
const totalItem = base + modulesSum
if (prod.billing_interval === 'one_time') {
oneTime += totalItem
} else {
monthly += totalItem
}
}
// Apply update modifier to oneTime total
if (updatePriceModifier.multiplier !== 1) {
@@ -310,37 +349,77 @@ export function OrderWizard({
const monthlyTax = monthlyNet * 0.19
const monthlyGross = monthlyNet + monthlyTax
// Helper: update product selection for a category (resets modules)
// Helper: update product selection for a category
function selectProduct(catId: string, productId: string) {
const cat = categories.find(c => c.id === catId)
setSelections(prev => {
const sel = prev[catId]
let nextProductIds = sel?.productIds || []
let nextProductId = sel?.productId || null
if (cat?.allow_multiselect) {
if (nextProductIds.includes(productId)) {
nextProductIds = nextProductIds.filter(id => id !== productId)
} else {
nextProductIds = [...nextProductIds, productId]
}
nextProductId = nextProductIds[0] || null
} else {
nextProductIds = [productId]
nextProductId = productId
}
const next = {
...prev,
[catId]: { productId, moduleIds: [] },
[catId]: { productId: nextProductId, productIds: nextProductIds, moduleIds: [] },
}
// Automatically deselect now conflicting products in other categories
const selectedIds = Object.entries(next)
.map(([, s]) => s.productId)
.filter((id): id is string => !!id)
const selectedIds: string[] = []
Object.values(next).forEach(s => {
if (s.productIds && s.productIds.length > 0) {
s.productIds.forEach(id => {
if (id) selectedIds.push(id)
})
} else if (s.productId) {
selectedIds.push(s.productId)
}
})
categories.forEach(c => {
if (c.id === catId) return
const sel = next[c.id]
if (!sel?.productId) return
const otherSelectedProductIds = selectedIds.filter(id => id !== sel.productId)
const prod = products.find(p => p.id === sel.productId)
const isExcluded = prod && (
otherSelectedProductIds.some(otherId => {
const otherProd = products.find(p => p.id === otherId)
return otherProd?.exclusions?.includes(prod.id)
}) ||
prod.exclusions?.some(exId => otherSelectedProductIds.includes(exId))
)
if (isExcluded) {
next[c.id] = { productId: null, moduleIds: [] }
const s = next[c.id]
if (c.allow_multiselect && s?.productIds) {
const validProductIds = s.productIds.filter(pId => {
const prod = products.find(p => p.id === pId)
const otherSelectedProductIds = selectedIds.filter(id => id !== pId)
const isExcluded = prod && (
otherSelectedProductIds.some(otherId => {
const otherProd = products.find(p => p.id === otherId)
return otherProd?.exclusions?.includes(prod.id)
}) ||
prod.exclusions?.some(exId => otherSelectedProductIds.includes(exId))
)
return !isExcluded
})
next[c.id] = {
productId: validProductIds[0] || null,
productIds: validProductIds,
moduleIds: []
}
} else if (s?.productId) {
const prod = products.find(p => p.id === s.productId)
const otherSelectedProductIds = selectedIds.filter(id => id !== s.productId)
const isExcluded = prod && (
otherSelectedProductIds.some(otherId => {
const otherProd = products.find(p => p.id === otherId)
return otherProd?.exclusions?.includes(prod.id)
}) ||
prod.exclusions?.some(exId => otherSelectedProductIds.includes(exId))
)
if (isExcluded) {
next[c.id] = { productId: null, productIds: [], moduleIds: [] }
}
}
})
@@ -352,8 +431,10 @@ export function OrderWizard({
function toggleModule(catId: string, moduleId: string) {
setSelections(prev => {
const sel = prev[catId]
if (!sel?.productId) return prev
const prod = products.find(p => p.id === sel.productId)
// Pick first selected product to associate module with (or fall back to single select productId)
const activeProductId = sel?.productId
if (!activeProductId) return prev
const prod = products.find(p => p.id === activeProductId)
const newModuleIds = toggleModuleInList(prod?.modules ?? [], sel.moduleIds, moduleId)
return { ...prev, [catId]: { ...sel, moduleIds: newModuleIds } }
})
@@ -827,7 +908,11 @@ export function OrderWizard({
<p className="text-slate-400 text-xs">{cat.description}</p>
)}
</div>
{sel?.productId ? (
{sel?.productIds && sel.productIds.length > 0 ? (
<Badge className="ml-auto bg-green-500/20 text-green-400 border border-green-500/30">
<Check className="w-3 h-3 mr-1" /> {sel.productIds.length} Ausgewählt
</Badge>
) : sel?.productId ? (
<Badge className="ml-auto bg-green-500/20 text-green-400 border border-green-500/30">
<Check className="w-3 h-3 mr-1" /> Ausgewählt
</Badge>
@@ -846,6 +931,53 @@ export function OrderWizard({
<p className="text-slate-500 text-sm italic">
Keine Produkte in dieser Kategorie im aktuellen Abrechnungsmodell vorhanden.
</p>
) : cat.allow_multiselect ? (
<div className="grid gap-3">
{catProducts.map(product => {
const isChecked = sel?.productIds?.includes(product.id) ?? false
return (
<div key={product.id} className="relative">
<Label
onClick={() => selectProduct(cat.id, product.id)}
className={`flex flex-col items-start p-4 rounded-xl border-2 cursor-pointer transition-all ${
isChecked
? 'border-primary bg-primary/5'
: 'border-white/5 bg-white/5 hover:bg-white/10'
}`}
>
<div className="flex justify-between w-full items-center">
<div className="flex items-center gap-3">
<Checkbox
checked={isChecked}
onCheckedChange={() => {}} // onClick on label handles it
className="border-white/20 data-[state=checked]:bg-primary"
/>
<span className="font-bold text-base text-white">{product.name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${billingBadgeClass(product.billing_interval)}`}>
{product.billing_interval === 'one_time' ? 'Einmalig' : 'Abo/Monat'}
</span>
</div>
<span className="text-primary font-semibold text-sm">
{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(product.base_price)}{' '}
<span className="text-xs text-slate-400">{billingLabel(product.billing_interval)}</span>
</span>
</div>
{product.description && (
<span className="text-sm text-slate-300 mt-1 pl-7">{product.description}</span>
)}
{product.modules && product.modules.length > 0 && (
<span className="text-xs text-slate-500 mt-1 pl-7">
{product.modules.length} optionale Module verfügbar
</span>
)}
</Label>
</div>
)
})}
</div>
) : (
<RadioGroup
value={sel?.productId ?? ''}
@@ -984,8 +1116,18 @@ export function OrderWizard({
<CardContent className="space-y-4">
{categories.map(cat => {
const sel = selections[cat.id]
const prod = products.find(p => p.id === sel?.productId)
if (!prod) return (
const selectedProds: Product[] = []
if (cat.allow_multiselect && sel?.productIds) {
sel.productIds.forEach(pId => {
const p = products.find(prod => prod.id === pId)
if (p) selectedProds.push(p)
})
} else if (sel?.productId) {
const p = products.find(prod => prod.id === sel.productId)
if (p) selectedProds.push(p)
}
if (selectedProds.length === 0) return (
<div key={cat.id} className="text-xs text-slate-500 italic flex items-center gap-1">
{cat.is_required ? (
<AlertCircle className="w-3 h-3 text-destructive" />
@@ -995,29 +1137,42 @@ export function OrderWizard({
{cat.name}: {cat.is_required ? 'nicht gewählt' : 'nicht gewählt (optional)'}
</div>
)
// Sort selected products by price to determine which ones are free
const sortedProds = [...selectedProds].sort((a, b) => a.base_price - b.base_price)
const freeLimit = cat.allow_multiselect ? cat.free_items_limit : 0
return (
<div key={cat.id} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-slate-400 font-medium">{cat.name}</span>
</div>
<div className="flex justify-between text-sm pl-2">
<span className="text-white">{prod.name}</span>
<span className="text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(prod.base_price)}
{' '}<span className="text-slate-400 text-xs">{billingLabel(prod.billing_interval)}</span>
</span>
</div>
{sel.moduleIds.map(mId => {
const mod = prod.modules?.find(m => m.id === mId)
const qty = moduleQuantities[mId] || 1
return mod ? (
<div key={mId} className="flex justify-between text-xs pl-4">
<span className="text-slate-300">+ {mod.name} {mod.has_quantity ? `(x${qty})` : ''}</span>
<span className="text-slate-300">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price * qty)}
</span>
{sortedProds.map((prod, idx) => {
const isFree = idx < freeLimit
const actualPrice = isFree ? 0 : prod.base_price
return (
<div key={prod.id} className="space-y-0.5 pl-2">
<div className="flex justify-between text-sm">
<span className="text-white">{prod.name} {isFree && <span className="text-[10px] text-green-400">(Frei)</span>}</span>
<span className="text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(actualPrice)}
{' '}<span className="text-slate-400 text-xs">{billingLabel(prod.billing_interval)}</span>
</span>
</div>
{sel.moduleIds.map(mId => {
const mod = prod.modules?.find(m => m.id === mId)
const qty = moduleQuantities[mId] || 1
return mod ? (
<div key={mId} className="flex justify-between text-xs pl-2">
<span className="text-slate-300">+ {mod.name} {mod.has_quantity ? `(x${qty})` : ''}</span>
<span className="text-slate-300">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price * qty)}
</span>
</div>
) : null
})}
</div>
) : null
)
})}
</div>
)
@@ -1150,39 +1305,66 @@ export function OrderWizard({
<div className="p-4 rounded-lg bg-white/5 space-y-4">
{categories.map(cat => {
const sel = selections[cat.id]
const prod = products.find(p => p.id === sel?.productId)
if (!prod) return null
const catTotal =
Number(prod.base_price) +
sel.moduleIds.reduce((acc, mId) => {
const mod = prod.modules?.find(m => m.id === mId)
const qty = moduleQuantities[mId] || 1
return acc + (Number(mod?.price ?? 0) * qty)
}, 0)
const selectedProds: Product[] = []
if (cat.allow_multiselect && sel?.productIds) {
sel.productIds.forEach(pId => {
const p = products.find(prod => prod.id === pId)
if (p) selectedProds.push(p)
})
} else if (sel?.productId) {
const p = products.find(prod => prod.id === sel.productId)
if (p) selectedProds.push(p)
}
if (selectedProds.length === 0) return null
// Sort selected products by price to determine which ones are free
const sortedProds = [...selectedProds].sort((a, b) => a.base_price - b.base_price)
const freeLimit = cat.allow_multiselect ? cat.free_items_limit : 0
return (
<div key={cat.id}>
<div className="flex justify-between font-bold text-base text-white">
<span className="flex items-center gap-2">
<CategoryIcon icon={cat.icon} className="w-4 h-4 text-primary" />
{prod.name}
</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(catTotal)}
{' '}<span className="text-sm font-normal text-slate-400">{billingLabel(prod.billing_interval)}</span>
</span>
<div key={cat.id} className="space-y-2 border-b border-white/5 pb-3 last:border-0 last:pb-0">
<div className="flex items-center gap-2 text-slate-400 font-semibold text-sm">
<CategoryIcon icon={cat.icon} className="w-4 h-4 text-primary" />
<span>{cat.name}</span>
</div>
{sel.moduleIds.length > 0 && (
<p className="text-sm text-slate-300 mt-1 pl-6">
Module: {sel.moduleIds
.map(id => {
const mod = prod.modules?.find(m => m.id === id)
const qty = moduleQuantities[id] || 1
return mod ? `${mod.name}${mod.has_quantity ? ` (x${qty})` : ''}` : ''
})
.filter(Boolean)
.join(', ')}
</p>
)}
{sortedProds.map((prod, idx) => {
const isFree = idx < freeLimit
const actualPrice = isFree ? 0 : prod.base_price
const catTotal =
Number(actualPrice) +
(sel?.moduleIds?.reduce((acc, mId) => {
const mod = prod.modules?.find(m => m.id === mId)
const qty = moduleQuantities[mId] || 1
return acc + (Number(mod?.price ?? 0) * qty)
}, 0) || 0)
return (
<div key={prod.id} className="pl-4">
<div className="flex justify-between font-bold text-base text-white">
<span>
{prod.name} {isFree && <span className="text-[10px] text-green-400 font-semibold">(Frei)</span>}
</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(catTotal)}
{' '}<span className="text-sm font-normal text-slate-400">{billingLabel(prod.billing_interval)}</span>
</span>
</div>
{sel?.moduleIds && sel.moduleIds.length > 0 && (
<p className="text-sm text-slate-300 mt-1">
Module: {sel.moduleIds
.map(id => {
const mod = prod.modules?.find(m => m.id === id)
const qty = moduleQuantities[id] || 1
return mod ? `${mod.name}${mod.has_quantity ? ` (x${qty})` : ''}` : ''
})
.filter(Boolean)
.join(', ')}
</p>
)}
</div>
)
})}
</div>
)
})}