feat: Add product dependencies (requirements and exclusions) to database, admin UI and wizard validation
All checks were successful
Staging Build / build (push) Successful in 2m39s

This commit is contained in:
DanielS
2026-06-25 14:38:30 +02:00
parent 5a292a8ddc
commit 47928fad3f
7 changed files with 197 additions and 4 deletions

View File

@@ -39,7 +39,8 @@ async function ProductDataWrapper() {
async function CreateProductAction() { async function CreateProductAction() {
const categories = await getCategories() const categories = await getCategories()
const products = await getProducts()
return ( return (
<CreateProductDialog categories={categories} /> <CreateProductDialog categories={categories} products={products} />
) )
} }

View File

@@ -58,15 +58,29 @@ const productSchema = z.object({
billing_interval: z.enum(['one_time', 'monthly']).default('monthly'), billing_interval: z.enum(['one_time', 'monthly']).default('monthly'),
show_in_kauf: z.boolean().default(true), show_in_kauf: z.boolean().default(true),
show_in_abo: 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([]), modules: z.array(moduleSchema).default([]),
}) })
type ProductFormValues = z.infer<typeof productSchema> type ProductFormValues = z.infer<typeof productSchema>
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 [open, setOpen] = useState(false)
const router = useRouter() const router = useRouter()
const otherProducts = products.filter(p => p.id !== product?.id)
const form = useForm<ProductFormValues>({ const form = useForm<ProductFormValues>({
resolver: zodResolver(productSchema) as any, resolver: zodResolver(productSchema) as any,
defaultValues: { defaultValues: {
@@ -77,6 +91,8 @@ export function CreateProductDialog({ children, categories, product }: { childre
billing_interval: product?.billing_interval || 'monthly', billing_interval: product?.billing_interval || 'monthly',
show_in_kauf: product ? (product.show_in_kauf ?? true) : true, show_in_kauf: product ? (product.show_in_kauf ?? true) : true,
show_in_abo: product ? (product.show_in_abo ?? true) : true, show_in_abo: product ? (product.show_in_abo ?? true) : true,
requirements: product?.requirements || [],
exclusions: product?.exclusions || [],
modules: product?.modules?.map(m => ({ modules: product?.modules?.map(m => ({
id: m.id, id: m.id,
name: m.name, name: m.name,
@@ -275,6 +291,100 @@ export function CreateProductDialog({ children, categories, product }: { childre
)} )}
/> />
<div className="grid grid-cols-2 gap-4">
{/* Product Requirements */}
<FormField
control={form.control}
name="requirements"
render={({ field: reqField }) => (
<FormItem className="flex flex-col gap-1.5">
<FormLabel className="text-slate-900 dark:text-white font-medium">Benötigte Artikel</FormLabel>
<ScrollArea className="h-32 border border-slate-200 dark:border-white/10 rounded p-3 bg-slate-50 dark:bg-white/5">
{otherProducts.length === 0 ? (
<p className="text-xs text-slate-500 italic">Keine anderen Artikel vorhanden.</p>
) : (
<div className="space-y-2">
{otherProducts.map(other => {
const checked = (reqField.value || []).includes(other.id)
return (
<div key={other.id} className="flex items-center space-x-2">
<Checkbox
id={`prod-req-${other.id}`}
checked={checked}
onCheckedChange={(checkedState) => {
const currentVal = reqField.value || []
if (checkedState) {
reqField.onChange([...currentVal, other.id])
} else {
reqField.onChange(currentVal.filter(id => id !== other.id))
}
}}
/>
<label
htmlFor={`prod-req-${other.id}`}
className="text-xs text-slate-700 dark:text-slate-300 cursor-pointer select-none truncate block"
title={other.name}
>
{other.name}
</label>
</div>
)
})}
</div>
)}
</ScrollArea>
<FormMessage />
</FormItem>
)}
/>
{/* Product Exclusions */}
<FormField
control={form.control}
name="exclusions"
render={({ field: exclField }) => (
<FormItem className="flex flex-col gap-1.5">
<FormLabel className="text-slate-900 dark:text-white font-medium">Schließt aus</FormLabel>
<ScrollArea className="h-32 border border-slate-200 dark:border-white/10 rounded p-3 bg-slate-50 dark:bg-white/5">
{otherProducts.length === 0 ? (
<p className="text-xs text-slate-500 italic">Keine anderen Artikel vorhanden.</p>
) : (
<div className="space-y-2">
{otherProducts.map(other => {
const checked = (exclField.value || []).includes(other.id)
return (
<div key={other.id} className="flex items-center space-x-2">
<Checkbox
id={`prod-excl-${other.id}`}
checked={checked}
onCheckedChange={(checkedState) => {
const currentVal = exclField.value || []
if (checkedState) {
exclField.onChange([...currentVal, other.id])
} else {
exclField.onChange(currentVal.filter(id => id !== other.id))
}
}}
/>
<label
htmlFor={`prod-excl-${other.id}`}
className="text-xs text-slate-700 dark:text-slate-300 cursor-pointer select-none truncate block"
title={other.name}
>
{other.name}
</label>
</div>
)
})}
</div>
)}
</ScrollArea>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator className="bg-slate-200 dark:bg-white/10" /> <Separator className="bg-slate-200 dark:bg-white/10" />
<div className="space-y-4"> <div className="space-y-4">

View File

@@ -154,7 +154,7 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
> >
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
</Button> </Button>
<CreateProductDialog categories={categories} product={product}> <CreateProductDialog categories={categories} product={product} products={products}>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"> <Button variant="ghost" size="icon" className="h-8 w-8 text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
<Edit2 className="h-4 w-4" /> <Edit2 className="h-4 w-4" />
</Button> </Button>

View File

@@ -139,6 +139,45 @@ export function OrderWizard({
}) })
}, [categories, selections, products, selectedBillingInterval]) }, [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) // Total price calculations (split by billing interval)
const { monthlyTotal, oneTimeTotal } = useMemo(() => { const { monthlyTotal, oneTimeTotal } = useMemo(() => {
let monthly = 0 let monthly = 0
@@ -786,12 +825,18 @@ export function OrderWizard({
Bitte aus jeder Pflichtkategorie einen Artikel wählen. Bitte aus jeder Pflichtkategorie einen Artikel wählen.
</p> </p>
)} )}
{productValidationErrors.map((err, errIdx) => (
<p key={errIdx} className="text-xs text-destructive flex items-center gap-1">
<AlertCircle className="w-3 h-3 text-destructive" />
{err}
</p>
))}
</CardContent> </CardContent>
<CardFooter className="flex flex-col gap-2"> <CardFooter className="flex flex-col gap-2">
<Button <Button
className="w-full h-12 text-lg" className="w-full h-12 text-lg"
onClick={nextStep} onClick={nextStep}
disabled={!allCategoriesFilled} disabled={isNextStepDisabled}
> >
Weiter <ChevronRight className="ml-2 w-5 h-5" /> Weiter <ChevronRight className="ml-2 w-5 h-5" />
</Button> </Button>

View File

@@ -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 // 1. Snapshots aufbauen
// Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback) // Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback)
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null) const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null)

View File

@@ -15,6 +15,8 @@ export type Product = {
created_at: string created_at: string
show_in_kauf?: boolean show_in_kauf?: boolean
show_in_abo?: boolean show_in_abo?: boolean
requirements?: string[]
exclusions?: string[]
} }
export type Category = { export type Category = {

View File

@@ -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 '{}';