/* Konfigurator Steps 1, 2, 3 — manual rooms / plan import / modules / summary */ const HOME_ROOM_TYPES = [ { type:"living", label:"Wohn/Esszimmer", icon:"🛋", defaultArea:28, defaultWindows:2 }, { type:"kitchen", label:"Küche", icon:"🍳", defaultArea:12, defaultWindows:1 }, { type:"bedroom", label:"Schlafzimmer", icon:"🛏", defaultArea:16, defaultWindows:1 }, { type:"room", label:"Zimmer", icon:"▢", defaultArea:14, defaultWindows:1 }, { type:"bath", label:"Bad", icon:"🚿", defaultArea: 8, defaultWindows:1 }, { type:"wc", label:"WC", icon:"⌬", defaultArea: 2, defaultWindows:0 }, { type:"hall", label:"Vorraum/Flur", icon:"⌷", defaultArea: 9, defaultWindows:0 }, { type:"storage", label:"Speis/Abstell", icon:"▦", defaultArea: 4, defaultWindows:0 }, { type:"tech", label:"Technik/HWR", icon:"⚙", defaultArea: 5, defaultWindows:0 }, { type:"entry", label:"Eingang", icon:"⌂", defaultArea: 4, defaultWindows:0 }, { type:"terrace", label:"Terrasse", icon:"☀", defaultArea:20, defaultWindows:0, outdoor:true }, { type:"office", label:"Büro/Arbeit", icon:"✎", defaultArea:12, defaultWindows:1 }, ]; const GEWERBE_ROOM_TYPES = [ { type:"living", label:"Empfang / Lobby", icon:"⌂", defaultArea:24, defaultWindows:2 }, { type:"office", label:"Büro / Arbeitsplatz", icon:"✎", defaultArea:16, defaultWindows:1 }, { type:"room", label:"Meeting / Seminar", icon:"▢", defaultArea:22, defaultWindows:1 }, { type:"bedroom", label:"Hotelzimmer / Apartment", icon:"▣", defaultArea:20, defaultWindows:1 }, { type:"kitchen", label:"Teeküche / Gastro", icon:"◫", defaultArea:14, defaultWindows:1 }, { type:"hall", label:"Flur / Gang", icon:"⌁", defaultArea:14, defaultWindows:0 }, { type:"entry", label:"Eingang / Zutritt", icon:"⌂", defaultArea:8, defaultWindows:0 }, { type:"tech", label:"Technik / Server", icon:"⚙", defaultArea:8, defaultWindows:0 }, { type:"storage", label:"Lager / Backoffice", icon:"▤", defaultArea:12, defaultWindows:0 }, { type:"wc", label:"WC / Sanitär", icon:"⌬", defaultArea:4, defaultWindows:0 }, { type:"terrace", label:"Außenbereich", icon:"☀", defaultArea:24, defaultWindows:0, outdoor:true }, ]; const ROOM_TYPES = HOME_ROOM_TYPES; function getRoomTypes(pathKey) { return pathKey === "gewerbe" ? GEWERBE_ROOM_TYPES : HOME_ROOM_TYPES; } const FLOORS = ["UG", "EG", "OG", "DG"]; /* ============================================================ STEP 1 — Manual room entry ============================================================ */ function KfgStep1Manual({ pathKey, rooms, setRooms, activeFloor, setActiveFloor, onNext, onBack }) { const floorRooms = rooms.filter(r => r.floor === activeFloor); const isGewerbe = pathKey === "gewerbe"; const roomTypes = getRoomTypes(pathKey); const copy = isGewerbe ? { title: "Bereiche erfassen", intro: "Wähle ein Stockwerk und füge Betriebsbereiche aus der Bibliothek hinzu. Größe und Fenster kannst du anpassen.", library: "Bereichstypen — klicken zum Hinzufügen", panel: "Bereiche", empty: "Noch keine Bereiche in diesem Stockwerk.", emptyHint: "← Wähle einen Bereichstyp aus der Bibliothek", next: "Funktionen wählen →", total: "Bereiche", } : { title: "Räume erfassen", intro: "Wähle ein Stockwerk und füge Räume aus der Bibliothek hinzu. Größe und Fenster kannst du anpassen.", library: "Raumtypen — klicken zum Hinzufügen", panel: "Räume", empty: "Noch keine Räume in diesem Stockwerk.", emptyHint: "← Wähle einen Raumtyp aus der Bibliothek", next: "Module wählen →", total: "Räume", }; const addRoom = (type) => { const def = roomTypes.find(rt => rt.type === type); const id = "r" + Date.now() + Math.random().toString(36).slice(2, 6); const same = rooms.filter(r => r.type === type).length; const name = same > 0 ? `${def.label} ${same+1}` : def.label; setRooms([...rooms, { id, name, type, floor: activeFloor, area: def.defaultArea, windows: def.defaultWindows, outdoor: !!def.outdoor, }]); }; const updateRoom = (id, patch) => { setRooms(rooms.map(r => r.id === id ? { ...r, ...patch } : r)); }; const removeRoom = (id) => { setRooms(rooms.filter(r => r.id !== id)); }; const totalArea = rooms.filter(r => !r.outdoor).reduce((s,r) => s + r.area, 0); return (

{copy.title}

{copy.intro}

{FLOORS.map(f => ( ))}

{copy.library}

{roomTypes.map(rt => ( ))}

{activeFloor} · {copy.panel}

{floorRooms.length} · {floorRooms.filter(r=>!r.outdoor).reduce((s,r)=>s+r.area,0).toFixed(1)} m²
{floorRooms.length === 0 ? (
{copy.empty}
{copy.emptyHint}
) : (
    {floorRooms.map(r => { const def = roomTypes.find(rt => rt.type === r.type) || ROOM_TYPES.find(rt => rt.type === r.type); return (
  • {def?.icon || "▢"} updateRoom(r.id, { name: e.target.value })}/> updateRoom(r.id, { area: parseFloat(e.target.value) || 0 })}/> m²
  • ); })}
)}
{rooms.length} {copy.total} · {totalArea.toFixed(1)} m²
); } /* ============================================================ STEP 1 — Plan import (with animated OCR) ============================================================ */ function KfgStep1Import({ pathKey, onComplete, onBack }) { const isGewerbe = pathKey === "gewerbe"; const [phase, setPhase] = React.useState("drop"); // drop | scanning | review | error const [logs, setLogs] = React.useState([]); const [progress, setProgress] = React.useState(0); const [detected, setDetected] = React.useState([]); // Echte Server-Antwort (token + erkannte rooms) wenn nicht Demo: const [serverResult, setServerResult] = React.useState(null); const [errorMsg, setErrorMsg] = React.useState(null); // Lokal eingelesenes File (data: URL) -- wird waehrend des Scannings als // Hintergrund angezeigt, damit der User direkt sieht was hochgeladen wurde. const [localPreviewUrl, setLocalPreviewUrl] = React.useState(null); const [localPreviewKind, setLocalPreviewKind] = React.useState(null); // "image" | "pdf" const fileInputRef = React.useRef(null); // Demo: hardcoded BASTING_ROOMS (existing path). const startDemo = () => { setPhase("scanning"); setLogs([]); setProgress(0); setDetected([]); setServerResult(null); setErrorMsg(null); const sequence = [ { t: 200, log: { txt: "PDF geladen · EFH Basting 06.03.2020.pdf", cls: "" } }, { t: 600, log: { txt: "Maßstab erkannt: 1:100", cls: "" } }, { t: 900, log: { txt: "Stockwerk identifiziert: Erdgeschoss", cls: "" } }, { t: 1200, log: { txt: "Wandstärken extrahiert: 25/50/12 cm", cls: "" } }, { t: 1500, log: { txt: isGewerbe ? "Beginne Bereichserkennung..." : "Beginne Raumerkennung...", cls: "" } }, ]; BASTING_ROOMS.forEach((room, i) => { sequence.push({ t: 1800 + i * 280, log: { txt: `→ ${room.name} · ${room.area} m²`, cls: "detect" }, room, }); }); sequence.push({ t: 1800 + BASTING_ROOMS.length * 280 + 400, log: { txt: `✓ ${BASTING_ROOMS.length} ${isGewerbe ? "Bereiche" : "Räume"} · 138.99 m² ${isGewerbe ? "Nutzfläche" : "Wohnfläche"} erkannt`, cls: "done" }, finish: true, }); sequence.forEach(s => { setTimeout(() => { setLogs(prev => [...prev, s.log]); setProgress(((sequence.indexOf(s) + 1) / sequence.length) * 100); if (s.room) setDetected(prev => [...prev, s.room]); if (s.finish) { setTimeout(() => setPhase("review"), 800); } }, s.t); }); }; // Echter Upload via XHR (Progress-Events). const uploadPlan = (file) => { if (!file) return; setPhase("scanning"); setLogs([{ txt: `Lade ${file.name} (${(file.size/1024/1024).toFixed(2)} MB) hoch …`, cls: "" }]); setProgress(0); setDetected([]); setServerResult(null); setErrorMsg(null); // Lokalen Preview als data:URL erstellen, wird sofort sichtbar. // Browser rendern PNG/JPG/WebP nativ; AVIF in modernen Browsern auch. // Bei PDF zeigen wir nur ein Loading-Icon -- PDFs sind in nicht // trivial darstellbar. const isImage = /\.(png|jpe?g|webp|avif|bmp|tiff?)$/i.test(file.name) || file.type.startsWith("image/"); setLocalPreviewKind(isImage ? "image" : "pdf"); if (isImage) { const reader = new FileReader(); reader.onload = () => setLocalPreviewUrl(String(reader.result)); reader.onerror = () => setLocalPreviewUrl(null); reader.readAsDataURL(file); } else { setLocalPreviewUrl(null); } const fd = new FormData(); fd.append("file", file); const xhr = new XMLHttpRequest(); xhr.open("POST", "/api/floorplan/detect", true); xhr.timeout = 90_000; xhr.upload.onprogress = (e) => { if (e.lengthComputable) { const pct = (e.loaded / e.total) * 30; // Upload nimmt erste 30% der Bar setProgress(pct); } }; xhr.upload.onload = () => { setProgress(35); setLogs(prev => [...prev, { txt: "Upload abgeschlossen — Plan wird analysiert …", cls: "" }]); }; xhr.onload = () => { let json; try { json = JSON.parse(xhr.responseText); } catch (e) { json = null; } if (xhr.status >= 200 && xhr.status < 300 && json && json.ok && json.token) { try { window.localStorage.setItem("sm-floorplan-token", json.token); } catch (e) {} setProgress(100); const fullRooms = (json.rooms || []); // Setze sofort serverResult mit LEEREN rooms -- viewbox+token sind ab // jetzt verfuegbar, Vorschau-Komponente kann den lokalen Plan rendern. // Dann animieren wir die Raum-Erkennung Stueck fuer Stueck (~240ms), // wie in der Demo. So sieht der User: 1. Plan kommt an, 2. Räume // werden "erkannt" und tauchen einer nach dem anderen als cyan Box auf. setServerResult({ ...json, rooms: [] }); const sourceLabel = json.source === "vector" ? "Vector-PDF (Text-Layer)" : json.source === "ocr" ? "OCR (Tesseract)" : json.source === "gemini" ? "Vision-LLM (Gemini 2.5 Flash)" : json.source === "llm" ? "Vision-LLM (LM Studio)" : json.source; setLogs(prev => [...prev, { txt: `Quelle: ${sourceLabel}`, cls: "" }]); const STEP_MS = 220; fullRooms.forEach((r, i) => { setTimeout(() => { setServerResult(prev => prev ? { ...prev, rooms: [...prev.rooms, r] } : prev); setLogs(prev => [...prev, { txt: `→ ${r.label}${r.area_m2 ? ` · ${r.area_m2.toFixed(2)} m²` : ""}`, cls: "detect", }]); }, i * STEP_MS); }); // Nach allen Räumen: Final-Log + Wechsel zu Review. const finishAt = fullRooms.length * STEP_MS + 600; setTimeout(() => { setLogs(prev => [ ...prev, { txt: `✓ ${fullRooms.length} ${isGewerbe ? "Bereiche" : "Räume"} erkannt`, cls: "done" }, ...(json.note ? [{ txt: `! ${json.note}`, cls: "" }] : []), ]); setPhase("review"); }, finishAt); } else { setErrorMsg(json?.error || `Server-Fehler ${xhr.status}`); setPhase("error"); } }; xhr.onerror = () => { setErrorMsg("Netzwerk-Fehler beim Upload."); setPhase("error"); }; xhr.ontimeout = () => { setErrorMsg("Upload-Timeout (über 90 Sek.)."); setPhase("error"); }; xhr.send(fd); }; const onPickFile = (e) => { const f = e.target.files && e.target.files[0]; if (f) uploadPlan(f); e.target.value = ""; // erlaubt erneutes Hochladen derselben Datei }; const onDropFile = (e) => { e.preventDefault(); const f = e.dataTransfer.files && e.dataTransfer.files[0]; if (f) uploadPlan(f); }; // Display-Daten: Demo-Fall vs. echter Upload. const isDemo = !serverResult; const displayRooms = isDemo ? BASTING_ROOMS : (serverResult.rooms || []); const indoor = isDemo ? BASTING_ROOMS.filter(r => !r.outdoor) : displayRooms; const outdoor = isDemo ? BASTING_ROOMS.filter(r => r.outdoor) : []; const totalArea = isDemo ? indoor.reduce((s, r) => s + r.area, 0) : displayRooms.reduce((s, r) => s + (r.area_m2 || 0), 0); const floors = isDemo ? Array.from(new Set(BASTING_ROOMS.map(r => r.floor))) : ["EG"]; // Worker erkennt aktuell nur 1 Stockwerk pro Upload return (

{phase === "drop" ? "Bauplan importieren" : phase === "scanning" ? "Plan wird analysiert..." : "Erkannter Plan · zur Kontrolle"}

{phase === "drop" ? (isGewerbe ? "PDF, DWG oder Foto von einem Objektplan. Bereiche, Flächen und Fenster werden automatisch erkannt." : "PDF, DWG oder Foto von einem Architektenplan. Räume, Flächen und Fenster werden automatisch erkannt.") : phase === "scanning" ? `Texterkennung läuft. Sobald alle ${isGewerbe ? "Bereiche" : "Räume"} erkannt sind, geht's automatisch weiter.` : `Bitte prüfen — passt das Layout? Du kannst ${isGewerbe ? "Bereiche" : "Räume"} noch korrigieren oder direkt weiter zum ${isGewerbe ? "Funktions" : "Modul"}-Schritt.`}

{phase === "drop" && (
fileInputRef.current && fileInputRef.current.click()} onDragOver={(e) => e.preventDefault()} onDrop={onDropFile}>

Plan hier ablegen

oder klicken zum Hochladen

PDF · PNG · JPG · WebP · AVIF · max. 20 MB
e.stopPropagation()} style={{ marginTop: 14, fontSize: 11, lineHeight: 1.5, color: KFG_PALETTE.muted, maxWidth: 380, textAlign: "center", fontFamily: "'Inter', sans-serif", }}> Mit dem Hochladen bestätigst du, dass du zur Übermittlung des Plans berechtigt bist. Der Plan wird nach 24 Stunden automatisch gelöscht.{' '} Details .
)} {phase === "error" && (

Upload fehlgeschlagen

{errorMsg || "Unbekannter Fehler"}

)} {phase === "scanning" && (
{localPreviewKind ? ( // Upload-Pfad. SRC-Logik: // - Vor Server-Antwort: lokales File (data: URL) sofort sichtbar // - Nach Server-Antwort: server-cropped preview.png, weil die // Room-Bboxes vom Server in cropped-Koords sind und sich // sonst auf dem ungeschnittenen Original verschieben würden. ) : ( // Demo-Pfad: hardcoded EFH-Basting Plan + simulierte Erkennung. )}

{localPreviewKind ? "Plan-Analyse" : "Texterkennung · OCR"}

    {logs.map((l, i) => (
  • {l.txt}
  • ))}
)} {phase === "review" && (
{isDemo ? : } {isDemo ?
Erkennung abgeschlossen
: }
{displayRooms.length}
{isGewerbe ? "Bereiche" : "Räume"}
{(totalArea || 0).toFixed(1)}
Fläche
{floors.length}
Stockwerk{floors.length > 1 ? "e" : ""}
{outdoor.length}
Außen
Erkannte {isGewerbe ? "Bereiche" : "Räume"}
{isDemo ? BASTING_ROOMS.map(r => (
{r.name} {r.floor}{r.outdoor ? " · außen" : ""} {r.area} m²
)) : displayRooms.map((r, i) => (
{r.label || `Raum ${i+1}`} {Math.round((r.confidence || 0) * 100)}% Konfidenz {r.area_m2 ? r.area_m2.toFixed(2) + " m²" : "–"}
))} {!isDemo && serverResult.note && (
{serverResult.note}
)}
)} {(phase === "drop" || phase === "error") && (
Tipp: „Demo starten" zeigt einen echten EFH-Plan
)} {phase === "review" && (
)}
); } /* ============================================================ Status-Pille: zeigt was die Erkennung gemacht hat (welcher LLM-Pfad, ob fallback, ob Diag-Bundle gespeichert). ============================================================ */ function SourcePill({ source, note }) { // source -> {label, color} const map = { "vector": { label: "Plan-Daten direkt gelesen", color: "#5DD3B0", bg: "rgba(93,211,176,0.10)" }, "ocr": { label: "OCR (Tesseract)", color: "#8DDBEA", bg: "rgba(141,219,234,0.10)" }, "gemini": { label: "Vision-AI · Gemini Flash-Lite", color: "#3ABDD0", bg: "rgba(58,189,208,0.10)" }, "openrouter": { label: "Vision-AI · OpenRouter", color: "#E8C547", bg: "rgba(232,197,71,0.10)" }, "llm": { label: "Vision-AI · lokal (LM Studio)", color: "#B47BD9", bg: "rgba(180,123,217,0.10)" }, "failed": { label: "Erkennung fehlgeschlagen", color: "#F2A93B", bg: "rgba(242,169,59,0.10)" }, }; const m = map[source] || { label: source || "Unbekannt", color: "#8DDBEA", bg: "rgba(141,219,234,0.10)" }; // Wenn note vorhanden + es klingt nach Problem (Quota/Fehler), zusaetzliche Warn-Pille. const hasIssue = note && /quota|fehlgeschlag|nicht verfueg|nicht verfügbar|Keine Räume/i.test(note); return (
{m.label}
{hasIssue && (
⚠ {note}
)}
); } /* ============================================================ Server-Upload-Vorschau: NUR das Plan-Bild ohne Box-Overlays. Vision-LLMs (Gemini/OpenRouter free tier) sind nicht zuverlaessig genug fuer Pixel-genaue Box-Positionen, deshalb zeigen wir den Plan unverfaelscht und die erkannten Raeume in der Liste rechts (Label + m^2 + Konfidenz). Step 2 (Modul-Auswahl) und Step 4 (Grundriss-Explorer) rendern dann GENERISCHE Raum-Geometrien aus den zuverlaessigen Daten (m^2, Anzahl und Seite der Fenster, Tuerseite). src Primary image URL (Server-Preview-PNG) fallbackSrc Fallback (lokale data:URL aus FileReader) wenn src fehlt/404 placeholderKind "image"|"pdf"|null -- nur fuer Scanning-Phase ohne Server-Bild ============================================================ */ function KfgUploadPreview({ src, fallbackSrc, placeholderKind }) { const [imgError, setImgError] = React.useState(false); const effectiveSrc = (!imgError && src) ? src : fallbackSrc; return (
{effectiveSrc ? ( Plan-Vorschau setImgError(true)} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "contain", pointerEvents: "none" }} /> ) : (
{placeholderKind === "pdf" ? "PDF wird analysiert …" : "Plan wird hochgeladen …"}
)}
); } /* Map server room-shape -> konfigurator-internal room-shape so Step 2 (Module) funktioniert wenn der User einen eigenen Plan hochgeladen hat. Wir uebernehmen NUR die zuverlaessigen Felder vom LLM: label, area_m2, windows count + window_sides Array, door_side. Die Pixel-Bbox/Polygon vom Plan ignorieren wir, weil free Vision-LLMs raeumlich zu unzuverlaessig sind. Step 2 + Step 4 rendern stattdessen generische Raum-Geometrien aus diesen Daten. */ function adaptServerRoomsToConfigurator(serverRooms) { const TYPE_BY_LABEL = [ [/wohn|essen/i, "living"], [/koch|küche|kueche/i, "kitchen"], [/bad/i, "bath"], [/wc|du(sche)?/i, "wc"], [/schlaf/i, "bedroom"], [/zimmer/i, "bedroom"], [/kinder/i, "bedroom"], [/diele|vorraum|flur|gang|korridor/i, "hall"], [/garderobe|abst/i, "hall"], [/technik|wira|ht|hwr/i, "tech"], [/speis|speiß|kammer/i, "storage"], [/stiege|treppe/i, "stairs"], [/galerie/i, "hall"], [/terrass|balkon|loggia/i, "terrace"], [/keller|garage|carport/i, "tech"], ]; const inferType = (label) => { for (const [re, t] of TYPE_BY_LABEL) if (re.test(label || "")) return t; return "room"; }; const inferOutdoor = (label) => /terrass|balkon|loggia|garten/i.test(label || ""); return serverRooms.map((r, i) => { // Anzahl der Fenster vertrauen wir dem LLM (das LLM kann zaehlen). // Die SEITE pro Fenster und doorSide sind beim LLM ~50% Trefferquote -- // wir verwerfen sie und benutzen generische Defaults: alle Fenster oben // (typische Annahme Aussenwand), Tuer unten (typische Diele-Anbindung). // Damit ist der Preview ehrlich generisch statt halbfalsch positioniert. const wnRaw = Array.isArray(r.window_sides) ? r.window_sides.length : (typeof r.windows === "number" ? r.windows : _heuristicWindows(r.label, r.area_m2 || 0)); const windowCount = Math.max(0, Math.min(8, wnRaw)); return { id: `u${i+1}`, name: r.label || `Raum ${i+1}`, area: r.area_m2 || 10, type: inferType(r.label), floor: "EG", windows: windowCount, windowSides: Array(windowCount).fill("top"), doorSide: "bottom", outdoor: inferOutdoor(r.label), }; }); } function _heuristicWindows(label, area) { const t = (label || "").toLowerCase(); if (/wohn|essen/.test(t)) return area > 25 ? 3 : 2; if (/koch|kueche|küche/.test(t)) return area > 8 ? 1 : 0; if (/schlaf|kind|zimmer/.test(t)) return area > 10 ? 1 : 0; if (/bad|wc/.test(t)) return area > 4 ? 1 : 0; if (/diele|vorraum|flur|gang/.test(t)) return 0; // innenliegend meist if (/technik|wira|hwr/.test(t)) return 0; return area > 6 ? 1 : 0; } /* Pixel-genaue Raum-Geometrie für assets/bauplan_eg.png. Generiert aus EFH_Basting_Bauplan.pdf (S.2 EG, M 1:100) via PDF-Render @200dpi → Crop → Hough-Wandgrid + per-label walk-walls. Window/door overlays: Pixel-Koordinaten aus BASTING_ROOMS.windowSides/doorSide, Fenster 120cm = 94px, Tür 80cm = 63px, Wandstärke 24px @200dpi. ViewBox: 1614 × 1049 px. */ const BASTING_GEOM = { "r1": { x: 56, y: 479, w: 612, h: 320, label: "Wohnen/Essen", windows: [{ x: 210, y: 787, w: 94, h: 24, side: "bottom" }, { x: 419, y: 787, w: 94, h: 24, side: "bottom" }, { x: 656, y: 592, w: 24, h: 94, side: "right" }], door: { x: 44, y: 608, w: 24, h: 63, side: "left" }, }, "r2": { x: 56, y: 374, w: 309, h: 207, label: "Kochen", windows: [{ x: 44, y: 430, w: 24, h: 94, side: "left" }], door: { x: 179, y: 569, w: 63, h: 24, side: "bottom" }, }, "r3": { x: 67, y: 166, w: 294, h: 93, label: "Speiß", windows: [], door: { x: 183, y: 247, w: 63, h: 24, side: "bottom" }, }, "r4": { x: 668, y: 154, w: 118, h: 517, label: "VR", windows: [], door: { x: 774, y: 381, w: 24, h: 63, side: "right" }, }, "r5": { x: 667, y: 671, w: 141, h: 93, label: "WC", windows: [{ x: 796, y: 670, w: 24, h: 94, side: "right" }], door: { x: 655, y: 686, w: 24, h: 63, side: "left" }, }, "r6": { x: 866, y: 166, w: 126, h: 213, label: "WiRa/HT", windows: [], door: { x: 980, y: 241, w: 24, h: 63, side: "right" }, }, "r7": { x: 1096, y: 166, w: 181, h: 203, label: "Bad", windows: [{ x: 1139, y: 154, w: 94, h: 24, side: "top" }], door: { x: 1155, y: 357, w: 63, h: 24, side: "bottom" }, }, "r8": { x: 1127, y: 484, w: 422, h: 280, label: "Schlafzimmer", windows: [{ x: 1291, y: 472, w: 94, h: 24, side: "top" }, { x: 1115, y: 577, w: 24, h: 94, side: "left" }], door: { x: 1537, y: 593, w: 24, h: 63, side: "right" }, }, "r9": { x: 818, y: 484, w: 300, h: 280, label: "K Zimmer 1", windows: [{ x: 921, y: 472, w: 94, h: 24, side: "top" }], door: { x: 937, y: 752, w: 63, h: 24, side: "bottom" }, }, "r10": { x: 1286, y: 166, w: 263, h: 307, label: "K Zimmer 2", windows: [{ x: 1370, y: 154, w: 94, h: 24, side: "top" }], door: { x: 1386, y: 461, w: 63, h: 24, side: "bottom" }, }, "r11": { x: 371, y: 166, w: 286, h: 203, label: "Zimmer", windows: [{ x: 645, y: 220, w: 24, h: 94, side: "right" }], door: { x: 359, y: 236, w: 24, h: 63, side: "left" }, }, "r12": { x: 807, y: 374, w: 320, h: 130, label: "Garderobe", windows: [], door: { x: 795, y: 408, w: 24, h: 63, side: "left" }, }, "r13": { x: 31, y: 801, w: 626, h: 248, label: "Terrasse", outdoor: true, windows: [], door: null, }, "r14": { x: 657, y: 24, w: 200, h: 110, label: "Zugang Haupt", windows: [], door: { x: 726, y: 122, w: 63, h: 24, side: "bottom" }, }, }; window.BASTING_GEOM = BASTING_GEOM; window.BASTING_GEOM_VIEWBOX = { w: 1614, h: 1049 }; /* KfgPlanSVG — zeigt das echte EG-PNG als Hintergrund, mit animierten Raum-Highlights + Fenster-/Tür-Markierungen aus BASTING_GEOM. */ function KfgPlanSVG({ detected }) { const ROOMS_GEOM = BASTING_GEOM; const detectedIds = new Set(detected.map(r => r.id)); const VB = window.BASTING_GEOM_VIEWBOX; return (
Erdgeschoss-Plan {Object.entries(ROOMS_GEOM).map(([id, g]) => { const isDetected = detectedIds.has(id); const windows = (isDetected && g.windows) || []; const door = isDetected ? g.door : null; return ( {windows.map((w, i) => ( ))} {door && ( )} {isDetected && ( {g.label} )} ); })}
); } /* ============================================================ STEP 2 — Module per room ============================================================ */ /* Visualizes a single room with its features placed inside. - Window slots on top edge, door on bottom edge - Module dots appear when modules are selected, with positions that make sense */ function KfgRoomPreview({ room, activeMods, modules, moduleCounts }) { // Aspect-aware rectangle inside 480×280 viewBox const VB_W = 480, VB_H = 280, PAD = 36; const aw = VB_W - 2*PAD, ah = VB_H - 2*PAD; // Aspekt: bevorzugt aus echter Detection-Geometrie (Plan-Upload), sonst // heuristisch aus Fläche. Geom-Variante macht den Preview-Raum proportional // korrekt -- ein langer Diele-Korridor sieht dann auch wie einer aus. const ratio = (room.geom && room.geom.w > 0 && room.geom.h > 0) ? Math.min(2.5, Math.max(0.4, room.geom.w / room.geom.h)) : Math.min(2.0, Math.max(0.7, Math.sqrt(room.area / 16))); let rw, rh; if (ratio >= 1) { rw = aw; rh = aw / ratio; } else { rh = ah; rw = ah * ratio; } if (rh > ah) { rh = ah; rw = ah * ratio; } if (rw > aw) { rw = aw; rh = aw / ratio; } const rx = (VB_W - rw) / 2; const ry = (VB_H - rh) / 2; // Window positions: pro Wand verteilt // Wenn windowSides nicht gegeben → fallback: alle oben const sides = (Array.isArray(room.windowSides) && room.windowSides.length === room.windows) ? room.windowSides : Array(Math.min(room.windows, 5)).fill("top"); const sideGroups = { top: [], right: [], bottom: [], left: [] }; sides.slice(0, 5).forEach((s, i) => { (sideGroups[s] || sideGroups.top).push(i); }); const windows = []; for (const side of ["top","right","bottom","left"]) { const idxList = sideGroups[side]; const n = idxList.length; if (n === 0) continue; const isHorizontal = (side === "top" || side === "bottom"); const wallLen = isHorizontal ? rw : rh; for (let k = 0; k < n; k++) { const t = (k + 1) / (n + 1); const winLen = Math.min(60, wallLen / (n + 1) * 0.7); if (side === "top") windows.push({ side, x: rx + rw*t - winLen/2, y: ry, w: winLen, h: 4 }); if (side === "bottom") windows.push({ side, x: rx + rw*t - winLen/2, y: ry + rh-4, w: winLen, h: 4 }); if (side === "left") windows.push({ side, x: rx, y: ry + rh*t - winLen/2, w: 4, h: winLen }); if (side === "right") windows.push({ side, x: rx + rw-4, y: ry + rh*t - winLen/2, w: 4, h: winLen }); } } // Door — pro Wand const doorSide = room.doorSide || "bottom"; const doorLen = 36; // anchor point = wo das Türscharnier in der Wand sitzt; door öffnet ins Rauminnere let doorAnchor, doorEnd, doorArcEnd; if (doorSide === "bottom") { const cx = rx + rw * 0.3; doorAnchor = { x: cx, y: ry + rh }; doorEnd = { x: cx + doorLen, y: ry + rh }; doorArcEnd = { x: cx, y: ry + rh - doorLen }; // swing inward (up) } else if (doorSide === "top") { const cx = rx + rw * 0.3; doorAnchor = { x: cx, y: ry }; doorEnd = { x: cx + doorLen, y: ry }; doorArcEnd = { x: cx, y: ry + doorLen }; } else if (doorSide === "left") { const cy = ry + rh * 0.3; doorAnchor = { x: rx, y: cy }; doorEnd = { x: rx, y: cy + doorLen }; doorArcEnd = { x: rx + doorLen, y: cy }; } else { // right const cy = ry + rh * 0.3; doorAnchor = { x: rx + rw, y: cy }; doorEnd = { x: rx + rw, y: cy + doorLen }; doorArcEnd = { x: rx + rw - doorLen, y: cy }; } // Door rendering uses doorX/doorY/doorW; das SVG ist auf horizontale // (top/bottom) Tueren ausgelegt -- fuer left/right fallen Rect+Arc visuell // nicht ideal, aber zumindest kein Crash mehr (war: ReferenceError doorX). const doorX = Math.min(doorAnchor.x, doorEnd.x); const doorY = doorAnchor.y; const doorW = doorLen; // Module placements const isOn = (k) => activeMods.includes(k); // Light points: ceiling grid + circuit-Zuordnung const lights = []; let lightCircuits = 1; if (isOn("licht")) { const lichtMod = modules.licht; lightCircuits = lichtMod ? Math.max(1, window.getModuleCount(lichtMod, room, moduleCounts, room.id, "licht")) : 1; const cols = Math.max(2, Math.ceil(rw / 90)); const rows = Math.max(1, Math.ceil(rh / 90)); const total = cols * rows; let idx = 0; for (let i = 0; i < cols; i++) { for (let j = 0; j < rows; j++) { // Verteile Spots gleichmäßig auf circuits: aufeinanderfolgende Indizes → gleicher Kreis-Block const circuit = Math.floor((idx * lightCircuits) / total); lights.push({ x: rx + rw * (i+1) / (cols+1), y: ry + rh * (j+1) / (rows+1), circuit: Math.min(lightCircuits - 1, circuit), }); idx++; } } } // Sockets: along walls, schaltbar const sockets = []; if (isOn("steckdosen")) { const stkMod = modules.steckdosen; const count = stkMod ? window.getModuleCount(stkMod, room, moduleCounts, room.id, "steckdosen") : Math.max(2, Math.ceil(room.area / 12)); for (let i = 0; i < count; i++) { const side = i % 4; const t = ((Math.floor(i/4) + 1) / (Math.ceil(count/4) + 1)); let x, y; if (side === 0) { x = rx + rw * t; y = ry + rh - 5; } else if (side === 1) { x = rx + rw - 5; y = ry + rh * t; } else if (side === 2) { x = rx + rw * t; y = ry + 5; } else { x = rx + 5; y = ry + rh * t; } sockets.push({ x, y }); } } return (
{/* Floor pattern */} {/* Room rectangle */} {/* Windows: knock out wall, draw window frame */} {windows.map((w, i) => ( {/* Beschattung indicator */} {isOn("beschattung") && ( )} ))} {/* Door */} {/* Zutritt */} {isOn("zutritt") && ( RFID )} {/* Lights — pro Schaltkreis gruppiert, sequenziell animiert */} {lights.map((l, i) => { // Zykluslänge: 1 Schaltkreis = 2.4s solo; mehrere Kreise = sequenziell + Pause const cycle = Math.max(2.4, lightCircuits * 1.2 + 0.6); const slot = lightCircuits === 1 ? 0 : (l.circuit / lightCircuits) * cycle; return ( ); })} {/* Heating: floor zones */} {isOn("heizung") && ( FBH 21°C )} {/* Klima: ceiling diffuser */} {isOn("klima") && ( )} {/* Sockets */} {/* Sockets — sequenziell aufpulsend */} {sockets.map((s, i) => { const cycle = Math.max(3, sockets.length * 0.35 + 1.5); const slot = sockets.length === 1 ? 0 : (i / sockets.length) * cycle; return ( ); })} {/* Multimedia: speaker icons */} {isOn("multimedia") && ( )} {/* Sensorik: motion detector in corner */} {isOn("sensorik") && ( )} {/* Energie: flash icon */} {isOn("energie") && ( )} {/* Alarm: siren in corner */} {isOn("alarm") && ( )} {/* Room label */} {room.name} {room.area} m² · {room.windows} Fenster {activeMods.length > 0 && (
{activeMods.map(k => (
{modules[k]?.label || k}
))}
)}
); } function legendColor(k) { switch(k) { case "licht": return KFG_PALETTE.warm; case "beschattung": return KFG_PALETTE.warm; case "heizung": return "#D4805A"; case "klima": return KFG_PALETTE.accent; case "zutritt": return KFG_PALETTE.accent; case "multimedia": return KFG_PALETTE.accent; case "sensorik": return KFG_PALETTE.accent; case "steckdosen": return KFG_PALETTE.muted; case "energie": return KFG_PALETTE.warm; case "alarm": return "#E66B5C"; default: return KFG_PALETTE.accent; } } /* Generische Sub-Bereich-Namen pro Raumtyp fuer Lichtkreise. Beispiel: Gang (hall, 2 Kreise) -> "Licht Gang", "Licht Garderobe". Erster Eintrag uebernimmt den Raum-Namen (matcht oft den ersten Sub- Bereich, wird dann uebersprungen). Wenn mehr Kreise als Sub-Bereiche existieren, fuellen wir mit "Kreis N" auf. */ const KFG_LIGHT_SUBAREAS = { living: ["Wohnen", "Essen", "Sofa", "Vitrine", "Wand"], kitchen: ["Kochen", "Esstisch", "Arbeitsplatte", "Insel"], bath: ["Bad", "Spiegel", "Dusche", "Wanne"], wc: ["WC", "Spiegel"], bedroom: ["Schlafen", "Nachttisch", "Schrank", "Lesen"], hall: ["Gang", "Garderobe", "Eingang", "Treppe"], tech: ["Technik", "Arbeitsfläche"], storage: ["Speis", "Regal"], stairs: ["Treppe", "Geländer"], terrace: ["Terrasse", "Wand"], room: ["Zimmer", "Schreibtisch", "Wand"], entry: ["Eingang", "Außen"], }; function kfgLichtCircuitNames(room, count) { const subs = KFG_LIGHT_SUBAREAS[room.type] || ["Decke", "Wand", "Spot"]; const out = [room.name]; for (const sub of subs) { if (out.length >= count) break; if (sub.toLowerCase() === (room.name || "").toLowerCase()) continue; out.push(sub); } while (out.length < count) out.push(`Kreis ${out.length}`); return out.slice(0, count); } /* ============================================================ Live-Dashboard fuer den AKTIVEN Raum -- nachgebaut aus dem Smart-Home-Demo (SMDRoomDetail), aber dynamisch: Sektionen erscheinen nur wenn das passende Modul aktiv ist. - licht -> Beleuchtung-Card mit Dimmer - heizung -> Raumklima Thermostat + Thermostat-Detail - klima -> Raumklima (alternativ wenn keine Heizung) - multimedia -> Mediaplayer - sensorik -> Praesenz-Tiles + Temperatur-Pill oben - steckdosen -> Hauptschalter-Tiles - energie -> Verbrauchs-Tile - beschattung -> Beschattungs-Card - zutritt -> Zutritt-Tile - alarm -> Alarm-Tile Szenen rendern wir wenn Licht aktiv ist (Szenen = Lichtszenen). Automationen werden aus den aktiven Modulen abgeleitet. ============================================================ */ function KfgRoomDashboard({ room, activeMods, modules, moduleCounts, ausstattungsklasse }) { // Demo-Helpers vom Smart-Home-Demo nachladen (ueber window expose). const SMD = window.SMD_PALETTE; const Icons = window.SMDIcons; const Tile = window.SMDTile; // dim: pro Lichtkreis ein Wert (per Index). Wir verwenden ein Object // damit Schluesselwechsel beim Raum-Switch keinen Stale-State erzeugen. const [dimByKey, setDimByKey] = React.useState({}); const dimOf = (key, fallback = 60) => dimByKey[key] != null ? dimByKey[key] : fallback; const setDim = (key, v) => setDimByKey({ ...dimByKey, [key]: v }); const [temp, setTemp] = React.useState(21.0); const [shade, setShade] = React.useState(40); const [scene, setScene] = React.useState("hell"); if (!SMD || !Icons || !Tile) { // smart-home-demo.jsx noch nicht geladen -- defensiver Fallback return
Lade Vorschau…
; } const has = (k) => activeMods.includes(k); const count = (k) => { const mod = modules[k]; if (!mod || !mod.countable || !window.getModuleCount) return 1; return window.getModuleCount(mod, room, moduleCounts, room.id, k, ausstattungsklasse); }; // Welche Sektionen werden gerendert? Wenn keine -> Empty-State. const showAny = activeMods.length > 0; // Section-Header (kompakt fuer ~320px Spalte) const Section = ({ title, action, children }) => (

{title}

{action}
{children}
); const Card = ({ children, style }) => (
{children}
); // Bullet (Status-Pille) wie im Demo const StatusPill = ({ icon, color, text }) => ( {icon(color)} {text} ); return (
{/* Header */}
Live-Vorschau
{room.name}
{(has("heizung") || has("klima") || has("sensorik")) && ( {Icons.thermo(SMD.warm)} 21,0 °C )}
{!showAny && (
Wähle links Module aus — die zugehörigen Steuerungen für {room.name} erscheinen dann hier so wie sie später im Dashboard aussehen.
)} {/* Beleuchtung -- ein Card PRO Lichtkreis (N Buttons mit generisch sinnvollen Namen, statt einer Karte mit Count). */} {has("licht") && (() => { const n = Math.max(1, count("licht")); const names = kfgLichtCircuitNames(room, n); return (
}>
{names.map((sub, i) => { const key = `${room.id}-licht-${i}`; const v = dimOf(key, 60 - i * 15); return (
{Icons.light(SMD.pv)}
Licht {sub}
{v} %
setDim(key, parseInt(e.target.value))} style={{ width: "100%", height: 4, accentColor: SMD.pv, cursor: "pointer" }} />
); })}
); })()} {/* Beschattung */} {has("beschattung") && room.windows > 0 && (
{Icons.grid(SMD.water)}
Raffstore {room.name}
{shade} % · {room.windows} Fenster
setShade(parseInt(e.target.value))} style={{ width: "100%", height: 4, accentColor: SMD.water, cursor: "pointer" }} />
)} {/* Raumklima -- bevorzugt Heizung, sonst Klima */} {(has("heizung") || has("klima")) && (
{Icons.fire(SMD.warm)}
{has("heizung") ? "Thermostat" : "Klima"} {room.name}
{has("heizung") ? "Heizbetrieb" : "Auto"}
{temp.toFixed(1)} °C
)} {/* Mediaplayer */} {has("multimedia") && (
{Icons.cast(SMD.accent)}
Lautsprecher {room.name}
Pausiert
)} {/* Szenen -- nur wenn Licht aktiv (Szenen = Lichtszenen) */} {has("licht") && (
{[ { id: "nacht", name: "Nachtlicht" }, { id: "hell", name: "Hell" }, { id: "gedimmt", name: "Gedimmt" }, { id: "fokus", name: "Fokus" }, ].map(s => { const active = scene === s.id; return ( ); })}
)} {/* Hauptschalter (Steckdosen + Energie) */} {(has("steckdosen") || has("energie")) && (
{has("energie") && ( )} {has("steckdosen") && ( )}
)} {/* Thermostat-Detail */} {has("heizung") && (
)} {/* Praesenz / Sensorik */} {has("sensorik") && (
)} {/* Sicherheit */} {(has("zutritt") || has("alarm")) && (
{has("zutritt") && } {has("alarm") && }
)} {/* Automationen -- aus aktiven Modulen abgeleitet */} {showAny && (
{has("licht") && } {has("heizung") && } {has("beschattung") && } {has("sensorik") && }
)}
); } function KfgStep2Modules({ pathKey, rooms, modules, assignments, setAssignments, moduleCounts, setModuleCounts, ausstattungsklasse = 1, setAusstattungsklasse, onNext, onBack }) { const indoorRooms = rooms.filter(r => !r.outdoor); const [activeRoomId, setActiveRoomId] = React.useState(indoorRooms[0]?.id); const [previewDevice, setPreviewDevice] = React.useState("tablet"); // "tablet" | "phone" const [previewFullscreen, setPreviewFullscreen] = React.useState(false); const isGewerbe = pathKey === "gewerbe"; const unitLabel = isGewerbe ? "Bereiche" : "Räume"; const moduleLabel = isGewerbe ? "Funktionen" : "Module"; // ESC schliesst Fullscreen React.useEffect(() => { if (!previewFullscreen) return; const onKey = (e) => { if (e.key === "Escape") setPreviewFullscreen(false); }; window.addEventListener("keydown", onKey); const prevOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = prevOverflow; }; }, [previewFullscreen]); const activeRoom = rooms.find(r => r.id === activeRoomId); const activeMods = assignments[activeRoomId] || []; const toggleModule = (modKey) => { const current = assignments[activeRoomId] || []; const next = current.includes(modKey) ? current.filter(m => m !== modKey) : [...current, modKey]; setAssignments({ ...assignments, [activeRoomId]: next }); }; const setCount = (modKey, value) => { const room = activeRoomId; const next = { ...(moduleCounts || {}) }; next[room] = { ...(next[room] || {}), [modKey]: Math.max(0, value) }; setModuleCounts(next); }; if (!activeRoom) return null; const totalRoomPrice = activeMods.reduce( (s, m) => s + (modules[m] ? window.getModulePrice(modules[m], activeRoom, moduleCounts, activeRoomId, m, ausstattungsklasse) : 0), 0 ); const klasseLabels = (window.KLASSE_LABELS || ["Mindest (DIN)", "Standard", "Komfort"]); return (
{/* RÄUME — kompakte horizontale Tabs (zuoberst, schlanker als Heading-Block) */}
{moduleLabel} pro {isGewerbe ? "Bereich" : "Raum"} · {isGewerbe ? "Bereich" : "Raum"} wählen
{indoorRooms.length} {unitLabel}
{indoorRooms.map(r => { const count = (assignments[r.id] || []).length; const isActive = r.id === activeRoomId; return ( ); })}
{/* HERO: Live-Vorschau — Header (Text + Toggles) volle Breite, darunter Bezel (Tablet ODER Handy) volle Breite. */}
▶ Live-Vorschau · {activeRoom.name}

{isGewerbe ? <>So sieht {activeRoom.name} später in der Betriebsoberfläche aus. : <>So sieht {activeRoom.name} später in deinem Home Assistant aus.}

{isGewerbe ? "Jede Funktion, die du unten aktivierst, erscheint hier sofort als Karte. Eine Oberfläche für Energie, Bereiche, Zutritt und Technik — bedienbar auf:" : "Jedes Modul, das du unten aktivierst, erscheint hier sofort als Karte. Eine Visu, ein Login, alle Geräte — bedienbar auf:"}

{previewDevice === "tablet" ? (
▲ Tablet · Wandhalterung oder mobil
) : (
▲ Smartphone · iOS / Android
)}
{previewFullscreen && (
{ if (e.target === e.currentTarget) setPreviewFullscreen(false); }} >
{previewDevice === "tablet" ? "Tablet · Wandhalterung oder mobil" : "Smartphone · iOS / Android"} · ESC zum Schliessen
)} {/* MODULE — 3-Spalten Cards. Klassen-Toggle (Mindest/Standard/Komfort) direkt neben der Modul-Überschrift, weil er die Vorgaben hier steuert. */}

