v0.0.2: pre-productie (Bootstrap 5.3.3, LightPdf klant-PDF, server-setup en release-script)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-15 11:58:52 +02:00
co-authored by Claude Opus 5.5
parent bffa053c05
commit 7d302dc405
48 changed files with 2769 additions and 698 deletions
+209
View File
@@ -0,0 +1,209 @@
/* Bestek uit een woningontwerp (house-design-JSON), voor een offerte.
*
* Rekent met de engine van de configurator (static/configurator/): HouseModel.computeParts zoals de PDF,
* HouseModel.computeTotals en CostModel.compute met de kosten-CSV's. Elke kostenpost (materiaal + arbeid +
* machine-uren, zoals CostModel.costOf) krijgt de algemene kosten en marge erbij en gaat naar een hoofdstuk;
* transport en kraan worden een eigen hoofdstuk. Samen precies het totaal excl. btw van de configurator.
* Productieposten (schroeven, zaagsneden, boorgaten, plaatsen) komen per hoofdstuk samen in één regel
* "Bevestiging en bewerking"; zo heeft elke andere post een zuivere prijs per eenheid.
* Posten met een vaste prijs per eenheid (wcd, toilet, rabat per m², ...) krijgen een product: SKU = de code uit
* de kosten van de configurator (cost-defaults.js / leveranciers.csv), bijv. elektra.wcdDubbel.
*/
(() => {
const root = document.getElementById('bestek');
const $ = (sel) => root.querySelector(sel);
const esc = (s) => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
const nl = (n, d = 0) => n.toLocaleString('nl-NL', { minimumFractionDigits: d, maximumFractionDigits: d });
const eur = (n) => '€ ' + nl(n, 2);
// Hoofdstukken van het bestek; een post gaat naar het hoofdstuk van zijn activiteit (ACT) of anders zijn categorie.
const CHAPTERS = [
['casco', 'Casco en constructie'], ['isolatie', 'Isolatie'], ['gevel', 'Gevelbekleding'],
['dak', 'Dak, dakrand en overkapping'], ['vloer', 'Vloer en fundering'], ['kozijnen', 'Kozijnen, ramen en deuren'],
['afwerking', 'Binnenafwerking'], ['inrichting', 'Inrichting'], ['verlichting', 'Verlichting'], ['elektra', 'Elektra'],
['transport', 'Transport en plaatsing']
];
const BY_CAT = { Hout: 'casco', Staal: 'casco', Schroeven: 'casco', Plaatwerk: 'casco', Isolatie: 'isolatie', Gevel: 'gevel',
Dak: 'dak', Vloer: 'vloer', Kozijnen: 'kozijnen', Afwerking: 'afwerking', Inrichting: 'inrichting', Verlichting: 'verlichting', Elektra: 'elektra' };
const BY_ACT = { Latwerk: 'gevel', Dak: 'dak' };
const chapterOf = (l) => BY_ACT[l.act] || BY_CAT[l.cat] || 'casco';
// Productieposten: wel in de prijs, niet als regel in het bestek.
const hidden = (l) => l.cat === 'Schroeven' || l.unit === 'sneden' || /^(Boorgaten|Plaatsen en vastzetten|Plaatschroeven)/.test(l.label);
// Omschrijving zonder de details per element (aantallen tussen haakjes), zodat gelijke posten samengaan;
// kozijnen houden hun maat en uitvoering.
function clean(l) {
let s = l.label;
if (l.cat !== 'Kozijnen') s = s.replace(/\s*\([^)]*\)/g, '');
return s.replace(/^Regelwerk \d+ st\.$/, 'Regelwerk wanden').trim();
}
const roundQty = (q, unit) => Math.round(q * (unit === 'm³' ? 100 : 10)) / (unit === 'm³' ? 100 : 10);
const HIDDEN_LABEL = { casco: 'Bevestiging en bewerking (schroeven, zaagwerk, plaatsen)', elektra: 'Bevestiging en bewerking (boorwerk door het regelwerk)' };
const slug = (t) => t.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/²/g, '2').replace(/×/g, 'x').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
// Product bij een post: [test, code | (label, regel) => code, naam]. Alleen posten waarvan de prijs per eenheid vast
// is (niet: regelwerk, vochtdoek, latwerk, groepenkast, verdeeldozen; die hangen af van het ontwerp).
const PRODUCT_RULES = [
[/^Wandcontactdozen dubbel/, 'elektra.wcdDubbel', 'Wandcontactdoos dubbel, inbouw'],
[/^Schakelaars/, 'elektra.schakelaar', 'Schakelaar, inbouw'],
[/^Aansluitpunt koken/, 'elektra.perilex', 'Aansluitpunt kookgroep (perilex)'],
[/^Aansluitpunt warmtepomp/, 'elektra.werkschakelaar', 'Aansluitpunt warmtepomp (werkschakelaar)'],
[/^Aansluitpunt /, 'elektra.wcdEnkel', 'Aansluitpunt apparaat (wcd enkel, eigen groep)'],
[/^Vloerdoos/, 'elektra.vloerdoosSt', 'Vloerdoos met dubbele wcd'],
[/^Spotdozen/, 'elektra.spotdoosSt', 'Spotdoos plafond, luchtdicht'],
[/^Kabeldoorvoer/, 'elektra.doorvoer', 'Kabeldoorvoer naar buiten, luchtdicht'],
[/^Steekverbinder module-naad 3/, 'elektra.steekverbinder3', 'Steekverbinder module-naad 3-polig (set)'],
[/^Steekverbinder module-naad 5/, 'elektra.steekverbinder5', 'Steekverbinder module-naad 5-polig (set)'],
[(l) => l.cat === 'Elektra' && l.unit === 'm', (l) => `elektra.leiding.${slug(l.label.split(':')[0])}`, (l) => `Leiding ${l.label.split(':')[0]}, gelegd`],
[/^Gevellampen/, 'verlichting.gevellamp', 'Gevellamp'],
[/^Boeideel-spots/, 'verlichting.boeideelSpot', 'Boeideel-spot'],
[/^Binnenspots/, 'verlichting.binnenspot', 'Binnenspot'],
[/^Voordeur /, 'kozijnen.voordeur', 'Voordeur incl. kozijn'],
[/^Deur /, 'kozijnen.deur', 'Deur incl. kozijn'],
[/^Schuifdeur /, 'kozijnen.schuifdeurBlad', 'Schuifdeur per blad'],
[/^Raam \d+×\d+/, (l) => `kozijnen.raam.${l.label.match(/\d+×\d+/)[0].replace('×', 'x')}`, (l) => `Raamkozijn ${l.label.match(/\d+×\d+/)[0]} mm incl. glas`],
[/^Rabat$/, 'gevel.rabatM2', 'Rabat gevelbekleding'],
[/^Steenstrips$/, 'gevel.steenM2', 'Steenstrips'],
[/^Stucwerk$/, 'gevel.stucM2', 'Stucwerk gevel'],
[(l) => l.cat === 'Gevel' && /^Felsplaten/.test(l.label), 'gevel.felsM2', 'Felsplaten gevel (staande naad)'],
[/^Afdekker trasraam/, 'gevel.afdekkerM', 'Afdekker trasraam'],
[/^Isolatieblok steen/, 'isolatie.steenIsolatieM3', 'Isolatieblok steengevel'],
[/^Glaswol/, 'isolatie.glaswolM3', 'Glaswol isolatie'],
[/^Dakleer/, 'dak.dakleerM2', 'Dakleer (EPDM)'],
[/^Riet$/, 'dak.rietM2', 'Rieten dakbedekking'],
[(l) => l.cat === 'Dak' && /^Felsplaten/.test(l.label), 'dak.felsM2', 'Felsplaten dak (staande naad)'],
[/^Boeideel \d+/, 'dak.boeideelM', 'Boeideel'],
[/^Onderplaat boeideel/, 'dak.onderplaatM2', 'Onderplaat boeideel'],
[/^Daktrim/, 'dak.daktrimM', 'Daktrim'],
[/^Ventilatierooster/, 'dak.ventRoosterM', 'Ventilatierooster onder boeideel'],
[/^Paalvoeten/, 'dak.paalvoet', 'Paalvoet verzinkt, verstelbaar'],
[/^Dakbeschot/, 'dak.beschotM2', 'Dakbeschot OSB 18 mm'],
[/^Spaanplaat$/, 'plaat.spaanplaatM2', 'Spaanplaat'],
[/^Cementplaat$/, 'plaat.cementplaatM2', 'Cementgebonden plaat'],
[/^OSB /, 'plaat.osbM2', 'OSB-plaat'],
[/^Vloerafwerking/, 'vloer.afwerkingM2', 'Vloerafwerking'],
[/vals plafond/, 'afwerking.valsPlafondM2', 'Vals plafond (gipsplaat, afgewerkt)'],
[/FIBO-wandpanelen/, 'afwerking.fiboM2', 'FIBO-wandpaneel']
];
// Inrichting: de code volgt het stuk zelf (FixtureModel: f.cost), de omschrijving is die van CostModel.
function fixtureCodes(parts) {
const map = {};
(parts.fixtures || []).filter(f => f.cost).forEach(f => {
const perM = { keuken: ['Keukenblok', 'keukenM'], kookeiland: ['Kookeiland', 'kookeilandM'], kast: [f.label, 'kastM'] }[f.cost];
map[perM ? `${perM[0]} ${f.w} mm` : f.label] = perM ? perM[1] : f.cost;
if (f.opts && f.opts.kookplaat) map.Inductiekookplaat = 'kookplaat';
});
return map;
}
function productOf(row, fixtures) {
let code = null, name = null;
if (row.cat === 'Inrichting' && fixtures[row.label]) {
code = `inrichting.${fixtures[row.label]}`;
name = row.label.replace(/ \d+ mm$/, '');
} else {
const rule = PRODUCT_RULES.find(([t]) => typeof t === 'function' ? t(row) : t.test(row.label));
if (!rule) return null;
code = typeof rule[1] === 'function' ? rule[1](row) : rule[1];
name = typeof rule[2] === 'function' ? rule[2](row) : rule[2];
}
const [g, k] = code.split('.'), def = (window.COST_DEFAULTS[g] && window.COST_DEFAULTS[g].items[k]) || null;
const sup = CostCsv.entry('leveranciers', code) || {};
return { sku: code, name, description: [def && def.label, 'Prijs uit de configurator: materiaal en montage, incl. opslagen en marge.'].filter(Boolean).join('\n'),
supplier: sup.supplier || '', article: sup.article || '' };
}
function build(st, parts, meta) {
const totals = HouseModel.computeTotals(parts);
const cost = CostModel.compute(parts, totals);
const S = cost.settings, O = S.opslag, A = S.arbeid, B = S.bedrijf;
const perHour = B.machines + B.slijtage + B.huisvesting;
const factor = (1 + O.algemeen / 100) * (1 + O.marge / 100);
// Zelfde opbouw als costOf in CostModel.compute.
const lineCost = (l) => {
const direct = l.sec / 3600, indirect = direct * A.toeslagUren / 100, qa = (direct + indirect) * A.qa / 100;
return l.mat * (1 + (O.verlies + O.klein) / 100) + (direct + indirect + qa) * A.uurloon + l.clock / 3600 * (1 + A.toeslagUren / 100) * perHour;
};
const chapters = new Map(CHAPTERS.map(([key, name]) => [key, { key, name, price: 0, rows: new Map(), info: [] }]));
cost.elements.forEach(el => el.lines.forEach(l => {
const ch = chapters.get(chapterOf(l)), price = lineCost(l) * factor;
ch.price += price;
const label = hidden(l) ? (HIDDEN_LABEL[ch.key] || 'Bevestiging en bewerking') : clean(l);
const unit = hidden(l) ? 'post' : l.unit, key = `${hidden(l) ? '~' : ''}${label}|${unit}`;
const row = ch.rows.get(key) || { label, unit, qty: 0, n: 0, cost: 0, cat: l.cat, hidden: hidden(l) };
row.qty = row.hidden ? 1 : row.qty + l.qty; row.n += 1; row.cost += price;
ch.rows.set(key, row);
}));
const fixtures = fixtureCodes(parts);
const n = parts.units.length, tr = chapters.get('transport');
tr.rows.set('t', { label: `Transport modules naar de bouwplaats`, unit: 'unit', qty: n, n: 1, cost: n * B.transportUnit * factor });
tr.rows.set('k', { label: 'Plaatsen met kraan', unit: 'post', qty: 1, n: 1, cost: B.kraan * factor });
tr.price = (n * B.transportUnit + B.kraan) * factor;
const out = [];
chapters.forEach(ch => {
if (!(ch.price > 0.005)) return;
// Bevestiging en bewerking als laatste regel van het hoofdstuk (daar komt ook het afrondingsverschil).
const rows = Array.from(ch.rows.values()).filter(r => r.qty > 0 && (r.cost > 0.005 || !r.hidden)).sort((a, b) => (a.hidden ? 1 : 0) - (b.hidden ? 1 : 0));
const lines = rows.map(r => {
// Ramen tellen per stuk (de prijs rekent per m²); een raam van een andere maat is een andere post.
const perPiece = r.cat === 'Kozijnen' && r.unit === 'm²';
const q0 = roundQty(r.qty, r.unit), qty = perPiece ? r.n : q0 > 0 ? q0 : Math.round(r.qty * 1000) / 1000 || 1;
const line = { label: r.label, qty, unit: perPiece ? 'st.' : r.unit, unitPrice: Math.round(r.cost / qty * 10000) / 10000, price: Math.round(r.cost * 100) / 100 };
const product = r.hidden ? null : productOf(r, fixtures);
if (product) line.product = product;
return line;
});
if (ch.key === 'elektra') {
const m = rows.filter(r => r.unit === 'm').reduce((t, r) => t + r.qty, 0);
if (m > 0) ch.info.push({ label: 'Totaal leidingwerk (buis en kabel)', qty: roundQty(m, 'm'), unit: 'm' });
}
out.push({ key: ch.key, name: ch.name, price: Math.round(ch.price * 100) / 100, lines, info: ch.info });
});
const sum = out.reduce((t, c) => t + c.price, 0);
if (Math.abs(sum - cost.summary.exBtw) > 1) console.warn('Bestek wijkt af van de configurator', sum, cost.summary.exBtw);
return Object.assign({ version: 1, computedAt: new Date().toISOString(), vat: O.btw, chapters: out,
totals: { exBtw: Math.round(cost.summary.exBtw * 100) / 100, btw: Math.round(cost.summary.btw * 100) / 100, inclBtw: Math.round(cost.summary.inclBtw * 100) / 100 },
units: n }, meta);
}
function render(b) {
$('.bs-body').innerHTML = b.chapters.map(ch => `
<section class="bs-ch">
<div class="bs-ch-head"><h2>${esc(ch.name)}</h2><b>${eur(ch.price)}</b></div>
<table><tbody>${ch.lines.map(l => `<tr><td>${esc(l.label)}${l.product ? ` <span class="chip" title="Product ${esc(l.product.name)}">${esc(l.product.sku)}</span>` : ''}</td>
<td class="num">${nl(l.qty, l.qty % 1 ? (l.unit === 'm³' ? 2 : 1) : 0)} ${esc(l.unit)}</td><td class="num muted">× ${eur(l.unitPrice)}</td><td class="num">${eur(l.price)}</td></tr>`).join('')}
${ch.info.map(l => `<tr class="bs-info"><td>${esc(l.label)}</td><td class="num">${nl(l.qty, 1)} ${esc(l.unit)}</td><td></td><td></td></tr>`).join('')}</tbody></table>
</section>`).join('');
$('.bs-total').innerHTML = `<div><span>Totaal excl. btw</span><b>${eur(b.totals.exBtw)}</b></div>
<div><span>Btw ${nl(b.vat)}%</span><b>${eur(b.totals.btw)}</b></div>
<div class="grand"><span>Totaal incl. btw</span><b>${eur(b.totals.inclBtw)}</b></div>`;
root.querySelector('[name=bestek]').value = JSON.stringify(b);
root.querySelector('button[type=submit]').disabled = false;
}
let st, parts;
try {
const design = JSON.parse(document.getElementById('design').textContent);
st = HouseModel.normalize(HouseModel.clone(design));
parts = HouseModel.computeParts(st, {
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials(),
elektraRough: false
});
} catch (err) {
console.error(err);
$('.bs-body').innerHTML = `<p class="empty">Dit ontwerp kan niet worden doorgerekend: ${esc(err.message)}</p>`;
return;
}
const meta = JSON.parse(document.getElementById('bestek-meta').textContent);
const run = () => {
try { render(build(st, parts, meta)); } catch (err) {
console.error(err);
$('.bs-body').innerHTML = `<p class="empty">Het bestek kon niet worden berekend: ${esc(err.message)}</p>`;
}
};
// Prijzen komen uit kosten/*.csv (async geladen bij het starten van de engine); daarna rekenen.
let done = false;
const once = () => { if (!done) { done = true; run(); } };
if (CostCsv.status('intern').loadedAt) once();
else { CostCsv.onChange(once); setTimeout(once, 6000); }
CostModel.onChange(() => { if (done) run(); });
})();
+198 -110
View File
@@ -1,4 +1,6 @@
/* Van Emous huisstijl — tokens uit het design system */
/* Van Emous huisstijl bovenop Bootstrap 5.3 — tokens uit het design system.
Layout, grid, formulieren, tabellen en het mobiele menu komen uit Bootstrap;
hier staan alleen de merkkleuren en de onderdelen die Bootstrap niet heeft. */
:root {
--orange: #b95913;
--orange-dark: #a24e11;
@@ -22,77 +24,154 @@
--radius-md: 1rem;
--radius-lg: 1.25rem;
--radius-pill: 50px;
/* Bootstrap op de huisstijl zetten */
--bs-body-font-family: var(--font-body);
--bs-body-font-size: 1rem;
--bs-body-line-height: 1.6;
--bs-body-color: var(--ink);
--bs-body-color-rgb: 33, 33, 33;
--bs-body-bg: var(--ultra-light);
--bs-heading-color: var(--ink);
--bs-primary: #b95913;
--bs-primary-rgb: 185, 89, 19;
--bs-secondary-color: var(--ink-muted);
--bs-border-color: var(--line);
--bs-border-radius: var(--radius-sm);
--bs-border-radius-lg: var(--radius-lg);
--bs-border-radius-pill: var(--radius-pill);
--bs-link-color: #b95913;
--bs-link-color-rgb: 185, 89, 19;
--bs-link-hover-color: #a24e11;
--bs-link-hover-color-rgb: 162, 78, 17;
--bs-focus-ring-color: var(--orange-focus);
--bs-code-color: var(--ink);
}
* { box-sizing: border-box; }
html { font-variant-numeric: lining-nums tabular-nums; }
body { margin: 0; font-family: var(--font-body); font-size: 16px; line-height: 1.6; color: var(--ink); background: var(--ultra-light); display: flex; min-height: 100vh; }
body { min-height: 100vh; }
h1, h2, h3 { font-family: var(--font-heading); font-weight: 500; line-height: 1.2; margin: 0 0 0.75rem; }
h1 { font-size: 2rem; line-height: 1.1; letter-spacing: -0.01em; }
h2 { font-size: 1.3rem; }
h3 { font-size: 1.05rem; margin-top: 1.25rem; }
a { color: var(--orange); font-weight: 600; text-decoration: none; }
a { font-weight: 600; text-decoration: none; }
a:hover { text-decoration: underline; }
a:focus-visible, button:focus-visible, .btn:focus-visible { outline: 3px solid var(--orange); outline-offset: 2px; }
a:focus-visible, button:focus-visible, .btn:focus-visible { outline: 3px solid var(--orange); outline-offset: 2px; box-shadow: none; }
/* zijbalk */
.side { width: 240px; flex: none; background: var(--white); border-right: 1px solid var(--line); padding: 1.5rem 1rem; display: flex; flex-direction: column; position: sticky; top: 0; height: 100vh; }
/* ---------- app-layout: zijbalk (desktop) / uitschuifmenu (mobiel) ---------- */
.side-panel { --bs-offcanvas-width: 270px; --bs-offcanvas-bg: var(--white); }
.side .offcanvas-header img { width: 130px; }
.side .offcanvas-body { display: flex; flex-direction: column; gap: 0.25rem; }
.brand img { width: 170px; display: block; margin: 0 0.75rem 2rem; }
.side nav { display: flex; flex-direction: column; gap: 0.25rem; flex: 1; }
.side nav a, .logout { color: var(--ink); padding: 0.6rem 1rem; border-radius: var(--radius-pill); display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; white-space: nowrap; }
.side nav a, .side .logout { color: var(--ink); padding: 0.6rem 1rem; border-radius: var(--radius-pill); display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; white-space: nowrap; }
.side nav a:hover { background: var(--orange-tint-soft); text-decoration: none; }
.side nav a.active { background: var(--orange); color: var(--white); }
.side .logout { color: var(--ink-muted); }
.count { background: var(--orange-tint); color: var(--orange-dark); font-size: 0.75rem; padding: 0 0.5rem; border-radius: 999px; }
.active .count { background: var(--white); }
.logout { color: var(--ink-muted); }
main { flex: 1; min-width: 0; padding: 2rem 2.5rem; max-width: 1400px; }
.topbar { position: sticky; top: 0; z-index: 1020; background: var(--white); border-bottom: 1px solid var(--line); padding: 0.6rem 1rem; display: flex; align-items: center; gap: 0.75rem; }
.topbar img { width: 120px; display: block; }
.topbar .menu-btn { border: 1px solid var(--line); background: var(--white); border-radius: var(--radius-pill); padding: 0.45rem 0.9rem; display: inline-flex; align-items: center; gap: 0.5rem; font-weight: 600; color: var(--ink); }
.topbar .menu-btn svg { width: 20px; height: 20px; }
.topbar .count { margin-left: auto; }
.app-main { flex: 1; min-width: 0; }
.content { padding: 1.25rem 1rem 2rem; max-width: 1400px; }
@media (min-width: 992px) {
.app { display: flex; min-height: 100vh; }
.side { width: 240px; flex: none; position: sticky; top: 0; height: 100vh; background: var(--white); border-right: 1px solid var(--line); padding: 1.5rem 1rem; display: flex; flex-direction: column; }
.side-panel { display: flex; flex-direction: column; }
.side .offcanvas-body { flex-grow: 1; overflow-y: auto; }
.content { padding: 2rem 2.5rem; }
}
/* ---------- paginakop en hulpklassen ---------- */
.head { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
.head h1 { margin: 0; }
.head h1 { margin: 0; overflow-wrap: anywhere; }
.kicker { font-size: 0.75rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--orange); }
.label { font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); }
.muted { color: var(--ink-muted); }
.empty { color: var(--ink-muted); padding: 1rem 0; }
.card { background: var(--white); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 1.5rem; margin-bottom: 1.5rem; }
.grid { display: grid; gap: 1.5rem; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); margin-bottom: 1.5rem; }
.grid .card { margin: 0; }
.two { display: grid; grid-template-columns: minmax(0, 1fr) 380px; gap: 1.5rem; align-items: start; }
.kpis { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); margin-bottom: 1.5rem; }
.kpi { background: var(--white); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 1.25rem 1.5rem; }
.kpi .value { font-family: var(--font-heading); font-weight: 600; font-size: 2rem; line-height: 1.1; margin: 0.35rem 0 0.15rem; }
/* knoppen en formulieren */
.btn { display: inline-flex; align-items: center; gap: 0.5rem; font: 600 15px/1 var(--font-body); padding: 0.75rem 1.4rem; border-radius: var(--radius-pill); border: 1px solid var(--orange); background: var(--orange); color: var(--white); cursor: pointer; white-space: nowrap; }
.btn:hover { background: var(--orange-dark); text-decoration: none; }
.btn.ghost { background: var(--white); color: var(--orange); border-color: var(--orange-line); }
.btn.ghost:hover { background: var(--orange-tint-soft); border-color: var(--orange); }
.btn.quiet { background: var(--white); color: var(--ink-muted); border-color: var(--line); }
.btn.small { padding: 0.45rem 0.9rem; font-size: 14px; }
form.inline { display: inline; }
label { display: block; font-weight: 600; font-size: 0.9rem; margin-bottom: 0.3rem; }
input, select, textarea { width: 100%; font: 16px/1.4 var(--font-body); color: var(--ink); padding: 0.6rem 0.9rem; border: 1px solid var(--control); border-radius: var(--radius-sm); background: var(--white); }
input[type=checkbox] { width: auto; margin-right: 0.4rem; accent-color: var(--orange); }
input[type=file] { border-style: dashed; }
textarea { min-height: 110px; resize: vertical; }
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--orange); box-shadow: 0 0 0 0.25rem var(--orange-focus); }
.fields { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.fields .full { grid-column: 1 / -1; }
.actions { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; margin-top: 1.25rem; }
.head .actions { margin: 0; }
.empty { color: var(--ink-muted); padding: 1rem 0; margin: 0; }
.hint { font-size: 0.875rem; color: var(--ink-muted); margin: 0.3rem 0 0; }
.actions { display: flex; gap: 0.6rem; flex-wrap: wrap; align-items: center; margin-top: 1.25rem; }
.head .actions, .card-top .actions { margin: 0; }
form.inline { display: inline; }
.search input { width: 260px; }
/* tabellen */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--line); white-space: nowrap; }
td { padding: 0.7rem 0.75rem; border-bottom: 1px solid var(--line); vertical-align: top; }
tbody tr:last-child td { border-bottom: 0; }
@media (max-width: 767.98px) {
h1 { font-size: 1.6rem; }
.head { flex-direction: column; align-items: stretch; }
.head .actions > * { flex: 1 1 auto; }
.head .actions .btn, .head .actions .form-select { width: 100%; }
.head .actions .search { flex-basis: 100%; }
.search input { width: 100%; }
}
/* ---------- kaarten en kerncijfers ---------- */
.card { --bs-card-spacer-y: 1.5rem; --bs-card-spacer-x: 1.5rem; --bs-card-border-color: var(--line); --bs-card-border-radius: var(--radius-lg); --bs-card-inner-border-radius: calc(var(--radius-lg) - 1px); --bs-card-bg: var(--white); margin-bottom: 1.5rem; }
.card-body > :last-child { margin-bottom: 0; }
.card-grid > * > .card { margin-bottom: 0; height: 100%; }
.card-top { display: flex; justify-content: space-between; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 1rem; }
.card-top h2 { margin: 0; }
.card-top .actions { gap: 0.5rem; }
@media (max-width: 575.98px) {
.card { --bs-card-spacer-y: 1.1rem; --bs-card-spacer-x: 1rem; }
}
.kpi { background: var(--white); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 1.25rem 1.5rem; height: 100%; }
.kpi .value { font-family: var(--font-heading); font-weight: 600; font-size: 2rem; line-height: 1.1; margin: 0.35rem 0 0.15rem; white-space: nowrap; }
/* ---------- knoppen ---------- */
.btn { --bs-btn-font-family: var(--font-body); --bs-btn-font-weight: 600; --bs-btn-font-size: 15px; --bs-btn-line-height: 1.1; --bs-btn-padding-x: 1.4rem; --bs-btn-padding-y: 0.72rem; --bs-btn-border-radius: var(--radius-pill); --bs-btn-focus-box-shadow: 0 0 0 0.25rem var(--orange-focus); display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; white-space: nowrap; }
.btn-sm { --bs-btn-padding-x: 0.9rem; --bs-btn-padding-y: 0.45rem; --bs-btn-font-size: 14px; --bs-btn-border-radius: var(--radius-pill); }
.btn-primary { --bs-btn-color: #fff; --bs-btn-bg: #b95913; --bs-btn-border-color: #b95913; --bs-btn-hover-color: #fff; --bs-btn-hover-bg: #a24e11; --bs-btn-hover-border-color: #a24e11; --bs-btn-active-color: #fff; --bs-btn-active-bg: #8f450f; --bs-btn-active-border-color: #8f450f; --bs-btn-disabled-color: #fff; --bs-btn-disabled-bg: #b95913; --bs-btn-disabled-border-color: #b95913; }
.btn-outline-primary { --bs-btn-color: #b95913; --bs-btn-bg: #fff; --bs-btn-border-color: var(--orange-line); --bs-btn-hover-color: #b95913; --bs-btn-hover-bg: var(--orange-tint-soft); --bs-btn-hover-border-color: #b95913; --bs-btn-active-color: #b95913; --bs-btn-active-bg: var(--orange-tint); --bs-btn-active-border-color: #b95913; --bs-btn-disabled-color: #b95913; --bs-btn-disabled-bg: #fff; --bs-btn-disabled-border-color: var(--orange-line); }
.btn-outline-secondary { --bs-btn-color: var(--ink-muted); --bs-btn-bg: #fff; --bs-btn-border-color: rgba(0, 0, 0, 0.12); --bs-btn-hover-color: var(--ink); --bs-btn-hover-bg: var(--light); --bs-btn-hover-border-color: rgba(0, 0, 0, 0.2); --bs-btn-active-color: var(--ink); --bs-btn-active-bg: var(--light); --bs-btn-active-border-color: rgba(0, 0, 0, 0.2); }
.linkbtn { background: none; border: 0; padding: 0; color: var(--ink-muted); font: 600 0.8rem var(--font-body); cursor: pointer; text-decoration: underline; }
/* ---------- formulieren ---------- */
.form-label { font-weight: 600; font-size: 0.9rem; margin-bottom: 0.3rem; }
.form-control, .form-select { --bs-border-color: var(--control); font-size: 16px; line-height: 1.4; padding-top: 0.6rem; padding-bottom: 0.6rem; padding-left: 0.9rem; color: var(--ink); }
.form-control { padding-right: 0.9rem; }
.form-control-sm, .form-select-sm { font-size: 15px; padding-top: 0.45rem; padding-bottom: 0.45rem; padding-left: 0.6rem; }
.form-control-sm { padding-right: 0.6rem; }
textarea.form-control { min-height: 110px; resize: vertical; }
textarea.form-control-sm { min-height: 42px; }
input[type=file].form-control { border-style: dashed; }
.form-control:focus, .form-select:focus { border-color: var(--orange); box-shadow: 0 0 0 0.25rem var(--orange-focus); }
.form-check-input { border-color: var(--control); }
.form-check-input:checked { background-color: var(--orange); border-color: var(--orange); }
.form-check-input:focus { border-color: var(--orange); box-shadow: 0 0 0 0.25rem var(--orange-focus); }
.form-check-label { font-weight: 400; }
/* ---------- tabellen ---------- */
.table { --bs-table-bg: transparent; --bs-table-color: var(--ink); --bs-table-border-color: var(--line); --bs-table-hover-bg: var(--orange-tint-soft); --bs-table-hover-color: var(--ink); margin-bottom: 0; }
.table > :not(caption) > * > * { padding: 0.7rem 0.75rem; vertical-align: top; }
.table > thead th { font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); padding-top: 0.5rem; padding-bottom: 0.5rem; white-space: nowrap; }
.table > tbody > tr:last-child > * { border-bottom-width: 0; }
tr.link { cursor: pointer; }
tr.link:hover td { background: var(--orange-tint-soft); }
.num { text-align: right; white-space: nowrap; }
.thumb-sm { width: 48px; height: 48px; border-radius: 10px; object-fit: cover; background: var(--blue); display: block; }
tr.unread td { font-weight: 600; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--orange); margin-right: 0.4rem; vertical-align: middle; }
/* mobiel: elke tabelrij wordt een blokje met label · waarde */
@media (max-width: 767.98px) {
.table-stack thead { display: none; }
.table-stack > tbody > tr { display: block; padding: 0.8rem 0; border-bottom: 1px solid var(--line); }
.table-stack > tbody > tr:last-child { border-bottom: 0; }
.table-stack > tbody > tr > td { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding: 0.1rem 0; border: 0; text-align: right; box-shadow: none; }
.table-stack > tbody > tr > td::before { content: attr(data-label); flex: none; text-align: left; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); }
.table-stack > tbody > tr > td.stack-title { display: block; text-align: left; padding-bottom: 0.3rem; }
.table-stack > tbody > tr > td.stack-title::before { content: none; }
.table-stack > tbody > tr > td:empty { display: none; }
.table-stack > tbody > tr.link:active { background: var(--orange-tint-soft); }
}
/* ---------- labels / statussen ---------- */
.chip { display: inline-block; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; padding: 0.15rem 0.65rem; border-radius: 999px; background: var(--blue); color: var(--ink); white-space: nowrap; }
.chip.nieuw, .chip.concept { background: var(--orange-tint); color: var(--orange-dark); }
.chip.offerte, .chip.verzonden { background: var(--blue-dark); }
@@ -100,77 +179,55 @@ tr.link:hover td { background: var(--orange-tint-soft); }
.chip.verloren, .chip.afgewezen { background: var(--light); color: var(--ink-muted); }
.filters { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; align-items: center; }
.filters a { padding: 0.35rem 0.9rem; border-radius: 999px; border: 1px solid var(--line); color: var(--ink); background: var(--white); font-size: 0.9rem; }
.filters a { padding: 0.35rem 0.9rem; border-radius: 999px; border: 1px solid var(--line); color: var(--ink); background: var(--white); font-size: 0.9rem; white-space: nowrap; }
.filters a:hover { border-color: var(--orange-line); text-decoration: none; }
.filters a.on { background: var(--orange); border-color: var(--orange); color: var(--white); }
.search { display: flex; gap: 0.5rem; }
.search input { width: 260px; }
.filters a.on .muted { color: inherit; }
@media (max-width: 767.98px) {
/* filterknoppen horizontaal schuiven in plaats van vijf regels hoog */
.filters { flex-wrap: nowrap; overflow-x: auto; margin-left: -1rem; margin-right: -1rem; padding: 0 1rem 0.25rem; scrollbar-width: none; }
.filters::-webkit-scrollbar { display: none; }
.filters > * { flex: none; }
}
.flash { padding: 0.8rem 1.2rem; border-radius: var(--radius-md); background: var(--blue-light); border: 1px solid var(--blue); margin-bottom: 1rem; }
.flash.error { background: var(--orange-tint-soft); border-color: var(--orange-line); }
/* grafieken */
/* ---------- grafieken ---------- */
.bars { display: flex; flex-direction: column; gap: 0.6rem; }
.bar { display: grid; grid-template-columns: 110px 1fr 40px; gap: 0.75rem; align-items: center; color: var(--ink); font-weight: 400; }
.bar { display: grid; grid-template-columns: minmax(80px, 110px) 1fr 40px; gap: 0.75rem; align-items: center; color: var(--ink); font-weight: 400; }
.bar > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
a.bar:hover { text-decoration: none; color: var(--orange); }
.bar .track { display: block; background: var(--blue-light); border-radius: 999px; height: 10px; overflow: hidden; }
.bar .fill { display: block; background: var(--orange); height: 100%; border-radius: 999px; }
.cols { display: flex; align-items: flex-end; gap: 6px; height: 170px; }
.cols > div { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; height: 100%; gap: 4px; font-size: 0.75rem; color: var(--ink-muted); }
.cols > div { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; height: 100%; gap: 4px; font-size: 0.75rem; color: var(--ink-muted); }
.cols .c { width: 100%; background: var(--orange); border-radius: 6px 6px 0 0; min-height: 2px; }
/* contactpagina */
dl.props { display: grid; grid-template-columns: max-content 1fr; gap: 0.4rem 1rem; margin: 0; }
dl.props dt { color: var(--ink-muted); }
dl.props dd { margin: 0; word-break: break-word; white-space: pre-line; }
/* ---------- contact- en relatiepagina ---------- */
dl.props { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 0.4rem 1rem; margin: 0; }
dl.props dt { color: var(--ink-muted); font-weight: 400; }
dl.props dd { margin: 0; overflow-wrap: anywhere; white-space: pre-line; }
.timeline { list-style: none; margin: 0; padding: 0; }
.timeline li { padding: 0.8rem 0; border-bottom: 1px solid var(--line); }
.timeline li:last-child { border: 0; }
.timeline .meta { font-size: 0.8125rem; color: var(--ink-muted); margin-bottom: 0.15rem; }
.timeline .body { white-space: pre-wrap; word-break: break-word; }
.timeline .body { white-space: pre-wrap; overflow-wrap: anywhere; }
.files { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 0.75rem; margin-bottom: 1rem; }
.file { border: 1px solid var(--line); border-radius: var(--radius-md); overflow: hidden; font-size: 0.8rem; background: var(--white); }
.file .thumb { aspect-ratio: 1; background: var(--blue); display: flex; align-items: center; justify-content: center; color: var(--ink); font-weight: 600; text-transform: uppercase; }
.file .thumb { aspect-ratio: 1; background: var(--blue); display: flex; align-items: center; justify-content: center; color: var(--ink); font-weight: 600; text-transform: uppercase; overflow: hidden; }
.file img { width: 100%; height: 100%; object-fit: cover; display: block; }
.file .name { padding: 0.35rem 0.5rem 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
.file form { padding: 0 0.5rem 0.35rem; }
.linkbtn { background: none; border: 0; padding: 0; color: var(--ink-muted); font: 600 0.8rem var(--font-body); cursor: pointer; text-decoration: underline; }
.file .muted { padding: 0 0.5rem 0.35rem; font-size: 0.8rem; }
.viewer-frame { aspect-ratio: 16 / 10; min-height: 320px; border: 1px solid var(--line); border-radius: var(--radius-md); overflow: hidden; background: var(--blue); }
.viewer-frame iframe { display: block; width: 100%; height: 100%; border: 0; }
details summary { cursor: pointer; font-family: var(--font-heading); font-size: 1.3rem; font-weight: 500; }
details[open] summary { margin-bottom: 1rem; }
/* inbox */
.mailbody { white-space: pre-wrap; word-break: break-word; font-size: 15px; line-height: 1.7; }
tr.unread td { font-weight: 600; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--orange); margin-right: 0.4rem; vertical-align: middle; }
/* offerte-editor */
.lines td { padding: 0.4rem 0.3rem; vertical-align: middle; }
.lines input, .lines select, .lines textarea { padding: 0.45rem 0.6rem; font-size: 15px; }
.lines textarea { min-height: 42px; height: 42px; }
.totals { margin-left: auto; width: 320px; max-width: 100%; margin-top: 1rem; }
.totals td { padding: 0.3rem 0.75rem; border: 0; }
.totals tr.grand td { font-family: var(--font-heading); font-weight: 600; font-size: 1.2rem; border-top: 1px solid var(--line); padding-top: 0.6rem; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; background: var(--blue-light); border-radius: var(--radius-sm); }
code { padding: 0.1rem 0.35rem; word-break: break-all; }
pre { padding: 1rem; overflow-x: auto; }
/* inloggen */
.auth { margin: auto; width: 100%; max-width: 420px; padding: 16px; }
.auth .card { padding: 2rem; }
.auth img { width: 200px; margin-bottom: 1.5rem; }
@media (max-width: 900px) {
body { flex-direction: column; }
.side { width: auto; height: auto; position: static; flex-direction: row; align-items: center; padding: 0.75rem 16px; gap: 0.75rem; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); }
.brand img { width: 110px; margin: 0; }
.side nav { flex-direction: row; }
.side nav a, .logout { padding: 0.45rem 0.8rem; }
main { padding: 1.25rem 16px; }
.two { grid-template-columns: 1fr; }
.search, .search input { width: 100%; }
h1 { font-size: 1.6rem; }
}
.mailbody { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 15px; line-height: 1.7; }
.members { display: grid; }
.member { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem 1rem; padding: 0.6rem 0; border-bottom: 1px solid var(--line); }
.member:last-child { border-bottom: 0; }
/* taken */
.task { display: flex; gap: 0.75rem; align-items: flex-start; padding: 0.6rem 0; border-bottom: 1px solid var(--line); }
@@ -179,28 +236,59 @@ pre { padding: 1rem; overflow-x: auto; }
.check { flex: none; width: 22px; height: 22px; margin-top: 2px; border-radius: 50%; border: 2px solid var(--orange); background: var(--white); cursor: pointer; padding: 0; }
.check:hover, .task.done .check { background: var(--orange); }
.overdue { color: var(--orange-dark); font-weight: 600; }
.inline-fields { display: grid; grid-template-columns: 1fr 160px; gap: 0.5rem; }
/* portaal */
.portal-top { background: var(--white); border-bottom: 1px solid var(--line); padding: 1rem 16px; }
.portal-top .wrap, .portal-main { max-width: 1000px; margin: 0 auto; }
.portal-top .wrap { display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
/* contact/relatie op mobiel: gegevens bovenaan, de 3D-weergave en de rest daaronder */
@media (max-width: 991.98px) {
.stack { display: flex; flex-direction: column; margin: 0; }
.stack > * { display: contents; }
.stack .o-1 { order: -3; }
.stack .o-2 { order: -2; }
.stack .o-3 { order: -1; }
.viewer-frame { aspect-ratio: 4 / 5; min-height: 0; max-height: 70vh; }
}
/* ---------- offerte-editor ---------- */
.lines > :not(caption) > * > * { padding: 0.4rem 0.3rem; vertical-align: middle; }
.lines textarea.form-control { height: 42px; }
.totals { margin-left: auto; width: 320px; max-width: 100%; margin-top: 1rem; }
.totals td { padding: 0.3rem 0.75rem; border: 0; }
.totals tr.grand td { font-family: var(--font-heading); font-weight: 600; font-size: 1.2rem; border-top: 1px solid var(--line); padding-top: 0.6rem; }
@media (max-width: 767.98px) {
/* elke regel als blokje: product en omschrijving breed, de getallen twee aan twee */
.lines thead { display: none; }
.lines, .lines > tbody { display: block; }
.lines > tbody > tr { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem 0.75rem; padding: 0.9rem 0; border-bottom: 1px solid var(--line); }
.lines > tbody > tr > td { display: block; padding: 0; border: 0; }
.lines > tbody > tr > td::before { content: attr(data-label); display: block; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); margin-bottom: 0.15rem; }
.lines > tbody > tr > td:nth-child(-n+2) { grid-column: 1 / -1; }
.lines .amt { text-align: left; font-weight: 600; align-self: end; }
.lines .del-cell { text-align: right; align-self: end; }
.lines .del-cell::before { content: none; }
.lines .del { font-size: 0.9rem; }
.lines .del::after { content: " verwijderen"; }
}
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; background: var(--blue-light); border-radius: var(--radius-sm); }
code { padding: 0.1rem 0.35rem; word-break: break-all; color: var(--ink); }
pre { padding: 1rem; overflow-x: auto; }
/* ---------- inloggen ---------- */
.auth-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 16px; }
.auth { width: 100%; max-width: 420px; }
.auth .card { --bs-card-spacer-y: 2rem; --bs-card-spacer-x: 2rem; }
.auth img { width: 200px; margin-bottom: 1.5rem; }
/* ---------- portaal ---------- */
.portal-top { background: var(--white); border-bottom: 1px solid var(--line); padding: 1rem 0; }
.portal-top img { width: 160px; display: block; }
.portal-main { padding: 2rem 16px; width: 100%; }
body.portal { flex-direction: column; }
.portal-main { padding-top: 2rem; padding-bottom: 2rem; }
/* dialoog */
.dialog { border: 0; border-radius: var(--radius-lg); padding: 1.75rem; width: min(480px, calc(100vw - 32px)); box-shadow: var(--shadow-menu, 0 10px 30px rgba(0,0,0,.1)); }
/* ---------- dialoog ---------- */
.dialog { border: 0; border-radius: var(--radius-lg); padding: 1.75rem; width: min(480px, calc(100vw - 32px)); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); }
.dialog::backdrop { background: rgba(255, 255, 255, 0.6); backdrop-filter: blur(2px); }
.dialog label { font-weight: 600; }
/* relaties */
.members { display: grid; gap: 0.5rem; }
.member { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 0.6rem 0; border-bottom: 1px solid var(--line); }
.member:last-child { border-bottom: 0; }
/* beweging (huisstijl: lift 4–6px met shadow-hover en oranje rand, beeld schaalt 1.03–1.06) */
.fx-wait main, .fx-wait .portal-main, .fx-wait .auth { opacity: 0; }
/* ---------- beweging (huisstijl: lift 4–6px met shadow-hover en oranje rand, beeld schaalt 1.03–1.06) ---------- */
.fx-wait .content, .fx-wait .portal-main, .fx-wait .auth { opacity: 0; }
.kpi, .file, a.bar, .btn, .side nav a { transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.6s cubic-bezier(0.22, 1, 0.36, 1), border-color 0.4s, background-color 0.25s, color 0.25s; }
.kpi:hover, .file:hover { transform: translateY(-5px); box-shadow: 0 1.25rem 2.5rem -1rem rgba(0, 0, 0, 0.18); border-color: var(--orange-line); }
.file img { transition: transform 0.8s cubic-bezier(0.22, 1, 0.36, 1); }
+510
View File
@@ -0,0 +1,510 @@
/* Viewer voor een woningontwerp (house-design-JSON uit de configurator), alleen bekijken.
*
* Draait op de engine van de configurator (static/configurator/, bijwerken met tools/configurator-sync.sh):
* dezelfde HouseModel.computeParts als de configurator en de light-versie, afgewerkte woning in een
* dag-omgeving. Weergaven 3D, Doorsnede en Plattegrond zoals de light-versie (js/light/light.js); de
* plattegrond is daaruit overgenomen, dus wijzigingen daar hier meenemen.
* Kerncijfers (maten, m², inhoud, gewicht, indicatieve prijs) rekent de engine; de prijs komt uit kosten/*.csv.
* Lagen: afgewerkt (zoals de light-versie) of per bouwfase / laag (ViewLayers, zoals Lagen in de configurator),
* zodat de ploeg bijv. regelwerk of elektra kan bekijken. PDF: werktekening = HousePdf.generate (alle tekenbladen),
* klant = LightPdf.generate zoals de light-configurator (voorblad met beeld, kerncijfers, keuzes, prijs en QR-code,
* aanzichten en plattegrond, werkwijze); bewaard bij het contact (POST naar de CRM); ontbreekt er een, dan maakt de
* viewer hem vanzelf aan.
*/
(() => {
const root = document.getElementById('viewer');
const $ = (sel) => root.querySelector(sel);
const nl = (n, d = 0) => n.toLocaleString('nl-NL', { minimumFractionDigits: d, maximumFractionDigits: d });
const meters = (mm) => `${nl(mm / 1000, 2)} m`;
const SECTION = { start: 1500, min: 300, max: 3000 };
const HINTS = {
'3d': 'Slepen = draaien · rechts of Shift+slepen = schuiven · klik en scroll = zoomen',
cut: 'Slepen = draaien · rechts of Shift+slepen = schuiven · klik en scroll = zoomen',
plan: 'Noord boven · maten in mm'
};
let st;
try {
st = HouseModel.normalize(HouseModel.clone(JSON.parse(document.getElementById('design').textContent)));
} catch (err) {
console.error(err);
root.classList.add('is-error');
$('.vw-msg').textContent = `Dit ontwerp kan niet worden getoond: ${err.message}`;
return;
}
// 3D: afgewerkte woning, dag-omgeving (zoals de light-versie).
const stage = $('.vw-3d');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xdfe6ec);
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 400);
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
renderer.shadowMap.enabled = true;
renderer.localClippingEnabled = true;
stage.appendChild(renderer.domElement);
const controls = new THREE.OrbitControls(camera, renderer.domElement);
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
const sun = new THREE.DirectionalLight(0xffffff, 0.8);
sun.position.set(5, 8, 5);
scene.add(ambient, sun);
const sky = Skybox.createEnvironment(scene);
const mats = {
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials(),
lightingMaterials: LightingModel.createMaterials(), sandwichMaterials: SandwichModel.createMaterials(), postMaterials: PostModel.createMaterials(),
chimneyMaterials: ChimneyModel.createMaterials(), elektraMaterials: ElektraModel.createMaterials()
};
const S = st.settings;
if (S.interiorPaint) mats.wallMaterials.spaanplaatMaterial.color.set(S.interiorColor);
mats.roofMaterials.ceilingMaterial.color.set(S.ceilingColor || '#f4f3ef');
const cfg = JSON.parse((document.getElementById('viewer-cfg') || {}).textContent || '{}');
const disposeGeo = (g) => g && g.traverse(x => { if (x.geometry) x.geometry.dispose(); });
// Lagen: 'af' = afgewerkte woning (finishedOnly, zoals de light-versie); anders de gekozen lagen (bouwfase of los).
let layers = ViewLayers.all(), finished = true, parts = null, group = null;
function build() {
const next = HouseModel.computeParts(st, Object.assign({ layers, finishedOnly: finished }, mats));
HouseModel.placeGroups(next);
if (group) { scene.remove(group); disposeGeo(group); caps = null; }
parts = next;
group = new THREE.Group();
HouseModel.rootGroups(parts).forEach(g => group.add(g));
scene.add(group);
}
let caps = null;
build();
const toM = 0.001, rs = parts.rects;
const bx0 = Math.min(...rs.map(r => r.x0)), bx1 = Math.max(...rs.map(r => r.x1));
const bz0 = Math.min(...rs.map(r => r.z0)), bz1 = Math.max(...rs.map(r => r.z1));
const center = { x: (bx0 + bx1) / 2 * toM, z: (bz0 + bz1) / 2 * toM, size: Math.max(bx1 - bx0, bz1 - bz0) * toM };
const floorTop = Math.max(...parts.units.map(u => u.layout.floorTop));
sky.fitTo(group, (floorTop - S.groundLevel - 2) * toM);
Skybox.apply('day', { env: sky, controls, sun, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: group });
controls.target.set(center.x, 1.2, center.z);
camera.position.set(center.x - center.size * 0.9, center.size * 0.7, center.z - center.size * 1.3);
controls.update();
// Weergave: 3D, doorsnede (dak en lampen weg, wanden afgesneden, snede wit afgedekt) of plattegrond.
let view = '3d', sectionMM = SECTION.start;
const poses = {};
const cutPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0);
const capMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
function clip(g, planes) {
if (g) g.traverse(x => (Array.isArray(x.material) ? x.material : x.material ? [x.material] : []).forEach(m => {
if ((m.clippingPlanes || []).length === planes.length) return;
m.clippingPlanes = planes;
m.needsUpdate = true;
}));
}
function placeCut() {
cutPlane.constant = (floorTop + sectionMM) * toM;
if (caps) { group.remove(caps); disposeGeo(caps); caps = null; }
if (view === 'cut') { caps = HouseModel.sectionCaps(parts, floorTop + sectionMM, capMaterial, ViewLayers.all()); group.add(caps); }
}
function applyView() {
const cut = view === 'cut', roof = !cut && layers.dak !== false;
parts.units.forEach(u => { if (u.roof.group) u.roof.group.visible = roof; });
[parts.roofEdgeGroup, parts.postsGroup].forEach(g => { if (g) g.visible = roof; });
if (parts.roomsGroup && parts.roomsGroup.userData.ceilings) parts.roomsGroup.userData.ceilings.visible = roof;
const planes = cut ? [cutPlane] : [];
HouseModel.allWallPieces(parts).forEach(x => clip(x.group, planes));
[parts.elektraGroup, parts.chimneysGroup, parts.roomsGroup, parts.fixturesGroup, parts.lightingGroup].forEach(g => clip(g, planes));
placeCut();
if (view === 'plan') drawPlan();
}
// Snijhoogte niet hoger dan de laagste goot (plat dak: de dakhoogte), zodat de snede door alle gevels gaat.
const cutInput = $('.vw-cutbar input');
const lows = st.settings.roof.house && st.settings.roof.house.enabled && st.settings.roof.house.form
? [st.settings.roof.house.form.low] : st.units.map(u => UnitModel.roofForm(u.unit).low);
cutInput.max = Math.max(SECTION.min, Math.floor(Math.min(...lows.filter(Number.isFinite), SECTION.max) / 50) * 50);
sectionMM = Math.min(sectionMM, Number(cutInput.max));
cutInput.value = sectionMM;
$('.vw-cutbar output').textContent = meters(sectionMM);
cutInput.addEventListener('input', () => {
sectionMM = Number(cutInput.value);
$('.vw-cutbar output').textContent = meters(sectionMM);
placeCut();
});
function setView(v) {
if (v === view) return;
if (view !== 'plan') poses[view] = { pos: camera.position.clone(), target: controls.target.clone() };
view = v;
if (v !== 'plan') {
const pose = poses[v];
if (pose) { camera.position.copy(pose.pos); controls.target.copy(pose.target); }
else {
controls.target.set(center.x, 0.6, center.z);
camera.position.set(center.x - center.size * 0.45, center.size * 1.05 + 2, center.z + center.size * 0.75);
}
controls.update();
}
root.classList.toggle('view-plan', v === 'plan');
root.classList.toggle('view-cut', v === 'cut');
root.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('on', b.dataset.view === v));
$('.vw-hint').textContent = HINTS[v];
applyView();
}
$('.vw-hint').textContent = HINTS['3d'];
$('.vw-viewbar').addEventListener('click', (e) => { const b = e.target.closest('[data-view]'); if (b) setView(b.dataset.view); });
// Plattegrond (noord boven), overgenomen uit js/light/light.js (drawPlan).
function drawPlan() {
const cv = $('.vw-plan'), W = cv.clientWidth, H = cv.clientHeight;
if (!W || !H) return;
const dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
const ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, W, H);
const p = parts;
const ext = rs.concat(p.units.map(u => u.roof.rect));
const ex0 = Math.min(...ext.map(r => r.x0)), ex1 = Math.max(...ext.map(r => r.x1));
const ez0 = Math.min(...ext.map(r => r.z0)), ez1 = Math.max(...ext.map(r => r.z1));
const top = 64, side = 56;
const scale = Math.min((W - 2 * side) / (ex1 - ex0), (H - top - side) / (ez1 - ez0));
const ox = W / 2 - (ex0 + ex1) / 2 * scale, oy = top + (H - top - side) / 2 - (ez0 + ez1) / 2 * scale;
const P = (x, z) => [ox + x * scale, oy + z * scale];
const path = (pts, closed) => { ctx.beginPath(); pts.forEach((q, i) => ctx[i ? 'lineTo' : 'moveTo'](...P(q.x, q.z))); if (closed) ctx.closePath(); };
const corners = (pl, lx0, lx1) => { const t = pl.thickness / 2; return [[lx0, -t], [lx1, -t], [lx1, t], [lx0, t]].map(([a, b]) => UnitModel.localToWorld(pl, a, b)); };
const rgba = (hex, a) => `rgba(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)}, ${a})`;
ctx.lineJoin = 'round';
// Dakrand
ctx.setLineDash([5, 4]); ctx.strokeStyle = '#9aa3ad'; ctx.lineWidth = 1;
if (p.roofEdge) p.roofEdge.segments.forEach(sg => { path([sg.outerA, sg.outerB]); ctx.stroke(); });
else p.units.forEach(u => { const [x, y] = P(u.roof.rect.x0, u.roof.rect.z0); ctx.strokeRect(x, y, u.roof.rect.W * scale, u.roof.rect.D * scale); });
ctx.setLineDash([]);
// Ruimtes
const R = p.rooms;
R.rooms.forEach(r => { ctx.fillStyle = rgba(r.color, 0.22); r.rects.forEach(c => { const [x, y] = P(c.x0, c.z0); ctx.fillRect(x, y, (c.x1 - c.x0) * scale, (c.z1 - c.z0) * scale); }); });
// Inrichting
(p.fixtures || []).forEach(it => FixtureModel.planShapes(it).forEach(sh => {
ctx.strokeStyle = '#8b939b';
ctx.lineWidth = sh.kind === 'thin' ? 0.6 : 1;
ctx.setLineDash(sh.kind === 'dash' ? [3, 3] : []);
if (sh.circle) { ctx.beginPath(); ctx.arc(...P(sh.circle.x, sh.circle.z), Math.max(1, sh.circle.r * scale), 0, Math.PI * 2); ctx.stroke(); return; }
if (sh.text) return;
path(sh.pts, sh.closed);
if (sh.fill) { ctx.fillStyle = 'rgba(139, 147, 155, 0.18)'; ctx.fill(); }
ctx.stroke();
}));
ctx.setLineDash([]);
// Wanden met openingen en kozijnsymbolen
const piece = (x, fill) => {
const pl = x.place, Wp = pl.width;
path(corners(pl, -Wp / 2, Wp / 2), true);
ctx.fillStyle = fill; ctx.fill();
x.config.openings.filter(op => op.type !== 'schoorsteen').forEach(op => {
path(corners(pl, -Wp / 2 + op.x, -Wp / 2 + op.x + op.w), true);
ctx.fillStyle = '#ffffff'; ctx.fill();
ctx.strokeStyle = '#33383d'; ctx.lineWidth = 0.8; ctx.stroke();
if (!op.fill) return;
KozijnModel.planSymbol(op, op.fill, { zIn: -pl.thickness / 2, zOut: pl.thickness / 2 }).forEach(sh => {
ctx.lineWidth = sh.kind === 'leaf' ? 1.3 : 0.8;
ctx.setLineDash(sh.kind === 'arc' || sh.kind === 'rail' ? [3, 3] : []);
path(sh.pts.map(q => UnitModel.localToWorld(pl, -Wp / 2 + op.x + q.x, q.z)), !!sh.closed);
ctx.stroke();
});
ctx.setLineDash([]);
});
};
p.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(x => piece(x, '#3d4247'))));
p.interior.forEach(x => piece(x, '#7a828a'));
ctx.fillStyle = '#a07c54';
p.posts.posts.forEach(pp => { const [x, y] = P(pp.x - pp.size / 2, pp.z - pp.size / 2); ctx.fillRect(x, y, pp.size * scale, pp.size * scale); });
p.posts.walls.forEach(w => { const [x, y] = P(w.x0, w.z0); ctx.fillStyle = '#3d4247'; ctx.fillRect(x, y, (w.x1 - w.x0) * scale, (w.z1 - w.z0) * scale); });
(p.chimneys || []).forEach(c => { path(c.footprint, true); ctx.fillStyle = '#8b8f93'; ctx.fill(); });
// Binnenspots
ctx.fillStyle = '#ffd36b'; ctx.strokeStyle = '#a07a1e'; ctx.lineWidth = 0.8;
(p.lighting.indoor || []).forEach(sp => { ctx.beginPath(); ctx.arc(...P(sp.x, sp.z), Math.max(2.5, 90 * scale), 0, Math.PI * 2); ctx.fill(); ctx.stroke(); });
// Ruimtenamen
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const fs = Math.max(9, Math.min(13, scale * 260));
R.rooms.forEach(r => {
const [x, y] = P(r.label.x, r.label.z);
ctx.fillStyle = '#212121'; ctx.font = `600 ${fs}px sans-serif`;
ctx.fillText(r.name, x, y - fs * 0.6);
ctx.fillStyle = '#5b6673'; ctx.font = `${fs * 0.9}px sans-serif`;
ctx.fillText(`${nl(r.areaM2, 1)} m²`, x, y + fs * 0.6);
});
// Buitenmaten (units) en noordpijl
ctx.strokeStyle = '#1d5f99'; ctx.fillStyle = '#1d5f99'; ctx.lineWidth = 1; ctx.font = '12px sans-serif';
const dim = (a, b, horiz, at) => {
const [x0, y0] = horiz ? P(a, 0) : P(0, a), [x1, y1] = horiz ? P(b, 0) : P(0, b);
const A = horiz ? [x0, at] : [at, y0], B = horiz ? [x1, at] : [at, y1];
ctx.beginPath(); ctx.moveTo(...A); ctx.lineTo(...B);
[A, B].forEach(([x, y]) => { ctx.moveTo(x - 4, y + 4); ctx.lineTo(x + 4, y - 4); ctx.moveTo(...(horiz ? [x, y - 6] : [x - 6, y])); ctx.lineTo(...(horiz ? [x, y + 6] : [x + 6, y])); });
ctx.stroke();
ctx.save();
ctx.translate((A[0] + B[0]) / 2, (A[1] + B[1]) / 2);
if (!horiz) ctx.rotate(-Math.PI / 2);
ctx.fillText(nl(Math.round(b - a)), 0, -9);
ctx.restore();
};
dim(bx0, bx1, true, P(0, ez0)[1] - 22);
dim(bz0, bz1, false, P(ex0, 0)[0] - 22);
const nx = W - 28, ny = top + 6;
ctx.beginPath(); ctx.moveTo(nx, ny); ctx.lineTo(nx - 7, ny + 18); ctx.lineTo(nx + 7, ny + 18); ctx.closePath(); ctx.fill();
ctx.fillText('N', nx, ny + 30);
}
// Kerncijfers; de prijs volgt zodra de kosten-CSV's geladen zijn.
function facts() {
const totals = HouseModel.computeTotals(parts);
const d = HouseModel.computeDimensions(parts, S.groundLevel);
let price = null;
try { price = CostModel.compute(parts, totals).summary.inclBtw; } catch (err) { console.warn(err); }
const rows = [
['Buitenmaten', `${nl(d.lengthX / 1000, 2)} × ${nl(d.lengthZ / 1000, 2)} m`],
['Bouwhoogte', meters(d.height)],
['Bruto / leefbaar', `${nl(d.brutoM2, 1)} / ${nl(d.leefbaarM2, 1)} m²`],
['Inhoud', `${nl(d.inhoudM3, 0)} m³`],
['Units', String(st.units.length)],
['Gewicht', `${nl(totals.totalKg / 1000, 1)} t`]
];
if (price) rows.push(['Indicatief incl. btw', `€ ${nl(Math.round(price / 100) * 100)}`]);
$('.vw-facts').innerHTML = rows.map(([k, v]) => `<div><span>${k}</span><b>${v}</b></div>`).join('');
}
facts();
CostModel.onChange(facts);
// Scrollwiel: in de CRM-pagina scrolt het de pagina, tenzij je in de 3D hebt geklikt of Ctrl/⌘ ingedrukt houdt.
const stageEl = $('.vw-stage');
let engaged = false, toastTimer = null;
const fsEl = () => document.fullscreenElement || document.webkitFullscreenElement;
function toast(msg) {
const t = $('.vw-toast');
t.textContent = msg;
t.classList.add('on');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('on'), 3000);
}
stageEl.addEventListener('pointerdown', () => { engaged = true; });
stageEl.addEventListener('pointerleave', () => { engaged = false; });
stageEl.addEventListener('wheel', (e) => {
if (engaged || e.ctrlKey || e.metaKey || view === 'plan' || fsEl() || window.parent === window) return;
e.stopPropagation();
toast('Klik eerst in de weergave (of houd Ctrl ingedrukt) om te zoomen.');
}, { capture: true });
// Schermvullend
const fullBtn = $('.vw-full');
if (root.requestFullscreen || root.webkitRequestFullscreen) {
fullBtn.hidden = false;
fullBtn.addEventListener('click', () => {
const r = fsEl() ? (document.exitFullscreen || document.webkitExitFullscreen).call(document)
: (root.requestFullscreen || root.webkitRequestFullscreen).call(root);
if (r && r.catch) r.catch(() => toast('Schermvullend lukt hier niet.'));
});
const onFull = () => { root.classList.toggle('is-full', !!fsEl()); fullBtn.title = fsEl() ? 'Schermvullend uit (Esc)' : 'Schermvullend'; };
document.addEventListener('fullscreenchange', onFull);
document.addEventListener('webkitfullscreenchange', onFull);
}
// Afbeelding van de huidige 3D-weergave
$('.vw-shot').addEventListener('click', () => {
if (view === 'plan') { const a = document.createElement('a'); a.href = $('.vw-plan').toDataURL('image/png'); a.download = 'plattegrond.png'; a.click(); return; }
renderer.render(scene, camera);
const a = document.createElement('a');
a.href = renderer.domElement.toDataURL('image/png');
a.download = `${(st.project && st.project.naam) || 'ontwerp'}.png`;
a.click();
});
// Lagenmenu: bouwfase (alles tot en met) of losse lagen; elke wijziging bouwt de woning opnieuw op.
const layerPop = $('.vw-pop-layers'), phaseSel = layerPop.querySelector('select');
phaseSel.innerHTML = `<option value="" hidden>Eigen keuze</option><option value="af">Afgewerkte woning</option>` +
ViewLayers.PHASES.slice(0, -1).map((p, i) => `<option value="${i}">${i + 1}. t/m ${p.toLowerCase()}</option>`).join('');
const LAYER_KEYS = ViewLayers.LIST.filter(l => l.key !== 'maten');
layerPop.querySelector('.vw-checks').innerHTML = LAYER_KEYS.map(l =>
`<label title="${l.hint || ''}"><input type="checkbox" data-layer="${l.key}" checked> ${l.label}</label>`).join('');
const syncChecks = () => layerPop.querySelectorAll('[data-layer]').forEach(c => { c.checked = layers[c.dataset.layer] !== false; });
function relayer(next, fin) {
layers = next; finished = fin;
root.classList.add('is-busy');
$('.vw-layers-btn').classList.toggle('on', !fin);
setTimeout(() => {
try { build(); Skybox.apply('day', { env: sky, controls, sun, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: group }); applyView(); }
catch (err) { console.error(err); toast('Deze lagen konden niet worden opgebouwd.'); }
root.classList.remove('is-busy');
}, 20);
}
phaseSel.addEventListener('change', () => {
const v = phaseSel.value;
// Bij een bouwfase het dak eraf, zodat je in de woning kijkt (aanzetten kan met de laag Dak).
relayer(v === 'af' ? ViewLayers.all() : Object.assign(ViewLayers.upTo(Number(v)), { dak: false, maten: true }), v === 'af');
syncChecks();
});
layerPop.addEventListener('change', (e) => {
const c = e.target.closest('[data-layer]');
if (!c) return;
const next = Object.assign({}, layers, { [c.dataset.layer]: c.checked });
const phase = ViewLayers.phaseOf(next);
phaseSel.value = LAYER_KEYS.every(l => next[l.key] !== false) ? 'af' : phase >= 0 && phase < ViewLayers.PHASES.length - 1 ? String(phase) : '';
relayer(next, phaseSel.value === 'af');
});
// Uitklappanelen (lagen, PDF): één open; klik ernaast sluit.
root.querySelectorAll('[data-pop]').forEach(btn => btn.addEventListener('click', (e) => {
e.stopPropagation();
const pop = $(btn.dataset.pop), open = pop.hidden;
root.querySelectorAll('.vw-pop').forEach(x => { x.hidden = true; });
pop.hidden = !open;
}));
root.querySelectorAll('.vw-pop').forEach(p => p.addEventListener('click', (e) => e.stopPropagation()));
document.addEventListener('click', () => root.querySelectorAll('.vw-pop').forEach(x => { x.hidden = true; }));
// PDF (werktekening / klant): de PDF-modules van de configurator pas laden als ze nodig zijn.
const PDF_SCRIPTS = ['js/vendor/jspdf.umd.min.js', 'js/core/logo-data.js', 'js/core/pdf-sheet.js', 'js/wall/wall-pdf.js',
'js/floor/floor-pdf.js', 'js/elektra/elektra-pdf.js', 'js/house/house-pdf.js', 'js/vendor/qrcode.js', 'js/light/light-pdf.js'];
let pdfLoading = null;
const loadScript = (src) => new Promise((ok, fail) => {
const sc = document.createElement('script');
sc.src = src; sc.onload = ok; sc.onerror = () => fail(new Error(`${src} niet geladen`));
document.head.appendChild(sc);
});
const loadPdf = () => pdfLoading || (pdfLoading = PDF_SCRIPTS.reduce((p, src) => p.then(() => loadScript(src)), Promise.resolve())
.then(() => Promise.all(HousePdf.materialThumbUrls(st).map(url => new Promise(ok => {
PdfSheet.preloadImage(url);
const img = new Image(); img.onload = img.onerror = ok; img.src = url;
})))).then(() => new Promise(ok => setTimeout(ok, 100))));
const costsReady = new Promise(ok => {
if (CostCsv.status('intern').loadedAt) ok();
else { CostCsv.onChange(ok); setTimeout(ok, 6000); }
});
const PDF_LABEL = { werk: 'Werktekening', klant: 'Klant-PDF' };
const pdfs = cfg.pdfs || {};
function renderPdfs() {
$('.vw-pop-pdf .vw-pdfs').innerHTML = ['werk', 'klant'].map(k => {
const f = pdfs[k];
return `<div class="vw-pdf"><div><b>${PDF_LABEL[k]}</b><span>${k === 'werk' ? 'Alle tekenbladen, elektra, zaaglijst en kosten (intern)' : 'Zoals de configurator: voorblad, aanzichten, plattegrond en werkwijze (ook in het klantportaal)'}</span>
${f ? `<span>Gemaakt ${f.date}</span>` : ''}</div>
<div>${f ? `<a href="${f.url}" target="_blank" rel="noopener">Openen</a>` : ''}
${cfg.pdfUrl ? `<button type="button" data-make="${k}">${f ? 'Opnieuw' : 'Maken'}</button>` : ''}</div></div>`;
}).join('');
}
// Klant-PDF zoals de light-configurator (js/light/light.js savePdf): LightPdf met beeld, kerncijfers en keuzes.
// De keuzes leest de CRM uit het ontwerp zelf (de light-keuzes komen niet mee in de house-design-JSON);
// zelfde onderwerpen en woorden als LightModel.describe.
const ROOF_NAMES = { plat: 'plat dak', lessenaar: 'lessenaardak', zadel: 'zadeldak', wolf: 'wolfsdak' };
const SIDE_NAMES = { voor: 'noord', achter: 'zuid', links: 'west', rechts: 'oost' };
const FACADES = { rabat: 'Hout', fels: 'Felsplaten', stuc: 'Stucwerk', steen: 'Steenstrips' };
const LIGHTING = { geen: 'Geen', spots: 'Spots in de dakrand', gevel: 'Gevellampen' };
const cap = (t) => t ? t.charAt(0).toUpperCase() + t.slice(1) : t;
const label = (list, key) => (list && list[key] && list[key].label) || '';
function choices() {
const R = S.roof, F = S.facade, rows = [];
const way = (f) => f.type === 'lessenaar' ? ` (hoog aan de ${SIDE_NAMES[f.dir] || f.dir}kant)`
: UnitModel.hasRidge(f.type) && UnitModel.RIDGES[f.ridge] ? ` (nok ${UnitModel.RIDGES[f.ridge].toLowerCase()})` : '';
const nok = (f) => f.type === 'plat' ? '' : `, ${f.type === 'lessenaar' ? 'hoge kant' : 'nok'} ${nl(f.high / 1000, 2)} m`;
const house = R.house && R.house.enabled && R.house.form;
const forms = house ? [R.house.form] : st.units.map(u => UnitModel.roofForm(u.unit));
const per = (fn) => { const v = forms.map(fn); return v.every(x => x === v[0]) ? v[0] : v.map((x, i) => `unit ${st.units[i].id}: ${x}`).join(', '); };
rows.push(['Dakvorm', cap(per(f => (ROOF_NAMES[f.type] || f.type) + way(f)) + (house ? ' over de hele woning' : ''))]);
rows.push(['Hoogte', cap(per(f => `goot ${nl(f.low / 1000, 2)} m${nok(f)}`))]);
const covers = [...new Set(st.units.map(u => label(RoofModel.COVERINGS, u.roof.covering) || label(RoofModel.COVERINGS, 'dakleer')))];
rows.push(['Dakbedekking', covers.join(', ')]);
const color = F.type === 'steen' ? `steen ${F.stone}` : F.type === 'rabat' ? label(WallModel.RABAT_COLORS, F.rabatColor)
: F.type === 'stuc' ? label(WallModel.STUC_COLORS, F.stucColor) : label(WallModel.FELS.COLORS, F.felsColor);
rows.push(['Gevel', cap([(FACADES[F.type] || F.type || '').toLowerCase(), F.type === 'rabat' ? S.cladding : null, color.toLowerCase()].filter(Boolean).join(', '))]);
const tr = S.trasraam || {};
rows.push(['Trasraam', tr.enabled ? `Steen ${tr.finish}, ${nl(tr.height / 1000, 2)} m hoog` : 'Geen']);
const fascia = st.units.some(u => u.roof.fascia !== false);
rows.push(['Dakrand', fascia ? `Boeidelen ${label(RoofModel.FASCIA_COLORS, R.fasciaColor).toLowerCase()}`.trim() : 'Daktrim']);
const L = S.lighting || {};
rows.push(['Buitenverlichting', (LIGHTING[L.mode] || 'Geen') + (L.mode === 'gevel' && LightingModel.DIRECTIONS[L.direction] ? ` (${LightingModel.DIRECTIONS[L.direction].toLowerCase()})` : '')]);
rows.push(['Binnenverlichting', L.indoor ? 'Inbouwspots in het plafond' : 'Geen']);
return rows;
}
// Beeld voor het voorblad: de afgewerkte woning in 3D (ook vanuit doorsnede, plattegrond of een laag), vaste resolutie.
function snapshot() {
const W = 1600, H = 1500, pr = renderer.getPixelRatio();
const keep = { view, layers, finished, pos: camera.position.clone(), target: controls.target.clone() };
const rebuild = () => { build(); Skybox.apply('day', { env: sky, controls, sun, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: group }); };
if (!finished) { layers = ViewLayers.all(); finished = true; rebuild(); }
if (view !== '3d') {
view = '3d'; applyView();
if (poses['3d']) { camera.position.copy(poses['3d'].pos); controls.target.copy(poses['3d'].target); }
}
renderer.setPixelRatio(1);
renderer.setSize(W, H, false);
camera.aspect = W / H;
camera.updateProjectionMatrix();
controls.update();
renderer.render(scene, camera);
const url = renderer.domElement.toDataURL('image/jpeg', 0.88);
renderer.setPixelRatio(pr);
if (!keep.finished) { layers = keep.layers; finished = false; rebuild(); }
view = keep.view;
applyView();
camera.position.copy(keep.pos);
controls.target.copy(keep.target);
controls.update();
resize();
return { url, width: W, height: H };
}
function customerPdf() {
const raw = JSON.parse(document.getElementById('design').textContent);
const key = raw.model || '', model = (HouseModel.MODELS || {})[key];
const name = (st.project && st.project.naam) || (model && model.label) || 'Uw woning';
const full = HouseModel.computeParts(st, { skip3D: true });
const totals = HouseModel.computeTotals(full), d = HouseModel.computeDimensions(full, S.groundLevel);
const info = {
price: CostModel.compute(full, totals).summary.inclBtw, kg: totals.totalKg, units: st.units.length,
lengthX: d.lengthX, lengthZ: d.lengthZ, height: d.height, brutoM2: d.brutoM2, leefbaarM2: d.leefbaarM2, inhoudM3: d.inhoudM3
};
let image = null;
try { image = snapshot(); } catch (err) { console.warn(err); }
const pdfSt = HouseModel.clone(st);
pdfSt.project = Object.assign({}, pdfSt.project, { naam: name, opmerking: (pdfSt.project && pdfSt.project.opmerking) || 'Indicatief ontwerp' });
return LightPdf.generate({ st: pdfSt, label: name, info, choices: choices(), url: LightPdf.modelUrl(key), image });
}
let making = Promise.resolve();
function makePdf(kind) {
making = making.then(() => {
toast(`${PDF_LABEL[kind]} wordt gemaakt…`);
return Promise.all([loadPdf(), costsReady]);
}).then(() => new Promise(ok => setTimeout(ok, 30))).then(() => {
const doc = kind === 'klant' ? customerPdf() : HousePdf.generate(st);
const body = new FormData();
body.append('kind', kind);
body.append('pdf', doc.output('blob'), `${kind}.pdf`);
return fetch(cfg.pdfUrl, { method: 'POST', body, credentials: 'same-origin' }).then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)));
}).then(res => {
pdfs[kind] = res;
renderPdfs();
toast(`${PDF_LABEL[kind]} opgeslagen bij het contact.`);
if (window.parent !== window) window.parent.postMessage({ type: 'tectum-pdf', kind, url: res.url, date: res.date }, location.origin);
}).catch(err => { console.error(err); toast(`${PDF_LABEL[kind]} maken lukte niet: ${err.message}`); });
return making;
}
$('.vw-pop-pdf').addEventListener('click', (e) => { const b = e.target.closest('[data-make]'); if (b) makePdf(b.dataset.make); });
renderPdfs();
// Direct aanwezig: ontbrekende PDF's maken zodra de weergave staat.
if (cfg.pdfUrl && cfg.auto) setTimeout(() => ['werk', 'klant'].filter(k => !pdfs[k]).forEach(makePdf), 1500);
function resize() {
const w = stage.clientWidth, h = stage.clientHeight;
if (!w || !h) return;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
}
new ResizeObserver(() => { resize(); if (view === 'plan') drawPlan(); }).observe(stage);
resize();
applyView();
root.classList.remove('is-busy');
(function loop() {
requestAnimationFrame(loop);
if (view !== 'plan' && !document.hidden) renderer.render(scene, camera);
})();
})();