diff --git a/shop/app/admin/products/page.tsx b/shop/app/admin/products/page.tsx index b0f28af..407a481 100644 --- a/shop/app/admin/products/page.tsx +++ b/shop/app/admin/products/page.tsx @@ -39,7 +39,8 @@ async function ProductDataWrapper() { async function CreateProductAction() { const categories = await getCategories() + const products = await getProducts() return ( - + ) } diff --git a/shop/components/admin/create-product-dialog.tsx b/shop/components/admin/create-product-dialog.tsx index 1947a51..55ad437 100644 --- a/shop/components/admin/create-product-dialog.tsx +++ b/shop/components/admin/create-product-dialog.tsx @@ -58,15 +58,29 @@ const productSchema = z.object({ billing_interval: z.enum(['one_time', 'monthly']).default('monthly'), show_in_kauf: z.boolean().default(true), show_in_abo: z.boolean().default(true), + requirements: z.array(z.string()).default([]), + exclusions: z.array(z.string()).default([]), modules: z.array(moduleSchema).default([]), }) type ProductFormValues = z.infer -export function CreateProductDialog({ children, categories, product }: { children?: React.ReactNode, categories: Category[], product?: Product }) { +export function CreateProductDialog({ + children, + categories, + product, + products = [] +}: { + children?: React.ReactNode + categories: Category[] + product?: Product + products?: Product[] +}) { const [open, setOpen] = useState(false) const router = useRouter() + const otherProducts = products.filter(p => p.id !== product?.id) + const form = useForm({ resolver: zodResolver(productSchema) as any, defaultValues: { @@ -77,6 +91,8 @@ export function CreateProductDialog({ children, categories, product }: { childre billing_interval: product?.billing_interval || 'monthly', show_in_kauf: product ? (product.show_in_kauf ?? true) : true, show_in_abo: product ? (product.show_in_abo ?? true) : true, + requirements: product?.requirements || [], + exclusions: product?.exclusions || [], modules: product?.modules?.map(m => ({ id: m.id, name: m.name, @@ -275,6 +291,100 @@ export function CreateProductDialog({ children, categories, product }: { childre )} /> +
+ {/* Product Requirements */} + ( + + Benötigte Artikel + + {otherProducts.length === 0 ? ( +

Keine anderen Artikel vorhanden.

+ ) : ( +
+ {otherProducts.map(other => { + const checked = (reqField.value || []).includes(other.id) + return ( +
+ { + const currentVal = reqField.value || [] + if (checkedState) { + reqField.onChange([...currentVal, other.id]) + } else { + reqField.onChange(currentVal.filter(id => id !== other.id)) + } + }} + /> + +
+ ) + })} +
+ )} +
+ +
+ )} + /> + + {/* Product Exclusions */} + ( + + Schließt aus + + {otherProducts.length === 0 ? ( +

Keine anderen Artikel vorhanden.

+ ) : ( +
+ {otherProducts.map(other => { + const checked = (exclField.value || []).includes(other.id) + return ( +
+ { + const currentVal = exclField.value || [] + if (checkedState) { + exclField.onChange([...currentVal, other.id]) + } else { + exclField.onChange(currentVal.filter(id => id !== other.id)) + } + }} + /> + +
+ ) + })} +
+ )} +
+ +
+ )} + /> +
+
diff --git a/shop/components/admin/product-list.tsx b/shop/components/admin/product-list.tsx index 3f5facf..96ded1f 100644 --- a/shop/components/admin/product-list.tsx +++ b/shop/components/admin/product-list.tsx @@ -154,7 +154,7 @@ export function ProductList({ initialProducts, categories }: { initialProducts: > - + diff --git a/shop/components/order-wizard.tsx b/shop/components/order-wizard.tsx index 7631989..68196b3 100644 --- a/shop/components/order-wizard.tsx +++ b/shop/components/order-wizard.tsx @@ -139,6 +139,45 @@ export function OrderWizard({ }) }, [categories, selections, products, selectedBillingInterval]) + // 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) + + selectedProductIds.forEach(prodId => { + const prod = products.find(p => p.id === prodId) + if (!prod) return + + // Requirements check + if (prod.requirements && prod.requirements.length > 0) { + const missing = prod.requirements.filter(reqId => !selectedProductIds.includes(reqId)) + if (missing.length > 0) { + const names = missing + .map(reqId => products.find(p => p.id === reqId)?.name || reqId) + .join(', ') + errors.push(`"${prod.name}" benötigt das Produkt: ${names}`) + } + } + + // Exclusions check + if (prod.exclusions && prod.exclusions.length > 0) { + const conflicting = prod.exclusions.filter(exId => selectedProductIds.includes(exId)) + if (conflicting.length > 0) { + const names = conflicting + .map(exId => products.find(p => p.id === exId)?.name || exId) + .join(', ') + errors.push(`"${prod.name}" schließt aus: ${names}`) + } + } + }) + + return errors + }, [selections, products]) + + const isNextStepDisabled = !allCategoriesFilled || productValidationErrors.length > 0 + // Total price calculations (split by billing interval) const { monthlyTotal, oneTimeTotal } = useMemo(() => { let monthly = 0 @@ -786,12 +825,18 @@ export function OrderWizard({ Bitte aus jeder Pflichtkategorie einen Artikel wählen.

)} + {productValidationErrors.map((err, errIdx) => ( +

+ + {err} +

+ ))} diff --git a/shop/lib/actions/orders.ts b/shop/lib/actions/orders.ts index 76cce69..3ea1468 100644 --- a/shop/lib/actions/orders.ts +++ b/shop/lib/actions/orders.ts @@ -104,6 +104,38 @@ export async function submitOrder(params: { } } + // Product-level constraints validation + const selectedProductIds = Object.values(selections) + .map(s => s.productId) + .filter((id): id is string => !!id) + + for (const prodId of selectedProductIds) { + const prod = dbProducts.find(p => p.id === prodId) + if (!prod) continue + + // Product requirements check + if (prod.requirements && prod.requirements.length > 0) { + const missing = prod.requirements.filter(reqId => !selectedProductIds.includes(reqId)) + if (missing.length > 0) { + const missingNames = missing + .map(reqId => dbProducts.find(p => p.id === reqId)?.name || reqId) + .join(', ') + throw new Error(`Das Produkt "${prod.name}" erfordert die Auswahl von: ${missingNames}.`) + } + } + + // Product exclusions check + if (prod.exclusions && prod.exclusions.length > 0) { + const conflicting = prod.exclusions.filter(exId => selectedProductIds.includes(exId)) + if (conflicting.length > 0) { + const conflictingNames = conflicting + .map(exId => dbProducts.find(p => p.id === exId)?.name || exId) + .join(', ') + throw new Error(`Das Produkt "${prod.name}" schließt die gleichzeitige Auswahl von folgenden Produkten aus: ${conflictingNames}.`) + } + } + } + // 1. Snapshots aufbauen // Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback) const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null) diff --git a/shop/lib/types.ts b/shop/lib/types.ts index c05b35d..d51fb05 100644 --- a/shop/lib/types.ts +++ b/shop/lib/types.ts @@ -15,6 +15,8 @@ export type Product = { created_at: string show_in_kauf?: boolean show_in_abo?: boolean + requirements?: string[] + exclusions?: string[] } export type Category = { diff --git a/shop/supabase/migrations/20260625143000_add_dependencies_to_products.sql b/shop/supabase/migrations/20260625143000_add_dependencies_to_products.sql new file mode 100644 index 0000000..cfb1c50 --- /dev/null +++ b/shop/supabase/migrations/20260625143000_add_dependencies_to_products.sql @@ -0,0 +1,3 @@ +-- Add requirements and exclusions columns to products table +ALTER TABLE public.products ADD COLUMN IF NOT EXISTS requirements UUID[] DEFAULT '{}'; +ALTER TABLE public.products ADD COLUMN IF NOT EXISTS exclusions UUID[] DEFAULT '{}';