{moduleLabel} für {activeRoom.name}

{activeRoom.area} m² · {activeRoom.windows} Fenster · €{totalRoomPrice.toLocaleString('de-AT')}
{setAusstattungsklasse && (
{klasseLabels.map((lbl, i) => { const active = i === ausstattungsklasse; return ( ); })}
)}

{isGewerbe ? <>Typische Smart-Building-Funktionen sind je Bereich vorgewählt. Alles an- und abwählbar — Stückzahlen direkt anpassbar. Klassen-Toggle {klasseLabels[ausstattungsklasse]} rechts ändert die Vorgabe für alle Bereiche gleichzeitig. : <>Standard-Module pro Raumtyp sind nach DIN 18015-2 / RAL-RG 678 vorgewählt. Alles an- und abwählbar — Stückzahlen direkt anpassbar. Klassen-Toggle {klasseLabels[ausstattungsklasse]} rechts ändert die Vorgabe für alle Räume gleichzeitig.}

{Object.entries(modules).map(([key, mod]) => { const isOn = activeMods.includes(key); const count = window.getModuleCount(mod, activeRoom, moduleCounts, activeRoomId, key, ausstattungsklasse); const price = window.getModulePrice(mod, activeRoom, moduleCounts, activeRoomId, key, ausstattungsklasse); return (
{ if (e.target.closest(".kfg-mod-counter")) return; toggleModule(key); }} >
{mod.icon}
{mod.label}
{mod.countable ? `${count} ${mod.unit}` : modDescriptor(key, activeRoom)}
{mod.countable && isOn ? (
e.stopPropagation()}> {count}
) : }
€{price.toLocaleString('de-AT')}
); })}
); } function modDescriptor(key, room) { switch(key) { case "licht": return `~${Math.ceil(room.area/8)} Schaltkreise`; case "beschattung": return `${room.windows} Fenster · automatisch`; case "heizung": return `Einzelraum-Regelung`; case "klima": return `Temperatur + Lüftung`; case "zutritt": return `Zugang + Rollen`; case "multimedia": return `Audio · Multiroom-fähig`; case "sensorik": return `Bewegung · Luft · Klima`; case "steckdosen": return `~${Math.max(2, Math.ceil(room.area/12))} schaltbar`; case "energie": return `Verbrauch + Submetering`; case "alarm": return `Status + Eskalation`; default: return ""; } } /* ============================================================ PDF-Generator: baut ein selbst-haltbares druckbares HTML-Dokument mit pro-Raum Funktion, Groesse, Modulen, Material, Funktions- beschreibung. Wird in einem neuen Browser-Fenster geoeffnet damit der User es als PDF speichern kann. ============================================================ */ const TYPE_LABEL = { living: "Wohnbereich", kitchen: "Küche", bath: "Badezimmer", wc: "WC", bedroom: "Schlafzimmer", hall: "Verkehrsfläche", tech: "Technikraum", storage: "Lager / Speis", stairs: "Treppenraum", terrace: "Außenbereich", room: "Zimmer", entry: "Eingangsbereich", }; const TYPE_LABEL_GEWERBE = { living: "Empfang / Lobby", kitchen: "Teeküche / Gastro", bath: "Sanitärbereich", wc: "WC / Sanitär", bedroom: "Hotelzimmer / Apartment", hall: "Flur / Gang", tech: "Technik / Server", storage: "Lager / Backoffice", stairs: "Treppenraum", terrace: "Außenbereich", room: "Meeting / Seminar", office: "Büro / Arbeitsplatz", entry: "Eingang / Zutritt", }; const MODULE_FUNCTION = { licht: "Schaltbare und dimmbare Beleuchtung pro Lichtkreis. Zentralsteuerung über KNX/DALI, einbindbar in Szenen und Anwesenheits-Automation.", steckdosen: "Schaltbare Steckdosen, fernsteuerbar via App und Sprachassistent. Zeitschaltung und Standby-Killer per Szene möglich.", beschattung: "Automatische Steuerung von Raffstores oder Rollläden pro Fenster, abhängig von Tageszeit, Sonneneinstrahlung und Innenraum-Temperatur.", heizung: "Einzelraumregelung mit Stellantrieb am Heizkreisverteiler und Raumthermostat. Heiz-/Absenkprofile pro Raum, Anwesenheits-gesteuert.", klima: "Steuerung von Klimaanlage und kontrollierter Wohnraumlüftung. CO₂-, Feuchte- und Anwesenheits-abhängig.", zutritt: "Türsteuerung, Zugangskontrolle und Status-Anzeige (z.B. Garage, Eingangstür). Anbindung an Anwesenheits-Logik.", multimedia: "Multiroom-Audio-Steuerung. Sonos/HEOS-kompatibel, ein-/ausschaltbar via Szenen.", sensorik: "Bewegungs-, CO₂-, Helligkeits- und Temperatursensorik. Basis für Anwesenheits-Automation und Raumklima-Regelung.", energie: "Verbrauchsmessung pro Stromkreis. Verbindung zur Energiemonitoring-App + PV/Speicher-Auswertung.", alarm: "Einbruch- und Brandmeldeanlage, Panik-Funktion, Anbindung an Sirene und Push-Benachrichtigung.", }; const MODULE_FUNCTION_GEWERBE = { licht: "Beleuchtung pro Zone oder Lichtkreis, einbindbar in Präsenzlogik, Szenen, Zeitprogramme und zentrale Betriebsoberflächen.", steckdosen: "Schaltbare Steckdosen oder Stromkreise für definierte Verbraucher, Zeitprogramme oder Standby-Abschaltung.", beschattung: "Automatische Steuerung von Raffstores oder Beschattung nach Sonne, Uhrzeit, Raumbelegung und Innenraumtemperatur.", heizung: "Einzelraum- oder Zonenregelung für Heizung. Nutzungszeiten, Absenkbetrieb und Präsenz können berücksichtigt werden.", klima: "HLK-Anbindung für Heizung, Kühlung oder Lüftung. CO₂-, Temperatur- und Anwesenheitswerte dienen als Regelbasis.", zutritt: "Zutritt, Türstatus und Rollenlogik für Eingänge, Teamzonen, Lager, Technikräume oder Hotelbereiche.", multimedia: "Medien- oder Audiozonen für Empfang, Besprechung, Gastro oder Allgemeinflächen.", sensorik: "Präsenz-, CO₂-, Helligkeits- und Temperatursensorik als Grundlage für Komfort, Energie und Betriebsalarme.", energie: "Verbrauchsmessung, PV-/Netzbezug und Submetering. Grundlage für Energie-Dashboard, Reports und Schwellwertmeldungen.", alarm: "Status- und Eskalationsmeldungen für betriebliche Übersicht. Zertifizierte Alarm- oder Brandmeldetechnik wird mit Fachpartnern abgegrenzt.", }; function _esc(s) { return String(s == null ? "" : s) .replace(/&/g, "&").replace(//g, ">") .replace(/"/g, """).replace(/'/g, "'"); } function _eur(n) { return Math.round(n).toLocaleString('de-AT'); } function buildPdfHtml(ctx) { const { pathKey, pathLabel, klasseLabel, indoorRooms, modules, assignments, moduleCounts, ausstattungsklasse, totalArea, totalModuleCount, material, install_cost, install_hours, commissioning_cost, commissioning_hours, total, lower, upper, stundensatz, safeAreaForDiv, } = ctx; const today = new Date().toLocaleDateString('de-AT', { day: '2-digit', month: '2-digit', year: 'numeric' }); const validUntilDate = new Date(Date.now() + 30 * 24 * 3600 * 1000); const validUntil = validUntilDate.toLocaleDateString('de-AT', { day: '2-digit', month: '2-digit', year: 'numeric' }); const docNummer = `S-${new Date().getFullYear()}-${Math.floor(Date.now() / 1000) % 10000}`; const pricePerSqm = Math.round(total / safeAreaForDiv); const isGewerbe = pathKey === "gewerbe"; const areaLabel = isGewerbe ? "Nutzfläche" : "Wohnfläche"; const unitLabel = isGewerbe ? "Bereiche" : "Räume"; const typeLabels = isGewerbe ? TYPE_LABEL_GEWERBE : TYPE_LABEL; const moduleFunctions = isGewerbe ? MODULE_FUNCTION_GEWERBE : MODULE_FUNCTION; const positionRows = indoorRooms.map((r, ri) => { const mods = assignments[r.id] || []; if (mods.length === 0) return ''; const typeLbl = typeLabels[r.type] || (isGewerbe ? "Bereich" : "Raum"); const winLbl = (r.windows || 0) === 0 ? "innenliegend" : (r.windows === 1 ? "1 Fenster" : `${r.windows} Fenster`); const catLine = `${_esc(r.name)} · ${typeLbl} · ${r.area} m² · ${winLbl}`; const rows = mods.map((modKey, mi) => { const mod = modules[modKey]; if (!mod) return ''; const count = window.getModuleCount(mod, r, moduleCounts, r.id, modKey, ausstattungsklasse); const price = window.getModulePrice(mod, r, moduleCounts, r.id, modKey, ausstattungsklasse); const fn = moduleFunctions[modKey] || ""; const menge = mod.countable ? count : 1; const einheit = mod.countable ? (mod.unit || "Stk") : "Set"; const einzel = mod.countable ? (mod.material_unit || (price / Math.max(menge, 1))) : (mod.material_base || price); return ` ${ri + 1}.${mi + 1}
${_esc(mod.label)}
${_esc(fn)}
${menge} ${_esc(einheit)} € ${_eur(einzel)} € ${_eur(price)} `; }).join(''); return ` ${catLine} ${rows}`; }).join(''); const installCategory = ` Installation & Inbetriebnahme ${indoorRooms.length + 1}.1
Installation Monteur
Verkabelung, Montage Aktoren/Sensoren, Anschluss an KNX-Bus.
${install_hours} Std. € ${stundensatz.monteur.toFixed(2).replace('.', ',')} € ${_eur(install_cost)} ${indoorRooms.length + 1}.2
Inbetriebnahme Spezial-Monteur
Programmierung, Funktionstest, Übergabe und Einweisung.
${commissioning_hours} Std. € ${stundensatz.spezial_monteur.toFixed(2).replace('.', ',')} € ${_eur(commissioning_cost)} `; return ` Smartmacherei — Kostenschätzung
Smartmacherei · Kostenschätzung Druck-Vorschau · "Als PDF speichern" wählen
Smartmacherei
Ing. Thomas Basting
KOSTENSCHÄTZUNG
Schätzung Nr.${docNummer}
Datum${_esc(today)}
Smartmacherei · Ing. Thomas Basting · Gnadlingerweg 11 · 4650 Edt bei Lambach
An
Bauherr / Kundin
Name
Strasse
PLZ Ort
Betreff
Unverbindliche Kostenschätzung Smart-Building · ${totalArea.toFixed(0)} m², ${indoorRooms.length} ${unitLabel}, ${totalModuleCount} Module · Pfad ${_esc(pathLabel)} · Ausstattung ${_esc(klasseLabel)}
Pfad ${_esc(pathLabel)} ${areaLabel} ${totalArea.toFixed(0)} m² ${unitLabel} ${indoorRooms.length} Module ${totalModuleCount} Ausstattung ${_esc(klasseLabel)}
Erste Grobschätzung

Diese Schätzung gibt Ihnen eine erste Hausnummer auf Basis Ihrer Angaben im Online-Konfigurator. Sie ist unverbindlich und ersetzt kein Angebot — die finalen Werte ermitteln wir gemeinsam bei der Vor-Ort-Begehung.

${positionRows} ${installCategory}
Pos. Beschreibung Menge Einheit Einzelpreis Gesamt
Material € ${_eur(material)}
Installation (${install_hours} h) € ${_eur(install_cost)}
Inbetriebnahme (${commissioning_hours} h) € ${_eur(commissioning_cost)}
Steuerfrei · Kleinunternehmerregelung § 6 Abs. 1 Z 27 UStG € 0,00
Geschätzte Gesamtsumme € ${_eur(total)}
Bandbreite ±15 % € ${_eur(lower)} – € ${_eur(upper)}
Preis pro m² (Mittelwert) € ${_eur(pricePerSqm)}
Hinweise zur Schätzung
Gültigkeit
30 Tage ab Schätzungsdatum, bis ${validUntil}.
Bandbreite
±15 % auf den Endpreis. Ausreisser nach oben durch Sonderwünsche, Ausreisser nach unten durch Eigenleistung möglich.
Material
Listenpreise 2026 ±15 %. KNX/DALI-Komponenten teurer als Funk-Lösungen — wir wählen pro ${isGewerbe ? "Bereich" : "Raum"} die saubere Variante.
Stundensätze
Laut WKO-Regiestundensätze 2026 (Österreich) inkl. Überstundenzuschlag. Monteur € ${stundensatz.monteur.toFixed(2).replace('.', ',')}/h, Spezial-Monteur € ${stundensatz.spezial_monteur.toFixed(2).replace('.', ',')}/h.
Nächster Schritt
Vor-Ort-Begehung (kostenlos, ca. 60 min) → verbindliches Angebot innerhalb von 5 Werktagen.
Hinweis
Diese Schätzung ist kein Angebot im Sinne des § 861 ABGB und löst keine Vertragsbindung aus.

Wenn Sie die Zahlen für Ihre Planung passen, vereinbaren wir gerne einen Vor-Ort-Termin. Bei Rückfragen melden Sie sich jederzeit — telefonisch oder per E-Mail.

Mit freundlichen Grüßen
Ing. Thomas Basting
Smartmacherei
`; } /* ============================================================ STEP 3 — Summary + Price ============================================================ */ function KfgStep3Summary({ pathKey, rooms, modules, assignments, moduleCounts, ausstattungsklasse = 1, onBack, onRestart }) { const indoorRooms = rooms.filter(r => !r.outdoor); const totalModuleCount = Object.values(assignments).flat().length; const totalArea = indoorRooms.reduce((s,r) => s + (Number(r.area) || 0), 0); const safeAreaForDiv = totalArea > 0 ? totalArea : 1; const pathLabel = (pathKey || "").toUpperCase() || "ALLGEMEIN"; const klasseLabel = (window.KLASSE_LABELS || ["Mindest","Standard","Komfort"])[ausstattungsklasse] || "Standard"; const stundensatz = (window.WKO_STUNDENSATZ || { monteur: 70.66, spezial_monteur: 91.28 }); const isGewerbe = pathKey === "gewerbe"; const areaLabel = isGewerbe ? "Nutzfläche" : "Wohnfläche"; const unitLabel = isGewerbe ? "Bereiche" : "Räume"; // Aufschluesselung Material / Installation / Inbetriebnahme. const breakdown = window.priceBreakdown ? window.priceBreakdown(rooms, assignments, modules, moduleCounts, ausstattungsklasse, pathKey) : null; const material = breakdown ? breakdown.material : 0; const install_cost = breakdown ? breakdown.install_cost : 0; const install_hours = breakdown ? breakdown.install_hours : 0; const commissioning_cost = breakdown ? breakdown.commissioning_cost : 0; const commissioning_hours = breakdown ? breakdown.commissioning_hours : 0; const total = breakdown ? breakdown.total : 0; const lower = breakdown ? breakdown.range_low : 0; const upper = breakdown ? breakdown.range_high : 0; // Plain-Text-Summary fuer Mail / Print. const buildSummary = () => { const lines = []; lines.push(`Smartmacherei -- Konfigurator-Empfehlung`); lines.push(`Pfad: ${pathLabel} Ausstattung: ${klasseLabel}`); lines.push(`${unitLabel}: ${indoorRooms.length} Module: ${totalModuleCount} ${areaLabel}: ${totalArea.toFixed(0)} m²`); lines.push(`Schätzung: ab €${lower.toLocaleString('de-AT')} (Bandbreite €${lower.toLocaleString('de-AT')} – €${upper.toLocaleString('de-AT')})`); lines.push(``); lines.push(`${unitLabel} im Detail:`); for (const r of indoorRooms) { const mods = assignments[r.id] || []; const modNames = mods.map(m => modules[m]?.label || m).join(", "); const roomPrice = mods.reduce((s,m) => s + (modules[m] ? window.getModulePrice(modules[m], r, moduleCounts, r.id, m, ausstattungsklasse) : 0), 0); lines.push(` - ${r.name} (${r.area} m², ${r.windows ?? 0} Fenster) -- Material €${Math.round(roomPrice).toLocaleString('de-AT')}`); if (mods.length) lines.push(` Module: ${modNames}`); } lines.push(``); lines.push(`Kosten-Aufschluesselung:`); lines.push(` Material (Aktoren, Sensoren, Taster): €${material.toLocaleString('de-AT')}`); lines.push(` Installation (${install_hours} h × €${stundensatz.monteur.toFixed(2)} Monteur): €${install_cost.toLocaleString('de-AT')}`); lines.push(` Inbetriebnahme (${commissioning_hours} h × €${stundensatz.spezial_monteur.toFixed(2)} Spezial): €${commissioning_cost.toLocaleString('de-AT')}`); lines.push(` ─────────────────────────────────────────────`); lines.push(` Geschätzte Gesamtsumme: €${total.toLocaleString('de-AT')} (±18%)`); lines.push(` Preis pro m²: ~€${Math.round(total/safeAreaForDiv).toLocaleString('de-AT')}`); lines.push(``); lines.push(`Stundensätze laut WKO Regiestundensätze 2026 (Österreich).`); return lines.join("\n"); }; const onAppointment = () => { // Snapshot in localStorage -> ContactForm liest + prefilled message-Feld. try { window.localStorage.setItem("sm-kfg-summary", buildSummary()); window.localStorage.setItem("sm-kfg-path", pathKey || ""); } catch (e) { /* private mode */ } // Sanft zum Kontakt-Anker scrollen. if (window.smoothScrollTo) { window.smoothScrollTo("kontakt", 1600); } else { const el = document.getElementById("kontakt"); if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); } // ContactForm-Listener kann auf das Storage-Event reagieren wenn schon // sichtbar ist. Wir feuern auch ein custom event als Sicherheits-Netz. window.dispatchEvent(new CustomEvent("sm-kfg-summary-updated")); }; // PDF-Export: oeffnet ein neues Browser-Fenster mit einem sauberen, // druck-optimierten HTML-Dokument. Der User druckt dieses Fenster und // waehlt im Druck-Dialog "Als PDF speichern". Vorteile gegenueber dem // alten window.print() im Haupt-Fenster: keine CSS-Konflikte mit der // Live-Site, sauberes A4-Layout, der User sieht eine echte Vorschau // bevor er druckt. const onPdfPrint = () => { const html = buildPdfHtml({ pathKey, pathLabel, klasseLabel, indoorRooms, modules, assignments, moduleCounts, ausstattungsklasse, totalArea, totalModuleCount, material, install_cost, install_hours, commissioning_cost, commissioning_hours, total, lower, upper, stundensatz, safeAreaForDiv, }); const w = window.open("", "_blank", "width=920,height=1200"); if (!w) { alert("Bitte Pop-ups erlauben, um die PDF-Vorschau zu öffnen."); return; } w.document.open(); w.document.write(html); w.document.close(); // Nach Load auto-print. setTimeout damit die Schriften + SVG-Render // fertig sind bevor der Druck-Dialog kommt. const tryPrint = () => { try { w.focus(); w.print(); } catch (e) { /* User can manuell drucken */ } }; if (w.document.readyState === "complete") setTimeout(tryPrint, 300); else w.addEventListener("load", () => setTimeout(tryPrint, 300)); }; // ───── Paketempfehlung aus Zustand ableiten ───── // Mapping auf die Pakete in pakete-section.jsx (A-I). const allAssignedMods = Object.values(assignments).flat(); const hasEnergyMod = allAssignedMods.includes("energie") || allAssignedMods.includes("klima"); const hasFullSet = ["licht","beschattung","heizung"].every(m => allAssignedMods.includes(m)); let recommendedPkg; if (pathKey === "gewerbe") { const names = indoorRooms.map(r => `${r.name} ${r.type || ""}`.toLowerCase()).join(" "); const hasHotelSignal = /hotel|gast|gäste|gaeste|apartment|pension|suite/.test(names); const hasOfficeSignal = /büro|buero|office|meeting|besprech|seminar|kanzlei|ordination/.test(names); recommendedPkg = hasHotelSignal ? { id: "hotel", title: "Hotel-Paket: Zimmer & Betrieb", price: "ab 6.500 €", why: "Zimmer-/Gästebereiche erkannt — hier zählen Belegung, Klima, Zutritt und Technikstatus im Betrieb." } : hasOfficeSignal ? { id: "buero", title: "Büro-Paket: Komfort & Präsenz", price: "ab 4.800 €", why: "Büro- oder Meetingbereiche erkannt — Präsenz, Licht, Beschattung, Raumklima und Zutritt sind die wichtigsten Hebel." } : { id: "gewerbe", title: "Gewerbe-Starter: Energie & Technik", price: "ab 2.900 €", why: hasEnergyMod ? "Energiemonitoring und Technikstatus stehen im Vordergrund — ein guter Einstieg für Betriebsgebäude." : "Einstieg für Gewerbeobjekte mit Fokus auf Technikstatus, Betriebsdashboard und sauberer Schnittstellenprüfung." }; } else if (totalModuleCount < 4 || indoorRooms.length < 3) { recommendedPkg = { id: "check", title: "Smart-Home-Check vor Ort", price: "ab 349 €", why: "Bei geringem Umfang oder noch unklarer Bestandssituation ist ein Vor-Ort-Check der saubere Einstieg." }; } else if (pathKey === "neubau" && hasFullSet && indoorRooms.length >= 5) { recommendedPkg = totalArea > 200 ? { id: "loxone", title: "Loxone Planung & Integration", price: "ab 2.900 €", why: "Größerer Neubau mit Licht, Beschattung und Heizung — robuste lokale Automatisierung empfehlenswert." } : { id: "neubau", title: "Neubau Smart-Home-Konzept", price: "ab 1.500 €", why: "Neubau mit vielen Räumen — Konzept- und Lastenheft vor der Elektroplanung lohnt sich." }; } else if (hasEnergyMod) { recommendedPkg = { id: "energie", title: "Energiepaket PV / Wärmepumpe / Wallbox", price: "ab 1.900 €", why: "Energie-/Klima-Module gewählt — sichtbar machen von PV, Wärmepumpe, Speicher und Wallbox steht im Fokus." }; } else { recommendedPkg = { id: "ha-starter", title: "Home-Assistant-Starter", price: "ab 1.250 €", why: "Mehrere Gewerke gewählt — Home Assistant als zentrale Oberfläche ist der naheliegende Einstieg." }; } // Kontakt-CTA mit Paket-Subject vorbefüllen. const onPaketAnfrage = () => { try { window.localStorage.setItem("sm-kfg-summary", buildSummary() + `\n\nEmpfohlenes Paket: ${recommendedPkg.title} (${recommendedPkg.price})`); window.localStorage.setItem("sm-kfg-path", pathKey || ""); window.localStorage.setItem("sm-paket-subject", `Anfrage: ${recommendedPkg.title}`); } catch (e) { /* private */ } if (window.smoothScrollTo) window.smoothScrollTo("kontakt", 1600); window.dispatchEvent(new CustomEvent("sm-kfg-summary-updated")); window.dispatchEvent(new CustomEvent("sm-paket-cta", { detail: { id: recommendedPkg.id, title: recommendedPkg.title } })); }; return (

Deine Empfehlung

Eine erste Schätzung basierend auf {indoorRooms.length} {unitLabel.toLowerCase()} und {totalModuleCount} Modulen. Verbindlich wird's nach dem Vor-Ort-Termin.

{/* Hinweis: Grobeinschätzung — rechtliche Absicherung */}
Grobeinschätzung, kein verbindliches Angebot.{" "} Der Konfigurator liefert eine erste Orientierung. Das finale Angebot entsteht nach Vor-Ort-Check, Schnittstellenprüfung und Abstimmung der Hardware. Elektroinstallationsarbeiten erfolgen über konzessionierte Partnerbetriebe.
{indoorRooms.map(r => { const mods = assignments[r.id] || []; const roomPrice = mods.reduce((s,m) => s + (modules[m] ? window.getModulePrice(modules[m], r, moduleCounts, r.id, m, ausstattungsklasse) : 0), 0); return (
{r.name}
{r.floor} · {r.area} m² · {r.windows} Fenster · {mods.length} Module
€{roomPrice.toLocaleString('de-AT')}
{mods.length === 0 ? ( Keine Module ausgewählt ) : mods.map(m => modules[m] ? ( {modules[m].icon} {modules[m].label} ) : null)}
); })}

Grobschätzung · {pathLabel} · {klasseLabel}

ab€{lower.toLocaleString('de-AT')}
Bandbreite: €{lower.toLocaleString('de-AT')} – €{upper.toLocaleString('de-AT')}
Material
Aktoren, Sensoren, Taster
€{material.toLocaleString('de-AT')}
Installation
{install_hours} h × €{stundensatz.monteur.toFixed(2)} Monteur
€{install_cost.toLocaleString('de-AT')}
Inbetriebnahme
{commissioning_hours} h × €{stundensatz.spezial_monteur.toFixed(2)} Spezial
€{commissioning_cost.toLocaleString('de-AT')}
Geschätzte Gesamtsumme €{total.toLocaleString('de-AT')}
{areaLabel}{totalArea.toFixed(0)} m²
Preis pro m²~€{Math.round(total/safeAreaForDiv).toLocaleString('de-AT')}
{/* Paketempfehlung — leitet zur passenden Variante in pakete-section */}
▶ Empfohlenes Paket
{recommendedPkg.title}
{recommendedPkg.price}
{recommendedPkg.why}

Unverbindliche Erstschätzung. Stundensätze laut WKO 2026 (AT). Endpreis hängt von Vor-Ort-Begehung, Materialqualität und individuellen Wünschen ab. Bandbreite ±18%. Elektroinstallationsarbeiten erfolgen über konzessionierte Partnerbetriebe.

); } window.KfgStep1Manual = KfgStep1Manual; window.KfgStep1Import = KfgStep1Import; window.KfgStep2Modules = KfgStep2Modules; window.KfgStep3Summary = KfgStep3Summary;