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
All checks were successful
Staging Build / build (push) Successful in 2m39s
This commit is contained in:
@@ -39,7 +39,8 @@ async function ProductDataWrapper() {
|
||||
|
||||
async function CreateProductAction() {
|
||||
const categories = await getCategories()
|
||||
const products = await getProducts()
|
||||
return (
|
||||
<CreateProductDialog categories={categories} />
|
||||
<CreateProductDialog categories={categories} products={products} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<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 router = useRouter()
|
||||
|
||||
const otherProducts = products.filter(p => p.id !== product?.id)
|
||||
|
||||
const form = useForm<ProductFormValues>({
|
||||
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
|
||||
)}
|
||||
/>
|
||||
|
||||
<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" />
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -154,7 +154,7 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</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">
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -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.
|
||||
</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>
|
||||
<CardFooter className="flex flex-col gap-2">
|
||||
<Button
|
||||
className="w-full h-12 text-lg"
|
||||
onClick={nextStep}
|
||||
disabled={!allCategoriesFilled}
|
||||
disabled={isNextStepDisabled}
|
||||
>
|
||||
Weiter <ChevronRight className="ml-2 w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 '{}';
|
||||
Reference in New Issue
Block a user