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
All checks were successful
Staging Build / build (push) Successful in 2m38s
This commit is contained in:
@@ -7,33 +7,46 @@ export function validateWizardSelections(
|
||||
): void {
|
||||
for (const cat of dbCategories) {
|
||||
const sel = selections[cat.id]
|
||||
if (cat.is_required && (!sel || !sel.productId)) {
|
||||
const hasSelection = sel && (
|
||||
(cat.allow_multiselect && sel.productIds && sel.productIds.length > 0) ||
|
||||
(!cat.allow_multiselect && sel.productId)
|
||||
)
|
||||
|
||||
if (cat.is_required && !hasSelection) {
|
||||
throw new Error(`Die Kategorie "${cat.name}" ist ein Pflichtfeld, wurde aber nicht ausgewählt.`)
|
||||
}
|
||||
if (sel?.productId) {
|
||||
const prod = dbProducts.find(p => p.id === sel.productId)
|
||||
if (!prod) {
|
||||
throw new Error(`Das ausgewählte Produkt für Kategorie "${cat.name}" wurde nicht gefunden.`)
|
||||
|
||||
if (sel) {
|
||||
const selectedProds: Product[] = []
|
||||
if (cat.allow_multiselect && sel.productIds) {
|
||||
sel.productIds.forEach(pId => {
|
||||
const p = dbProducts.find(prod => prod.id === pId)
|
||||
if (p) selectedProds.push(p)
|
||||
})
|
||||
} else if (sel.productId) {
|
||||
const p = dbProducts.find(prod => prod.id === sel.productId)
|
||||
if (p) selectedProds.push(p)
|
||||
}
|
||||
|
||||
// Validate selected modules
|
||||
for (const mId of sel.moduleIds) {
|
||||
const mod = prod.modules?.find(m => m.id === mId)
|
||||
if (!mod) {
|
||||
throw new Error(`Das Modul mit ID "${mId}" gehört nicht zum Produkt "${prod.name}".`)
|
||||
}
|
||||
// Requirements check
|
||||
if (mod.requirements && mod.requirements.length > 0) {
|
||||
const hasMetRequirement = mod.requirements.some(reqId => sel.moduleIds.includes(reqId))
|
||||
if (!hasMetRequirement) {
|
||||
throw new Error(`Das Modul "${mod.name}" setzt die Aktivierung mindestens eines der folgenden Module voraus: ${mod.requirements.join(', ')}.`)
|
||||
for (const prod of selectedProds) {
|
||||
// Validate selected modules
|
||||
for (const mId of sel.moduleIds) {
|
||||
const mod = prod.modules?.find(m => m.id === mId)
|
||||
if (!mod) continue // module might belong to another selected product in the same category, so skip error if it's not this one's
|
||||
|
||||
// Requirements check
|
||||
if (mod.requirements && mod.requirements.length > 0) {
|
||||
const hasMetRequirement = mod.requirements.some(reqId => sel.moduleIds.includes(reqId))
|
||||
if (!hasMetRequirement) {
|
||||
throw new Error(`Das Modul "${mod.name}" setzt die Aktivierung mindestens eines der folgenden Module voraus: ${mod.requirements.join(', ')}.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Exclusions check
|
||||
if (mod.exclusions && mod.exclusions.length > 0) {
|
||||
const conflicting = mod.exclusions.filter(exId => sel.moduleIds.includes(exId))
|
||||
if (conflicting.length > 0) {
|
||||
throw new Error(`Das Modul "${mod.name}" schließt die Kombination mit anderen gewählten Modulen aus.`)
|
||||
// Exclusions check
|
||||
if (mod.exclusions && mod.exclusions.length > 0) {
|
||||
const conflicting = mod.exclusions.filter(exId => sel.moduleIds.includes(exId))
|
||||
if (conflicting.length > 0) {
|
||||
throw new Error(`Das Modul "${mod.name}" schließt die Kombination mit anderen gewählten Modulen aus.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,9 +54,16 @@ export function validateWizardSelections(
|
||||
}
|
||||
|
||||
// Product-level constraints validation
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
for (const prodId of selectedProductIds) {
|
||||
const prod = dbProducts.find(p => p.id === prodId)
|
||||
|
||||
@@ -99,40 +99,59 @@ export function buildOrderSnapshot(
|
||||
|
||||
for (const cat of categories) {
|
||||
const sel: CategorySelection | undefined = selections[cat.id]
|
||||
if (!sel?.productId) continue
|
||||
if (!sel) continue
|
||||
|
||||
const prod = products.find((p) => p.id === sel.productId)
|
||||
if (!prod) continue
|
||||
|
||||
dominantTaxRate = prod.tax_rate
|
||||
|
||||
const selectedModules = sel.moduleIds
|
||||
.map((modId) => prod.modules?.find((m) => m.id === modId))
|
||||
.filter((m): m is NonNullable<typeof m> => !!m)
|
||||
.map((mod) => {
|
||||
const qty = moduleQuantities?.[mod.id] || 1
|
||||
return {
|
||||
module_id: mod.id,
|
||||
module_name: mod.name,
|
||||
price: mod.price,
|
||||
quantity: qty,
|
||||
total_price: mod.price * qty,
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
const moduleTotal = selectedModules.reduce((acc, m) => acc + (m.total_price || m.price), 0)
|
||||
const itemTotal = prod.base_price + moduleTotal
|
||||
subtotal += itemTotal
|
||||
if (selectedProds.length === 0) continue
|
||||
|
||||
items.push({
|
||||
category_id: cat.id,
|
||||
category_name: cat.name,
|
||||
product_id: prod.id,
|
||||
product_name: prod.name,
|
||||
base_price: prod.base_price,
|
||||
billing_interval: prod.billing_interval,
|
||||
selected_modules: selectedModules,
|
||||
item_total: itemTotal,
|
||||
// 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) => {
|
||||
dominantTaxRate = prod.tax_rate
|
||||
|
||||
const selectedModules = sel.moduleIds
|
||||
.map((modId) => prod.modules?.find((m) => m.id === modId))
|
||||
.filter((m): m is NonNullable<typeof m> => !!m)
|
||||
.map((mod) => {
|
||||
const qty = moduleQuantities?.[mod.id] || 1
|
||||
return {
|
||||
module_id: mod.id,
|
||||
module_name: mod.name,
|
||||
price: mod.price,
|
||||
quantity: qty,
|
||||
total_price: mod.price * qty,
|
||||
}
|
||||
})
|
||||
|
||||
const moduleTotal = selectedModules.reduce((acc, m) => acc + (m.total_price || m.price), 0)
|
||||
|
||||
// If this product falls under the free limit, set its base price to 0
|
||||
const actualBasePrice = (sortedIndex < freeLimit) ? 0 : prod.base_price
|
||||
const itemTotal = actualBasePrice + moduleTotal
|
||||
subtotal += itemTotal
|
||||
|
||||
items.push({
|
||||
category_id: cat.id,
|
||||
category_name: cat.name,
|
||||
product_id: prod.id,
|
||||
product_name: prod.name + (actualBasePrice === 0 ? " (Inklusive/Frei)" : ""),
|
||||
base_price: actualBasePrice,
|
||||
billing_interval: prod.billing_interval,
|
||||
selected_modules: selectedModules,
|
||||
item_total: itemTotal,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ export type Category = {
|
||||
icon?: string | null
|
||||
sort_order: number
|
||||
is_required: boolean
|
||||
allow_multiselect: boolean
|
||||
free_items_limit: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -79,7 +81,8 @@ export type EndCustomer = {
|
||||
// ─── Wizard-Zustand (flüchtig, im RAM) ───────────────────────────────────────
|
||||
|
||||
export type CategorySelection = {
|
||||
productId: string | null
|
||||
productId: string | null // for backwards compatibility & single select
|
||||
productIds?: string[] // for multi-select categories
|
||||
moduleIds: string[]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user