diff --git a/component-library/src/pages/configurator.tsx b/component-library/src/pages/configurator.tsx index a2f7faf..2b42db7 100644 --- a/component-library/src/pages/configurator.tsx +++ b/component-library/src/pages/configurator.tsx @@ -1,11 +1,10 @@ import * as React from "react" import { Link, useSearchParams } from "react-router-dom" -import { Check, Plus, X } from "lucide-react" +import { Ban, Check, CircleCheck, Euro, Plus, X } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card } from "@/components/ui/card" -import { Checkbox } from "@/components/ui/checkbox" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" @@ -22,6 +21,7 @@ import { type RenderAsset, } from "@/components/solenos/site-assets" import { cn } from "@/lib/utils" +import { useAnimatedNumber } from "@/lib/use-animated-number" type Heat = "heizkoerper" | "fussbodenheizung" | "gemischt" | "unbekannt" @@ -49,6 +49,21 @@ const INITIAL_BUILDING: Building = { zimmer: 3, } +/** Grundpreise je Wohnung / Monat – wie in der Klarpreis-Liste. */ +const UVI_MONTHLY = 10.5 +const INSPECTION_MONTHLY = 2.2 +/** RWM-Hardware je Wohnung einmalig – ohne Montage, die eine eigene Position ist. */ +const RWM_ONE_TIME = 30 +/** Montage durch SolenOS: je neuem Melder und je Wohnung mit Messtechnik. */ +const INSTALL_PER_RWM = 10 +const INSTALL_PER_UNIT = 10 + +function formatEuro(value: number) { + return value.toLocaleString("de-DE", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) +} /** * Keeps a partially edited number field empty instead of snapping it to 0, so @@ -167,7 +182,8 @@ function StepCard({ } /** - * Selectable service tile (step 1) — the active option carries a brand border. + * Selectable service tile (step 1) — the whole tile is the toggle, the switch + * mirrors the state. The active option carries a brand border. */ function ServiceOption({ id, @@ -183,48 +199,47 @@ function ServiceOption({ description: string }) { return ( -
-
- onCheckedChange(value === true)} - aria-describedby={`${id}-description ${id}-state`} - className="mt-0.5 size-6" - /> -
-
- - - {checked ? "Enthalten · abwählbar" : "Hinzufügen"} - -
-

+

+ - {description} -

+ {title} +
+ + {checked ? "Enthalten · abwählbar" : "Hinzufügen"} +
+

+ {description} +

