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:
@@ -43,6 +43,8 @@ const categorySchema = z.object({
|
|||||||
icon: z.string().default('Package'),
|
icon: z.string().default('Package'),
|
||||||
sort_order: z.coerce.number().int().min(0).default(0),
|
sort_order: z.coerce.number().int().min(0).default(0),
|
||||||
is_required: z.boolean().default(true),
|
is_required: z.boolean().default(true),
|
||||||
|
allow_multiselect: z.boolean().default(false),
|
||||||
|
free_items_limit: z.coerce.number().int().min(0).default(0),
|
||||||
})
|
})
|
||||||
|
|
||||||
type CategoryFormValues = z.infer<typeof categorySchema>
|
type CategoryFormValues = z.infer<typeof categorySchema>
|
||||||
@@ -65,15 +67,17 @@ export function CategoryDialog({
|
|||||||
icon: category?.icon || 'Package',
|
icon: category?.icon || 'Package',
|
||||||
sort_order: category?.sort_order ?? 0,
|
sort_order: category?.sort_order ?? 0,
|
||||||
is_required: category?.is_required ?? true,
|
is_required: category?.is_required ?? true,
|
||||||
|
allow_multiselect: category?.allow_multiselect ?? false,
|
||||||
|
free_items_limit: category?.free_items_limit ?? 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
async function onSubmit(values: CategoryFormValues) {
|
async function onSubmit(values: CategoryFormValues) {
|
||||||
try {
|
try {
|
||||||
if (category) {
|
if (category) {
|
||||||
await updateCategory(category.id, values)
|
await updateCategory(category.id, values as any)
|
||||||
} else {
|
} else {
|
||||||
await createCategory(values)
|
await createCategory(values as any)
|
||||||
}
|
}
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
form.reset()
|
form.reset()
|
||||||
@@ -207,6 +211,52 @@ export function CategoryDialog({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="allow_multiselect"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-lg border border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 p-4">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="space-y-1 leading-none">
|
||||||
|
<FormLabel className="text-slate-900 dark:text-white font-semibold cursor-pointer">
|
||||||
|
Mehrfachauswahl erlauben
|
||||||
|
</FormLabel>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Erlaubt die Auswahl mehrerer Produkte/Module innerhalb dieser Kategorie.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{form.watch('allow_multiselect') && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="free_items_limit"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-slate-900 dark:text-white">Inklusive Freie Artikel Limit</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
placeholder="z.B. 2"
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Bis zu dieser Anzahl sind gewählte Artikel in dieser Kategorie kostenlos. Alle darüber hinausgehenden werden voll berechnet (günstigste Artikel zuerst frei).
|
||||||
|
</p>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
<DialogFooter className="pt-4">
|
<DialogFooter className="pt-4">
|
||||||
|
|||||||
@@ -80,11 +80,18 @@ export function CategoryList({ initialCategories }: { initialCategories: Categor
|
|||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
<div className="flex flex-col gap-1 items-start">
|
||||||
{category.is_required ? (
|
{category.is_required ? (
|
||||||
<Badge className="bg-green-500/20 text-green-600 dark:text-green-400 border border-green-500/30 whitespace-nowrap">Pflicht</Badge>
|
<Badge className="bg-green-500/20 text-green-600 dark:text-green-400 border border-green-500/30 whitespace-nowrap">Pflicht</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="border-slate-200 dark:border-white/20 text-slate-500 dark:text-slate-400 whitespace-nowrap">Optional</Badge>
|
<Badge variant="outline" className="border-slate-200 dark:border-white/20 text-slate-500 dark:text-slate-400 whitespace-nowrap">Optional</Badge>
|
||||||
)}
|
)}
|
||||||
|
{category.allow_multiselect && (
|
||||||
|
<Badge className="bg-purple-500/20 text-purple-600 dark:text-purple-400 border border-purple-500/30 whitespace-nowrap">
|
||||||
|
Multi ({category.free_items_limit} frei)
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ import { createEndCustomer } from '@/lib/actions/end-customers'
|
|||||||
import { Category } from '@/lib/types'
|
import { Category } from '@/lib/types'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
||||||
type CategorySelection = {
|
type CategorySelection = {
|
||||||
productId: string | null // selected product in this category
|
productId: string | null // selected product in this category
|
||||||
|
productIds?: string[] // selected product IDs for multi-select
|
||||||
moduleIds: string[] // selected module IDs for this product
|
moduleIds: string[] // selected module IDs for this product
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,22 +195,28 @@ export function OrderWizard({
|
|||||||
}
|
}
|
||||||
}, [lastLicenseMonths, selectedBillingInterval])
|
}, [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 [selections, setSelections] = useState<Record<string, CategorySelection>>(() => {
|
||||||
const init: Record<string, CategorySelection> = {}
|
const init: Record<string, CategorySelection> = {}
|
||||||
categories.forEach(cat => {
|
categories.forEach(cat => {
|
||||||
if (initialOrder) {
|
if (initialOrder) {
|
||||||
const item = initialOrder.order_data?.items?.find(i => i.category_id === cat.id)
|
// Multi-select might have multiple items matching this category
|
||||||
if (item) {
|
const matchingItems = initialOrder.order_data?.items?.filter(i => i.category_id === cat.id) || []
|
||||||
|
if (matchingItems.length > 0) {
|
||||||
init[cat.id] = {
|
init[cat.id] = {
|
||||||
productId: item.product_id,
|
productId: matchingItems[0].product_id,
|
||||||
moduleIds: item.selected_modules?.map(m => m.module_id) ?? []
|
productIds: matchingItems.map(i => i.product_id),
|
||||||
|
moduleIds: matchingItems.flatMap(i => i.selected_modules?.map(m => m.module_id) ?? [])
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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)
|
const first = products.find(p => p.category_id === cat.id && p.show_in_abo !== false)
|
||||||
init[cat.id] = { productId: first?.id ?? null, moduleIds: [] }
|
init[cat.id] = { productId: first?.id ?? null, productIds: first ? [first.id] : [], moduleIds: [] }
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return init
|
return init
|
||||||
})
|
})
|
||||||
@@ -221,6 +227,14 @@ export function OrderWizard({
|
|||||||
.filter(cat => cat.is_required)
|
.filter(cat => cat.is_required)
|
||||||
.every(cat => {
|
.every(cat => {
|
||||||
const sel = selections[cat.id]
|
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
|
if (!sel?.productId) return false
|
||||||
const prod = products.find(p => p.id === sel.productId)
|
const prod = products.find(p => p.id === sel.productId)
|
||||||
if (!prod) return false
|
if (!prod) return false
|
||||||
@@ -234,9 +248,16 @@ export function OrderWizard({
|
|||||||
// Product validation errors (requirements and exclusions)
|
// Product validation errors (requirements and exclusions)
|
||||||
const productValidationErrors = useMemo(() => {
|
const productValidationErrors = useMemo(() => {
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
const selectedProductIds = Object.values(selections)
|
const selectedProductIds: string[] = []
|
||||||
.map(s => s.productId)
|
Object.values(selections).forEach(s => {
|
||||||
.filter((id): id is string => !!id)
|
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 => {
|
selectedProductIds.forEach(prodId => {
|
||||||
const prod = products.find(p => p.id === prodId)
|
const prod = products.find(p => p.id === prodId)
|
||||||
@@ -276,10 +297,27 @@ export function OrderWizard({
|
|||||||
let oneTime = 0
|
let oneTime = 0
|
||||||
for (const cat of categories) {
|
for (const cat of categories) {
|
||||||
const sel = selections[cat.id]
|
const sel = selections[cat.id]
|
||||||
if (!sel?.productId) continue
|
if (!sel) continue
|
||||||
const prod = products.find(p => p.id === sel.productId)
|
|
||||||
if (!prod) continue
|
const selectedProds: Product[] = []
|
||||||
const base = Number(prod.base_price)
|
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
|
let modulesSum = 0
|
||||||
sel.moduleIds.forEach(mId => {
|
sel.moduleIds.forEach(mId => {
|
||||||
const mod = prod.modules?.find(m => m.id === mId)
|
const mod = prod.modules?.find(m => m.id === mId)
|
||||||
@@ -294,6 +332,7 @@ export function OrderWizard({
|
|||||||
} else {
|
} else {
|
||||||
monthly += totalItem
|
monthly += totalItem
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
// Apply update modifier to oneTime total
|
// Apply update modifier to oneTime total
|
||||||
if (updatePriceModifier.multiplier !== 1) {
|
if (updatePriceModifier.multiplier !== 1) {
|
||||||
@@ -310,27 +349,67 @@ export function OrderWizard({
|
|||||||
const monthlyTax = monthlyNet * 0.19
|
const monthlyTax = monthlyNet * 0.19
|
||||||
const monthlyGross = monthlyNet + monthlyTax
|
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) {
|
function selectProduct(catId: string, productId: string) {
|
||||||
|
const cat = categories.find(c => c.id === catId)
|
||||||
setSelections(prev => {
|
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 = {
|
const next = {
|
||||||
...prev,
|
...prev,
|
||||||
[catId]: { productId, moduleIds: [] },
|
[catId]: { productId: nextProductId, productIds: nextProductIds, moduleIds: [] },
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatically deselect now conflicting products in other categories
|
// Automatically deselect now conflicting products in other categories
|
||||||
const selectedIds = Object.entries(next)
|
const selectedIds: string[] = []
|
||||||
.map(([, s]) => s.productId)
|
Object.values(next).forEach(s => {
|
||||||
.filter((id): id is string => !!id)
|
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 => {
|
categories.forEach(c => {
|
||||||
if (c.id === catId) return
|
if (c.id === catId) return
|
||||||
const sel = next[c.id]
|
const s = next[c.id]
|
||||||
if (!sel?.productId) return
|
if (c.allow_multiselect && s?.productIds) {
|
||||||
|
const validProductIds = s.productIds.filter(pId => {
|
||||||
const otherSelectedProductIds = selectedIds.filter(id => id !== sel.productId)
|
const prod = products.find(p => p.id === pId)
|
||||||
const prod = products.find(p => p.id === sel.productId)
|
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 && (
|
const isExcluded = prod && (
|
||||||
otherSelectedProductIds.some(otherId => {
|
otherSelectedProductIds.some(otherId => {
|
||||||
const otherProd = products.find(p => p.id === otherId)
|
const otherProd = products.find(p => p.id === otherId)
|
||||||
@@ -338,9 +417,9 @@ export function OrderWizard({
|
|||||||
}) ||
|
}) ||
|
||||||
prod.exclusions?.some(exId => otherSelectedProductIds.includes(exId))
|
prod.exclusions?.some(exId => otherSelectedProductIds.includes(exId))
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isExcluded) {
|
if (isExcluded) {
|
||||||
next[c.id] = { productId: null, moduleIds: [] }
|
next[c.id] = { productId: null, productIds: [], moduleIds: [] }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -352,8 +431,10 @@ export function OrderWizard({
|
|||||||
function toggleModule(catId: string, moduleId: string) {
|
function toggleModule(catId: string, moduleId: string) {
|
||||||
setSelections(prev => {
|
setSelections(prev => {
|
||||||
const sel = prev[catId]
|
const sel = prev[catId]
|
||||||
if (!sel?.productId) return prev
|
// Pick first selected product to associate module with (or fall back to single select productId)
|
||||||
const prod = products.find(p => p.id === sel.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)
|
const newModuleIds = toggleModuleInList(prod?.modules ?? [], sel.moduleIds, moduleId)
|
||||||
return { ...prev, [catId]: { ...sel, moduleIds: newModuleIds } }
|
return { ...prev, [catId]: { ...sel, moduleIds: newModuleIds } }
|
||||||
})
|
})
|
||||||
@@ -827,7 +908,11 @@ export function OrderWizard({
|
|||||||
<p className="text-slate-400 text-xs">{cat.description}</p>
|
<p className="text-slate-400 text-xs">{cat.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<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
|
<Check className="w-3 h-3 mr-1" /> Ausgewählt
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -846,6 +931,53 @@ export function OrderWizard({
|
|||||||
<p className="text-slate-500 text-sm italic">
|
<p className="text-slate-500 text-sm italic">
|
||||||
Keine Produkte in dieser Kategorie im aktuellen Abrechnungsmodell vorhanden.
|
Keine Produkte in dieser Kategorie im aktuellen Abrechnungsmodell vorhanden.
|
||||||
</p>
|
</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
|
<RadioGroup
|
||||||
value={sel?.productId ?? ''}
|
value={sel?.productId ?? ''}
|
||||||
@@ -984,8 +1116,18 @@ export function OrderWizard({
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{categories.map(cat => {
|
{categories.map(cat => {
|
||||||
const sel = selections[cat.id]
|
const sel = selections[cat.id]
|
||||||
const prod = products.find(p => p.id === sel?.productId)
|
const selectedProds: Product[] = []
|
||||||
if (!prod) return (
|
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">
|
<div key={cat.id} className="text-xs text-slate-500 italic flex items-center gap-1">
|
||||||
{cat.is_required ? (
|
{cat.is_required ? (
|
||||||
<AlertCircle className="w-3 h-3 text-destructive" />
|
<AlertCircle className="w-3 h-3 text-destructive" />
|
||||||
@@ -995,15 +1137,25 @@ export function OrderWizard({
|
|||||||
{cat.name}: {cat.is_required ? 'nicht gewählt' : 'nicht gewählt (optional)'}
|
{cat.name}: {cat.is_required ? 'nicht gewählt' : 'nicht gewählt (optional)'}
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div key={cat.id} className="space-y-1">
|
<div key={cat.id} className="space-y-1">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-slate-400 font-medium">{cat.name}</span>
|
<span className="text-slate-400 font-medium">{cat.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm pl-2">
|
{sortedProds.map((prod, idx) => {
|
||||||
<span className="text-white">{prod.name}</span>
|
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">
|
<span className="text-white">
|
||||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(prod.base_price)}
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(actualPrice)}
|
||||||
{' '}<span className="text-slate-400 text-xs">{billingLabel(prod.billing_interval)}</span>
|
{' '}<span className="text-slate-400 text-xs">{billingLabel(prod.billing_interval)}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1011,7 +1163,7 @@ export function OrderWizard({
|
|||||||
const mod = prod.modules?.find(m => m.id === mId)
|
const mod = prod.modules?.find(m => m.id === mId)
|
||||||
const qty = moduleQuantities[mId] || 1
|
const qty = moduleQuantities[mId] || 1
|
||||||
return mod ? (
|
return mod ? (
|
||||||
<div key={mId} className="flex justify-between text-xs pl-4">
|
<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">+ {mod.name} {mod.has_quantity ? `(x${qty})` : ''}</span>
|
||||||
<span className="text-slate-300">
|
<span className="text-slate-300">
|
||||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price * qty)}
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price * qty)}
|
||||||
@@ -1022,6 +1174,9 @@ export function OrderWizard({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
<Separator className="bg-white/10" />
|
<Separator className="bg-white/10" />
|
||||||
{oneTimeTotal > 0 && monthlyTotal > 0 ? (
|
{oneTimeTotal > 0 && monthlyTotal > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -1150,29 +1305,53 @@ export function OrderWizard({
|
|||||||
<div className="p-4 rounded-lg bg-white/5 space-y-4">
|
<div className="p-4 rounded-lg bg-white/5 space-y-4">
|
||||||
{categories.map(cat => {
|
{categories.map(cat => {
|
||||||
const sel = selections[cat.id]
|
const sel = selections[cat.id]
|
||||||
const prod = products.find(p => p.id === sel?.productId)
|
const selectedProds: Product[] = []
|
||||||
if (!prod) return null
|
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} 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>
|
||||||
|
{sortedProds.map((prod, idx) => {
|
||||||
|
const isFree = idx < freeLimit
|
||||||
|
const actualPrice = isFree ? 0 : prod.base_price
|
||||||
const catTotal =
|
const catTotal =
|
||||||
Number(prod.base_price) +
|
Number(actualPrice) +
|
||||||
sel.moduleIds.reduce((acc, mId) => {
|
(sel?.moduleIds?.reduce((acc, mId) => {
|
||||||
const mod = prod.modules?.find(m => m.id === mId)
|
const mod = prod.modules?.find(m => m.id === mId)
|
||||||
const qty = moduleQuantities[mId] || 1
|
const qty = moduleQuantities[mId] || 1
|
||||||
return acc + (Number(mod?.price ?? 0) * qty)
|
return acc + (Number(mod?.price ?? 0) * qty)
|
||||||
}, 0)
|
}, 0) || 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={cat.id}>
|
<div key={prod.id} className="pl-4">
|
||||||
<div className="flex justify-between font-bold text-base text-white">
|
<div className="flex justify-between font-bold text-base text-white">
|
||||||
<span className="flex items-center gap-2">
|
<span>
|
||||||
<CategoryIcon icon={cat.icon} className="w-4 h-4 text-primary" />
|
{prod.name} {isFree && <span className="text-[10px] text-green-400 font-semibold">(Frei)</span>}
|
||||||
{prod.name}
|
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(catTotal)}
|
{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 className="text-sm font-normal text-slate-400">{billingLabel(prod.billing_interval)}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{sel.moduleIds.length > 0 && (
|
{sel?.moduleIds && sel.moduleIds.length > 0 && (
|
||||||
<p className="text-sm text-slate-300 mt-1 pl-6">
|
<p className="text-sm text-slate-300 mt-1">
|
||||||
Module: {sel.moduleIds
|
Module: {sel.moduleIds
|
||||||
.map(id => {
|
.map(id => {
|
||||||
const mod = prod.modules?.find(m => m.id === id)
|
const mod = prod.modules?.find(m => m.id === id)
|
||||||
@@ -1186,6 +1365,9 @@ export function OrderWizard({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
<Separator className="bg-white/10" />
|
<Separator className="bg-white/10" />
|
||||||
<div className="text-sm text-slate-300 space-y-1">
|
<div className="text-sm text-slate-300 space-y-1">
|
||||||
{selectedEndCustomer ? (
|
{selectedEndCustomer ? (
|
||||||
|
|||||||
@@ -7,21 +7,33 @@ export function validateWizardSelections(
|
|||||||
): void {
|
): void {
|
||||||
for (const cat of dbCategories) {
|
for (const cat of dbCategories) {
|
||||||
const sel = selections[cat.id]
|
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.`)
|
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 (sel) {
|
||||||
if (!prod) {
|
const selectedProds: Product[] = []
|
||||||
throw new Error(`Das ausgewählte Produkt für Kategorie "${cat.name}" wurde nicht gefunden.`)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const prod of selectedProds) {
|
||||||
// Validate selected modules
|
// Validate selected modules
|
||||||
for (const mId of sel.moduleIds) {
|
for (const mId of sel.moduleIds) {
|
||||||
const mod = prod.modules?.find(m => m.id === mId)
|
const mod = prod.modules?.find(m => m.id === mId)
|
||||||
if (!mod) {
|
if (!mod) continue // module might belong to another selected product in the same category, so skip error if it's not this one's
|
||||||
throw new Error(`Das Modul mit ID "${mId}" gehört nicht zum Produkt "${prod.name}".`)
|
|
||||||
}
|
|
||||||
// Requirements check
|
// Requirements check
|
||||||
if (mod.requirements && mod.requirements.length > 0) {
|
if (mod.requirements && mod.requirements.length > 0) {
|
||||||
const hasMetRequirement = mod.requirements.some(reqId => sel.moduleIds.includes(reqId))
|
const hasMetRequirement = mod.requirements.some(reqId => sel.moduleIds.includes(reqId))
|
||||||
@@ -39,11 +51,19 @@ export function validateWizardSelections(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Product-level constraints validation
|
// Product-level constraints validation
|
||||||
const selectedProductIds = Object.values(selections)
|
const selectedProductIds: string[] = []
|
||||||
.map(s => s.productId)
|
Object.values(selections).forEach(s => {
|
||||||
.filter((id): id is string => !!id)
|
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) {
|
for (const prodId of selectedProductIds) {
|
||||||
const prod = dbProducts.find(p => p.id === prodId)
|
const prod = dbProducts.find(p => p.id === prodId)
|
||||||
|
|||||||
@@ -99,11 +99,26 @@ export function buildOrderSnapshot(
|
|||||||
|
|
||||||
for (const cat of categories) {
|
for (const cat of categories) {
|
||||||
const sel: CategorySelection | undefined = selections[cat.id]
|
const sel: CategorySelection | undefined = selections[cat.id]
|
||||||
if (!sel?.productId) continue
|
if (!sel) continue
|
||||||
|
|
||||||
const prod = products.find((p) => p.id === sel.productId)
|
const selectedProds: Product[] = []
|
||||||
if (!prod) continue
|
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) => {
|
||||||
dominantTaxRate = prod.tax_rate
|
dominantTaxRate = prod.tax_rate
|
||||||
|
|
||||||
const selectedModules = sel.moduleIds
|
const selectedModules = sel.moduleIds
|
||||||
@@ -121,19 +136,23 @@ export function buildOrderSnapshot(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const moduleTotal = selectedModules.reduce((acc, m) => acc + (m.total_price || m.price), 0)
|
const moduleTotal = selectedModules.reduce((acc, m) => acc + (m.total_price || m.price), 0)
|
||||||
const itemTotal = prod.base_price + moduleTotal
|
|
||||||
|
// 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
|
subtotal += itemTotal
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
category_id: cat.id,
|
category_id: cat.id,
|
||||||
category_name: cat.name,
|
category_name: cat.name,
|
||||||
product_id: prod.id,
|
product_id: prod.id,
|
||||||
product_name: prod.name,
|
product_name: prod.name + (actualBasePrice === 0 ? " (Inklusive/Frei)" : ""),
|
||||||
base_price: prod.base_price,
|
base_price: actualBasePrice,
|
||||||
billing_interval: prod.billing_interval,
|
billing_interval: prod.billing_interval,
|
||||||
selected_modules: selectedModules,
|
selected_modules: selectedModules,
|
||||||
item_total: itemTotal,
|
item_total: itemTotal,
|
||||||
})
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wenn billingInterval übergeben, dieses nutzen; sonst automatisch ermitteln
|
// Wenn billingInterval übergeben, dieses nutzen; sonst automatisch ermitteln
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export type Category = {
|
|||||||
icon?: string | null
|
icon?: string | null
|
||||||
sort_order: number
|
sort_order: number
|
||||||
is_required: boolean
|
is_required: boolean
|
||||||
|
allow_multiselect: boolean
|
||||||
|
free_items_limit: number
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +81,8 @@ export type EndCustomer = {
|
|||||||
// ─── Wizard-Zustand (flüchtig, im RAM) ───────────────────────────────────────
|
// ─── Wizard-Zustand (flüchtig, im RAM) ───────────────────────────────────────
|
||||||
|
|
||||||
export type CategorySelection = {
|
export type CategorySelection = {
|
||||||
productId: string | null
|
productId: string | null // for backwards compatibility & single select
|
||||||
|
productIds?: string[] // for multi-select categories
|
||||||
moduleIds: string[]
|
moduleIds: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,28 +24,7 @@ CREATE POLICY "Partner sehen nur eigene Endkunden" ON public.end_customers
|
|||||||
partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid())
|
partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid())
|
||||||
);
|
);
|
||||||
|
|
||||||
-- 4. Recreate RLS policies for licenses (since partner_id now matches company_id)
|
-- 4. Licenses policy recreation is omitted since licenses table is dropped.
|
||||||
DROP POLICY IF EXISTS "Partner sehen Lizenzen eigener Kunden" ON public.licenses;
|
|
||||||
CREATE POLICY "Partner sehen Lizenzen eigener Kunden" ON public.licenses
|
|
||||||
FOR SELECT
|
|
||||||
USING (
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM public.end_customers c
|
|
||||||
WHERE c.id = licenses.end_customer_id
|
|
||||||
AND c.partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid())
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS "Partner erstellen Lizenzen eigener Kunden" ON public.licenses;
|
|
||||||
CREATE POLICY "Partner erstellen Lizenzen eigener Kunden" ON public.licenses
|
|
||||||
FOR INSERT
|
|
||||||
WITH CHECK (
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM public.end_customers c
|
|
||||||
WHERE c.id = licenses.end_customer_id
|
|
||||||
AND c.partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid())
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Reload Schema Cache
|
-- Reload Schema Cache
|
||||||
NOTIFY pgrst, 'reload schema';
|
NOTIFY pgrst, 'reload schema';
|
||||||
|
|||||||
@@ -33,31 +33,7 @@ CREATE POLICY "Partner sehen nur eigene Endkunden" ON public.end_customers
|
|||||||
OR partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid())
|
OR partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid())
|
||||||
);
|
);
|
||||||
|
|
||||||
-- 4. Recreate licenses SELECT RLS policy to allow admin bypass
|
-- 4 & 5. Licenses policy updates are omitted since licenses table is dropped.
|
||||||
DROP POLICY IF EXISTS "Partner sehen Lizenzen eigener Kunden" ON public.licenses;
|
|
||||||
CREATE POLICY "Partner sehen Lizenzen eigener Kunden" ON public.licenses
|
|
||||||
FOR SELECT
|
|
||||||
USING (
|
|
||||||
(SELECT role FROM public.users WHERE id = auth.uid()) = 'admin'
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1 FROM public.end_customers c
|
|
||||||
WHERE c.id = licenses.end_customer_id
|
|
||||||
AND c.partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid())
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 5. Recreate licenses INSERT RLS policy to allow admin bypass
|
|
||||||
DROP POLICY IF EXISTS "Partner erstellen Lizenzen eigener Kunden" ON public.licenses;
|
|
||||||
CREATE POLICY "Partner erstellen Lizenzen eigener Kunden" ON public.licenses
|
|
||||||
FOR INSERT
|
|
||||||
WITH CHECK (
|
|
||||||
(SELECT role FROM public.users WHERE id = auth.uid()) = 'admin'
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1 FROM public.end_customers c
|
|
||||||
WHERE c.id = licenses.end_customer_id
|
|
||||||
AND c.partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid())
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Reload Schema Cache
|
-- Reload Schema Cache
|
||||||
NOTIFY pgrst, 'reload schema';
|
NOTIFY pgrst, 'reload schema';
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Add multi-select and free items limit to categories table
|
||||||
|
ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS allow_multiselect BOOLEAN DEFAULT false NOT NULL;
|
||||||
|
ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS free_items_limit INTEGER DEFAULT 0 NOT NULL;
|
||||||
Reference in New Issue
Block a user