/* 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 => (
setActiveFloor(f)}>
{f} {rooms.filter(r => r.floor === f).length || ""}
))}
{copy.library}
{roomTypes.map(rt => (
addRoom(rt.type)}>
{rt.icon}
{rt.label}
~{rt.defaultArea}m² · {rt.defaultWindows} Fenster
))}
{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}
) : (
)}
← Zurück
{rooms.length} {copy.total} · {totalArea.toFixed(1)} m²
{copy.next}
);
}
/* ============================================================
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
{ e.stopPropagation(); startDemo(); }}>
▶ Demo starten · EFH Basting
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"}
setPhase("drop")}>← Erneut versuchen
)}
{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)}m²
Fläche
{floors.length}
Stockwerk{floors.length > 1 ? "e" : ""}
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") && (
← Zurück
Tipp: „Demo starten" zeigt einen echten EFH-Plan
)}
{phase === "review" && (
setPhase("drop")}>← Anderen Plan laden
onComplete(isDemo ? BASTING_ROOMS : adaptServerRoomsToConfigurator(displayRooms))}>
Stimmt — weiter zu den Modulen →
)}
);
}
/* ============================================================
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 ? (
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 (
{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 ;
}
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 (
}>
);
})()}
{/* 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"}
setTemp(Math.max(15, temp - 0.5))} style={{
width: 28, height: 28, borderRadius: 6, border: `1px solid ${SMD.border}`,
background: SMD.bgDeep, color: SMD.text, cursor: "pointer", fontSize: 14,
}}>−
{temp.toFixed(1)} °C
setTemp(Math.min(28, temp + 0.5))} style={{
width: 28, height: 28, borderRadius: 6, border: `1px solid ${SMD.border}`,
background: SMD.bgDeep, color: SMD.text, cursor: "pointer", fontSize: 14,
}}>+
)}
{/* 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 (
setScene(s.id)} style={{
background: active ? `${SMD.accent}14` : SMD.bgCard,
border: `1px solid ${active ? SMD.borderHi : SMD.border}`,
borderRadius: 8, padding: "8px 10px", display: "flex", alignItems: "center", gap: 8,
cursor: "pointer", textAlign: "left", color: "inherit",
fontFamily: "Inter, sans-serif",
}}>
{Icons.scenes(active ? SMD.accent : SMD.soft)}
{s.name}
);
})}
)}
{/* 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 (
setActiveRoomId(r.id)}
>
{count}
);
})}
{/* 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:"}
setPreviewDevice("tablet")}
>
Web / Tablet
setPreviewDevice("phone")}
>
Handy
setPreviewFullscreen(true)}
aria-label="Vorschau maximieren"
title="Vorschau maximieren"
>
⤢ Maximieren
{previewDevice === "tablet" ? (
▲ Tablet · Wandhalterung oder mobil
) : (
▲ Smartphone · iOS / Android
)}
{previewFullscreen && (
{ if (e.target === e.currentTarget) setPreviewFullscreen(false); }}
>
setPreviewFullscreen(false)}
aria-label="Schliessen"
title="Schliessen (ESC)"
>×
{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 (
setAusstattungsklasse(i)}
style={{
padding: "6px 14px", borderRadius: 100, border: 0, cursor: "pointer",
background: active ? KFG_PALETTE.accent : "transparent",
color: active ? KFG_PALETTE.paper : KFG_PALETTE.muted,
fontFamily: "var(--font-mono, monospace)", fontSize: 11,
letterSpacing: "0.08em", textTransform: "uppercase",
fontWeight: active ? 600 : 400, transition: "all 0.15s",
}}
title={`Ausstattungsklasse ${i + 1} -- ${lbl}`}
>
{lbl}
);
})}
)}
{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()}>
setCount(key, count - 1)} aria-label={`${mod.unit} weniger`}>−
{count}
setCount(key, count + 1)} aria-label={`${mod.unit} mehr`}>+
) :
}
€{price.toLocaleString('de-AT')}
);
})}
← Zurück
Empfehlung ansehen →
);
}
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 · 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.
Pos.
Beschreibung
Menge
Einheit
Einzelpreis
Gesamt
${positionRows}
${installCategory}
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')}
MaterialAktoren, 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')}
Termin vereinbaren →
Schätzung als PDF
{/* Paketempfehlung — leitet zur passenden Variante in pakete-section */}
▶ Empfohlenes Paket
{recommendedPkg.title}
{recommendedPkg.price}
{recommendedPkg.why}
{recommendedPkg.title} anfragen →
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.
← Module bearbeiten
↻ Neu starten
);
}
window.KfgStep1Manual = KfgStep1Manual;
window.KfgStep1Import = KfgStep1Import;
window.KfgStep2Modules = KfgStep2Modules;
window.KfgStep3Summary = KfgStep3Summary;