Compare commits
2 Commits
17d76f55e6
...
43e6a92663
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43e6a92663 | ||
|
|
305671acc6 |
@@ -8,6 +8,8 @@ import { renderToBuffer } from '@react-pdf/renderer';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { InvoicePDF } from '@/components/invoice-pdf';
|
import { InvoicePDF } from '@/components/invoice-pdf';
|
||||||
import { getOrderEmailTemplate, buildEmailItemsSection } from '@/lib/actions/email-templates';
|
import { getOrderEmailTemplate, buildEmailItemsSection } from '@/lib/actions/email-templates';
|
||||||
|
import { validateWizardSelections } from '@/lib/actions/validation';
|
||||||
|
|
||||||
|
|
||||||
function generateOrderNumber(prefix: 'BE' | 'AE' = 'AE'): string {
|
function generateOrderNumber(prefix: 'BE' | 'AE' = 'AE'): string {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
@@ -49,6 +51,13 @@ export async function POST(request: Request) {
|
|||||||
const products = await getProducts();
|
const products = await getProducts();
|
||||||
const categories = await getCategories();
|
const categories = await getCategories();
|
||||||
|
|
||||||
|
// --- Backend-Validierung: alle Kategorie-Flags für jede Kasse prüfen ---
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.selections) {
|
||||||
|
validateWizardSelections(item.selections, products, categories);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Split items into purchase and subscription
|
// Split items into purchase and subscription
|
||||||
const purchaseItems = items.filter(
|
const purchaseItems = items.filter(
|
||||||
(item: any) => item.billingInterval === 'one_time' || item.billingType === 'purchase'
|
(item: any) => item.billingInterval === 'one_time' || item.billingType === 'purchase'
|
||||||
|
|||||||
@@ -593,31 +593,54 @@ export function OrderWizard({
|
|||||||
function selectProduct(catId: string, productId: string) {
|
function selectProduct(catId: string, productId: string) {
|
||||||
setSelections(prev => {
|
setSelections(prev => {
|
||||||
const cat = categories.find(c => c.id === catId)
|
const cat = categories.find(c => c.id === catId)
|
||||||
|
|
||||||
|
// Build updated selection for this category
|
||||||
|
let updatedCatSel: CategorySelection
|
||||||
if (cat?.allow_multiselect) {
|
if (cat?.allow_multiselect) {
|
||||||
const currentIds = prev[catId]?.productIds || []
|
const currentIds = prev[catId]?.productIds || []
|
||||||
const nextIds = currentIds.includes(productId)
|
const nextIds = currentIds.includes(productId)
|
||||||
? currentIds.filter(id => id !== productId)
|
? currentIds.filter(id => id !== productId)
|
||||||
: [...currentIds, productId]
|
: [...currentIds, productId]
|
||||||
return {
|
updatedCatSel = {
|
||||||
...prev,
|
productId: nextIds[0] || null,
|
||||||
[catId]: {
|
productIds: nextIds,
|
||||||
productId: nextIds[0] || null,
|
moduleIds: prev[catId]?.moduleIds || []
|
||||||
productIds: nextIds,
|
|
||||||
moduleIds: prev[catId]?.moduleIds || []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const isCurrentlyChecked = prev[catId]?.productId === productId
|
const isCurrentlyChecked = prev[catId]?.productId === productId
|
||||||
const nextId = isCurrentlyChecked ? null : productId
|
const nextId = isCurrentlyChecked ? null : productId
|
||||||
return {
|
updatedCatSel = {
|
||||||
...prev,
|
productId: nextId,
|
||||||
[catId]: {
|
productIds: nextId ? [nextId] : [],
|
||||||
productId: nextId,
|
moduleIds: []
|
||||||
productIds: nextId ? [nextId] : [],
|
|
||||||
moduleIds: []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If this category resets others, rebuild all other categories to their initial state
|
||||||
|
if (cat?.resets_others) {
|
||||||
|
const resetSels: Record<string, CategorySelection> = {}
|
||||||
|
categories.forEach(c => {
|
||||||
|
if (c.id === catId) {
|
||||||
|
resetSels[c.id] = updatedCatSel
|
||||||
|
} else {
|
||||||
|
const isVisible = selectedBillingInterval === 'one_time' ? c.show_in_kauf !== false : c.show_in_abo !== false
|
||||||
|
if (!isVisible || c.allow_multiselect || c.preselect === false) {
|
||||||
|
resetSels[c.id] = { productId: null, productIds: [], moduleIds: [] }
|
||||||
|
} else {
|
||||||
|
const first = products.find(p =>
|
||||||
|
p.category_id === c.id &&
|
||||||
|
(selectedBillingInterval === 'one_time' ? p.show_in_kauf !== false : p.show_in_abo !== false)
|
||||||
|
)
|
||||||
|
resetSels[c.id] = { productId: first?.id ?? null, productIds: first ? [first.id] : [], moduleIds: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Also reset module quantities for other categories
|
||||||
|
setModuleQuantities({})
|
||||||
|
return resetSels
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...prev, [catId]: updatedCatSel }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,16 +17,16 @@ export function ProgressStepper({ step, basketItemsCount }: ProgressStepperProps
|
|||||||
<div
|
<div
|
||||||
className={`w-10 h-10 rounded-full flex items-center justify-center transition-all duration-500 ${
|
className={`w-10 h-10 rounded-full flex items-center justify-center transition-all duration-500 ${
|
||||||
step >= s
|
step >= s
|
||||||
? 'bg-primary text-white scale-110 shadow-[0_0_15px_rgba(59,130,246,0.5)]'
|
? 'bg-blue-600 text-white scale-110 shadow-[0_0_15px_rgba(59,130,246,0.5)]'
|
||||||
: 'bg-slate-800 text-slate-400'
|
: 'bg-slate-800 text-slate-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{step > s ? <Check className="w-5 h-5" /> : s}
|
{step > s ? <Check className="w-5 h-5" /> : s}
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-xs font-semibold ${step >= s ? 'text-primary' : 'text-slate-500'} flex items-center gap-1`}>
|
<span className={`text-xs font-semibold ${step >= s ? 'text-blue-400' : 'text-slate-500'} flex items-center gap-1`}>
|
||||||
{s === 1 ? 'Kunde' : s === 2 ? 'Modell' : s === 3 ? 'Software' : 'Abschluss'}
|
{s === 1 ? 'Kunde' : s === 2 ? 'Modell' : s === 3 ? 'Software' : 'Abschluss'}
|
||||||
{s === 3 && basketItemsCount > 0 && (
|
{s === 3 && basketItemsCount > 0 && (
|
||||||
<span className="bg-primary text-white text-[9px] w-4 h-4 rounded-full flex items-center justify-center font-bold px-1.5 leading-none">
|
<span className="bg-blue-600 text-white text-[9px] w-4 h-4 rounded-full flex items-center justify-center font-bold px-1.5 leading-none">
|
||||||
{basketItemsCount}
|
{basketItemsCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -69,12 +69,12 @@ export function SummarySidebar({
|
|||||||
prevStep,
|
prevStep,
|
||||||
}: SummarySidebarProps) {
|
}: SummarySidebarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="sticky top-1/2 -translate-y-1/2 max-h-[75vh] flex flex-col">
|
||||||
<Card className="glass-dark border-primary/20 sticky top-[20vh] backdrop-blur-lg bg-slate-950/75">
|
<Card className="glass-dark border-primary/20 backdrop-blur-lg bg-slate-950/75 flex flex-col max-h-[75vh]">
|
||||||
<CardHeader>
|
<CardHeader className="shrink-0">
|
||||||
<CardTitle className="text-white">Zusammenfassung</CardTitle>
|
<CardTitle className="text-white">Zusammenfassung</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4 flex-1 overflow-y-auto subpixel-antialiased scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent">
|
||||||
{visibleCategories.map(cat => {
|
{visibleCategories.map(cat => {
|
||||||
const sel = selections[cat.id]
|
const sel = selections[cat.id]
|
||||||
const selectedProds: Product[] = []
|
const selectedProds: Product[] = []
|
||||||
@@ -261,7 +261,7 @@ export function SummarySidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex flex-col gap-3">
|
<CardFooter className="flex flex-col gap-3 shrink-0">
|
||||||
<div className="w-full space-y-2">
|
<div className="w-full space-y-2">
|
||||||
<Label htmlFor="device-name" className="text-xs text-slate-400">Kassenname (optional)</Label>
|
<Label htmlFor="device-name" className="text-xs text-slate-400">Kassenname (optional)</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ 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]
|
||||||
|
|
||||||
|
// --- is_required: Pflichtauswahl prüfen ---
|
||||||
const hasSelection = sel && (
|
const hasSelection = sel && (
|
||||||
(cat.allow_multiselect && sel.productIds && sel.productIds.length > 0) ||
|
(cat.allow_multiselect && sel.productIds && sel.productIds.length > 0) ||
|
||||||
(!cat.allow_multiselect && sel.productId)
|
(!cat.allow_multiselect && sel.productId)
|
||||||
@@ -17,6 +19,13 @@ export function validateWizardSelections(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sel) {
|
if (sel) {
|
||||||
|
// --- allow_multiselect = false: keine doppelten IDs erlaubt ---
|
||||||
|
if (!cat.allow_multiselect && sel.productIds && sel.productIds.length > 1) {
|
||||||
|
throw new Error(
|
||||||
|
`Die Kategorie "${cat.name}" erlaubt keine Mehrfachauswahl. Bitte nur ein Produkt wählen.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const selectedProds: Product[] = []
|
const selectedProds: Product[] = []
|
||||||
if (cat.allow_multiselect && sel.productIds) {
|
if (cat.allow_multiselect && sel.productIds) {
|
||||||
sel.productIds.forEach(pId => {
|
sel.productIds.forEach(pId => {
|
||||||
@@ -32,7 +41,7 @@ export function validateWizardSelections(
|
|||||||
// 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) continue // module might belong to another selected product in the same category, so skip error if it's not this one's
|
if (!mod) continue
|
||||||
|
|
||||||
// Requirements check
|
// Requirements check
|
||||||
if (mod.requirements && mod.requirements.length > 0) {
|
if (mod.requirements && mod.requirements.length > 0) {
|
||||||
@@ -53,7 +62,7 @@ export function validateWizardSelections(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Product-level constraints validation
|
// --- Product-level constraints validation ---
|
||||||
const selectedProductIds: string[] = []
|
const selectedProductIds: string[] = []
|
||||||
Object.values(selections).forEach(s => {
|
Object.values(selections).forEach(s => {
|
||||||
if (s.productIds && s.productIds.length > 0) {
|
if (s.productIds && s.productIds.length > 0) {
|
||||||
@@ -92,3 +101,4 @@ export function validateWizardSelections(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,65 +1,5 @@
|
|||||||
import { Product, ProductModule, Category, WizardSelections } from './types';
|
import { Product, ProductModule, Category, WizardSelections } from './types';
|
||||||
|
|
||||||
export interface GlobalInclusion {
|
|
||||||
trigger_product_id: string;
|
|
||||||
included_module_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GlobalExclusion {
|
|
||||||
product_id: string;
|
|
||||||
excluded_product_id?: string | null;
|
|
||||||
excluded_module_id?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle changes to the base product.
|
|
||||||
* Resets selected modules that are excluded by global exclusions for the new product.
|
|
||||||
* Automatically adds globally included modules for the new product.
|
|
||||||
*/
|
|
||||||
export function handleBaseProductChange(
|
|
||||||
newProductId: string,
|
|
||||||
currentSelections: WizardSelections,
|
|
||||||
modules: ProductModule[],
|
|
||||||
inclusions: GlobalInclusion[],
|
|
||||||
exclusions: GlobalExclusion[]
|
|
||||||
): WizardSelections {
|
|
||||||
const updated: WizardSelections = JSON.parse(JSON.stringify(currentSelections));
|
|
||||||
|
|
||||||
// Find all modules that are excluded for the new product
|
|
||||||
const excludedModuleIds = exclusions
|
|
||||||
.filter(ex => ex.product_id === newProductId && ex.excluded_module_id)
|
|
||||||
.map(ex => ex.excluded_module_id as string);
|
|
||||||
|
|
||||||
// Find all modules that are automatically included for the new product
|
|
||||||
const includedModuleIds = inclusions
|
|
||||||
.filter(inc => inc.trigger_product_id === newProductId)
|
|
||||||
.map(inc => inc.included_module_id);
|
|
||||||
|
|
||||||
// Iterate over categories and filter modules
|
|
||||||
for (const categoryId in updated) {
|
|
||||||
const selection = updated[categoryId];
|
|
||||||
|
|
||||||
// Remove excluded modules
|
|
||||||
selection.moduleIds = selection.moduleIds.filter(
|
|
||||||
mid => !excludedModuleIds.includes(mid)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add automatically included modules if they belong to this category
|
|
||||||
for (const incId of includedModuleIds) {
|
|
||||||
const module = modules.find(m => m.id === incId);
|
|
||||||
if (module && module.product_id === newProductId) {
|
|
||||||
// Find category of module. In our schema, modules might belong to a category.
|
|
||||||
// Assuming we check if module belongs to current category:
|
|
||||||
// We'll append it if the selections is for that category (or we can resolve category from module)
|
|
||||||
// Let's assume we map it by checking module metadata or category_id if available.
|
|
||||||
// For simplicity: if the module is already in the DB and matches, we add it.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle toggling of a module selection.
|
* Handle toggling of a module selection.
|
||||||
* Handles category-specific allow_multiselect rules.
|
* Handles category-specific allow_multiselect rules.
|
||||||
|
|||||||
Reference in New Issue
Block a user