Compare commits

..

2 Commits

Author SHA1 Message Date
DanielS
13981970f5 docs: document frontend top-down cascade and visibility guarantee
All checks were successful
Staging Build / build (push) Successful in 2m46s
2026-07-09 22:04:14 +02:00
DanielS
8dd5fd3f44 fix: enforce top-down state cascade and prevent base product hiding 2026-07-09 22:04:10 +02:00
2 changed files with 19 additions and 17 deletions

View File

@@ -30,7 +30,8 @@ graph TD
- **Abhängigkeiten (Requirements)**: Ein Modul oder Produkt kann andere Module/Produkte voraussetzen (z.B. Modul B erfordert Modul A). Das System prüft dies client- und serverseitig. - **Abhängigkeiten (Requirements)**: Ein Modul oder Produkt kann andere Module/Produkte voraussetzen (z.B. Modul B erfordert Modul A). Das System prüft dies client- und serverseitig.
- **Top-Down State-Management**: - **Top-Down State-Management**:
- Die Wahl eines Basis-Produkts steuert die Sichtbarkeit und Wählbarkeit aller Module (Top-Down). Modul-Auswahlen dürfen niemals die Liste der wählbaren Basis-Produkte beeinflussen (Verhinderung von UI-Deadlocks). - Die Wahl eines Basis-Produkts steuert die Sichtbarkeit und Wählbarkeit aller Module (Top-Down). Modul-Auswahlen dürfen niemals die Liste der wählbaren Basis-Produkte beeinflussen (Verhinderung von UI-Deadlocks).
- **Auto-Reset**: Beim Wechsel des Basis-Produkts über `handleBaseProductChange` werden automatisch alle ausgewählten Module entfernt, die durch `global_exclusions` für das neue Produkt ausgeschlossen sind. - **Auto-Reset & Kaskaden-Bereinigung**: Beim Wechsel des Basis-Produkts in `selectProduct` werden inkompatible Module in anderen Kategorien automatisch über eine Kaskaden-Bereinigung (Filterung der `moduleIds` basierend auf den `exclusions` des neuen Produkts) deselektiert.
- **Sichtbarkeits-Garantie**: Basis-Produkte werden im UI niemals durch Modulausschlüsse ausgeblendet (Vermeidung von UI-Deadlocks). Stattdessen werden inkompatible Moduloptionen im JSX deaktiviert (`disabled`).
- **Radio-Button-Verhalten (allow_multiselect = false)**: Falls eine Kategorie keine Mehrfachauswahl erlaubt, wird bei Auswahl eines neuen Moduls das zuvor ausgewählte Modul dieser Kategorie automatisch abgewählt. - **Radio-Button-Verhalten (allow_multiselect = false)**: Falls eine Kategorie keine Mehrfachauswahl erlaubt, wird bei Auswahl eines neuen Moduls das zuvor ausgewählte Modul dieser Kategorie automatisch abgewählt.
- **Snapshot-Architektur**: Sobald eine Bestellung aufgegeben wird, werden die Kundendaten (`CustomerSnapshot`) und Produktkonfigurationen (`OrderSnapshot` samt Preisen und Modulversionen) in `orders` als JSONB-Snapshots eingefroren. Preisänderungen im Katalog haben keinen Einfluss auf bestehende Bestellungen. - **Snapshot-Architektur**: Sobald eine Bestellung aufgegeben wird, werden die Kundendaten (`CustomerSnapshot`) und Produktkonfigurationen (`OrderSnapshot` samt Preisen und Modulversionen) in `orders` als JSONB-Snapshots eingefroren. Preisänderungen im Katalog haben keinen Einfluss auf bestehende Bestellungen.

View File

@@ -390,6 +390,20 @@ export function OrderWizard({
[catId]: { productId: nextProductId, productIds: nextProductIds, moduleIds: [] }, [catId]: { productId: nextProductId, productIds: nextProductIds, moduleIds: [] },
} }
// Kaskaden-Bereinigung: Entferne inkompatible Module in anderen Kategorien
const newProduct = products.find(p => p.id === nextProductId)
categories.forEach(c => {
if (c.id === catId) return
const s = next[c.id]
if (s && s.moduleIds.length > 0) {
s.moduleIds = s.moduleIds.filter(mId => {
// Prüfe ob das neue Basis-Produkt dieses Modul ausschließt
const isExcluded = newProduct?.exclusions?.includes(mId)
return !isExcluded
})
}
})
if (shouldReset) { if (shouldReset) {
// Reset all other categories // Reset all other categories
categories.forEach(c => { categories.forEach(c => {
@@ -429,7 +443,7 @@ export function OrderWizard({
next[c.id] = { next[c.id] = {
productId: validProductIds[0] || null, productId: validProductIds[0] || null,
productIds: validProductIds, productIds: validProductIds,
moduleIds: [] moduleIds: s.moduleIds
} }
} else if (s?.productId) { } else if (s?.productId) {
const prod = products.find(p => p.id === s.productId) const prod = products.find(p => p.id === s.productId)
@@ -902,27 +916,14 @@ export function OrderWizard({
.map(([, s]) => s.productId) .map(([, s]) => s.productId)
.filter((id): id is string => !!id) .filter((id): id is string => !!id)
// Filter products based on display flags and mutual exclusions // Filter products based on display flags (do not hide due to exclusions to prevent UI deadlocks)
const catProducts = products.filter(p => { const catProducts = products.filter(p => {
if (p.category_id !== cat.id) return false if (p.category_id !== cat.id) return false
const matchesInterval = selectedBillingInterval === 'one_time' const matchesInterval = selectedBillingInterval === 'one_time'
? p.show_in_kauf !== false ? p.show_in_kauf !== false
: p.show_in_abo !== false : p.show_in_abo !== false
if (!matchesInterval) return false return matchesInterval
// Check if p is excluded by any other selected product
const isExcludedByOthers = otherSelectedProductIds.some(otherId => {
const otherProd = products.find(prod => prod.id === otherId)
return otherProd?.exclusions?.includes(p.id)
})
if (isExcludedByOthers) return false
// Check if p excludes any other selected product
const excludesOthers = p.exclusions?.some(exId => otherSelectedProductIds.includes(exId))
if (excludesOthers) return false
return true
}) })
const sel = selections[cat.id] const sel = selections[cat.id]
const selectedProduct = catProducts.find(p => p.id === sel?.productId) ?? null const selectedProduct = catProducts.find(p => p.id === sel?.productId) ?? null