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() {
|
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} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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