-
+ + ) } @@ -281,14 +296,56 @@ function PackageCard({ /> {included ? null : (

- Leistung in Schritt 1 auswählen, um sie in den Leistungsumfang - aufzunehmen. + Leistung in Schritt 1 auswählen, um sie in den Preis aufzunehmen.

)} ) } +/** + * One position of the price indication: figure, cadence and whether the + * position can be passed on to tenants (umlagefähig). Wording follows the + * Klarpreis price list. + */ +type Position = { + label: string + amount: number + unit: string + note?: string + allocatable: boolean + /** A deselected position stays on the card as a faint zero. */ + active: boolean +} + +function PriceRow({ + label, + amount, + unit, + note, + allocatable, + active, +}: Position) { + const shown = useAnimatedNumber(amount) + + return ( +
+

{label}

+

+ ca. {formatEuro(shown)} + {"\u00A0"}€ +

+

{unit}

+ {note ? ( +

{note}

+ ) : null} + + {allocatable ? : } + {allocatable ? "Umlagefähig" : "Nicht umlagefähig"} + +
+ ) +} export default function ConfiguratorPage() { const [searchParams] = useSearchParams() @@ -336,6 +393,77 @@ export default function ConfiguratorPage() { } const totalUnits = buildings.reduce((sum, b) => sum + (b.wohnungen || 0), 0) + const unitWord = totalUnits === 1 ? "Wohnung" : "Wohnungen" + /* + * Melder je Wohnung: einer je Zimmer plus einer im Flur als Rettungsweg + * (DIN 14676). Die Hardware wird je Wohnung abgerechnet, die Montage je + * Gerät; die endgültige Anzahl ergibt sich aus der Detailkonfiguration. + */ + const rwmDevices = rwm + ? Math.round( + buildings.reduce( + (sum, b) => sum + (b.wohnungen || 0) * ((b.zimmer || 0) + 1), + 0 + ) + ) + : 0 + /* UVI und Abrechnung teilen sich die Messtechnik – sie wird einmal montiert. */ + const meteringUnits = uvi || billing ? totalUnits : 0 + const installTotal = + rwmDevices * INSTALL_PER_RWM + meteringUnits * INSTALL_PER_UNIT + + const installNotes: string[] = [] + if (install && rwmDevices > 0) { + installNotes.push( + `${rwmDevices} Rauchwarnmelder × ${formatEuro(INSTALL_PER_RWM)}\u00A0€` + ) + } + if (install && meteringUnits > 0) { + installNotes.push( + `Messtechnik in ${meteringUnits} ${unitWord} × ${formatEuro(INSTALL_PER_UNIT)}\u00A0€` + ) + } + + /* + * Every position stays on the card. A deselected one counts down to a faint + * zero, so a toggle reads as a change in the price instead of a row that + * vanishes. + */ + const positions: Position[] = [ + { + label: "UVI-Komplettbetrieb", + amount: uvi ? UVI_MONTHLY : 0, + unit: "je Wohnung / Monat", + allocatable: true, + active: uvi, + }, + { + label: "Ferninspektion Rauchwarnmelder", + amount: rwm ? INSPECTION_MONTHLY : 0, + unit: "je Wohnung / Monat", + allocatable: true, + active: rwm, + }, + { + label: "Rauchwarnmelder-Geräte", + /* Hardware is billed once per Wohnung – the estimate is the sum. */ + amount: rwm ? RWM_ONE_TIME * totalUnits : 0, + unit: "einmalig gesamt", + note: rwm + ? `${totalUnits} ${unitWord} × ${formatEuro(RWM_ONE_TIME)}\u00A0€` + : undefined, + allocatable: false, + active: rwm, + }, + { + label: "Montage durch SolenOS", + amount: install ? installTotal : 0, + unit: "einmalig gesamt", + note: installNotes.length > 0 ? installNotes.join(" · ") : undefined, + allocatable: false, + active: install && installTotal > 0, + }, + ] return ( @@ -350,7 +478,7 @@ export default function ConfiguratorPage() { />
@@ -382,22 +510,22 @@ export default function ConfiguratorPage() { /> -
- + {install ? "SolenOS montiert" : "Selbstmontage"} @@ -407,7 +535,7 @@ export default function ConfiguratorPage() { className="mt-1 text-sm text-muted-foreground" > {install - ? "SolenOS übernimmt Montage und Softwarekonfiguration. Der geprüfte Gesamtpreis folgt mit dem verbindlichen Angebot." + ? "SolenOS übernimmt Montage und Softwarekonfiguration – die Montage erscheint als eigene Position." : "Sie, Ihr Hausmeister oder Ihr Fachbetrieb montieren. Eine Softwarekonfiguration vor Ort ist nicht erforderlich."}

@@ -415,10 +543,11 @@ export default function ConfiguratorPage() { id="installation" checked={install} onCheckedChange={setInstall} + aria-labelledby="installation-title" aria-describedby="installation-description" className="mt-0.5" /> -
+ @@ -599,105 +728,52 @@ export default function ConfiguratorPage() {
- {/* Right column: sticky configuration summary */} + {/* Right column: sticky price indication */}

- Ihre Konfiguration + Geschätzter Preis

- +

- {buildings.length} Gebäude · {totalUnits}{" "} - {totalUnits === 1 ? "Wohnung" : "Wohnungen"} + {buildings.length} Gebäude · {totalUnits} {unitWord}

-
-

- Gewählte Leistungen +

+ {positions.map((position, index) => ( + + {index > 0 ? : null} + + + ))} +
+
+ {billing ? ( +

+ Die Heizkostenabrechnung wird mit dem verbindlichen + Angebot bepreist. +

+ ) : null} +

+ Die erste Berechnung ist eine unverbindliche + Preisindikation. Der genaue Preis ergibt sich aus der + vollständigen Objektkonfiguration.

-
    -
  • - {uvi ? ( - - ) : ( - - )} - - Monatliche Verbrauchsinformation - {uvi ? " – enthalten" : " – nicht enthalten"} - -
  • -
  • - {billing ? ( - - ) : ( - - )} - - Heizkostenabrechnung - {billing ? " – enthalten" : " – nicht enthalten"} - -
  • -
  • - {rwm ? ( - - ) : ( - - )} - - Rauchwarnmelder - {rwm ? " – enthalten" : " – nicht enthalten"} - -
  • -
  • - - - {install - ? "Montage durch SolenOS" - : "Montage durch Sie oder Ihren Fachbetrieb"} - -
  • -
- -

- Geprüfter Gesamtpreis nach Detailkonfiguration -

-

- Wir veröffentlichen keine ungeprüften Preiswerte. Nach der - manuellen Prüfung erhalten Sie ein verbindliches - Gesamtangebot mit der erforderlichen Gateway-Infrastruktur, - den gewählten Leistungen und der gewählten Montagevariante. -

- {/* Vollständiger Leistungsumfang des späteren Gesamtangebots */} + {/* Was ist in dem Preis enthalten? */}
- +