diff --git a/.gitignore b/.gitignore index bb4730c..52860ea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ data/ .venv/ __pycache__/ +static/configurator/ diff --git a/README.md b/README.md index 6f538e3..3bbe903 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,20 @@ Eenvoudige CRM in Python (Flask) met SQLite, in de Van Emous-huisstijl. Versie 0 familie of bedrijf, met meerdere contactpersonen en hun rol) en **partners** met een type (leverancier, aannemer, installateur, ...; de typen pas je aan bij Instellingen). Een lead omzetten naar klant zet de persoon in een nieuwe of bestaande klantrelatie. Leveranciers van producten worden automatisch partners. +- **Gekozen model** per lead/contact: een woningontwerp uit de configurator (`house-design`-JSON, via het + formulier, de API of geüpload bij Bestanden) wordt het gekozen model; het nieuwste telt, een ander ontwerp + kies je op de contactpagina. Daar staat het in een **3D-viewer** op de engine van de configurator + (`/files//viewer`): 3D, doorsnede en plattegrond, met maten, m², inhoud, gewicht en een indicatieve + prijs (uit de kosten-CSV's van de configurator). In de leadlijst staat het model als kolom. + Met **Lagen** in de viewer kies je een bouwfase (t/m regelwerk, elektra, beplating, …) of losse lagen + (regelwerk, elektra, kozijnen, dak, …), zoals Lagen in de configurator, zodat de ploeg de opbouw kan bekijken. +- **PDF's per ontwerp** (`HousePdf` van de configurator, gemaakt in de viewer en bewaard bij het contact): een + **werktekening** (alle tekenbladen, elektra, zaaglijst en kosten; intern, niet in het klantportaal) en een + **klant-PDF** zoals de light-configurator die maakt (`LightPdf`: voorblad met beeld, kerncijfers, keuzes, indicatieve + prijs en QR-code, dan aanzichten en plattegrond en de werkwijze; wel in het portaal). Ontbreken ze, dan maakt de viewer ze vanzelf bij + het eerste openen; opnieuw maken via PDF in de weergave. Op de contactpagina bij het gekozen model en op de + relatiepagina (Ontwerpen en tekeningen, voor alle contactpersonen). +- Op mobiel staan bij contact en relatie de gegevens bovenaan, de 3D-weergave verderop. - **Taken/opvolging** per contact met datum en eigenaar; "Mijn taken" op het dashboard. - **API voor formulieren** (`POST /api/leads`): JSON, gewone formulieren en multipart met bestanden. Nederlandse en Engelse veldnamen worden herkend, onbekende velden bewaard. Dubbele leads (zelfde @@ -29,6 +43,22 @@ Eenvoudige CRM in Python (Flask) met SQLite, in de Van Emous-huisstijl. Versie 0 formaat als de import. - **Offertes** met regels (product of vrije regel), btw per tarief en een afdrukversie die je als PDF opslaat via de browser. +- **Bestek uit ontwerp** bij een offerte (of "Offerte met bestek" bij het gekozen model): de engine van de + configurator rekent het ontwerp door zoals Kosten & tijden (`static/bestek.js`, `CostModel.compute` met de + kosten-CSV's) en zet alle posten met hoeveelheden in hoofdstukken (casco, isolatie, gevel, dak, vloer, + kozijnen, afwerking, inrichting, verlichting, elektra met wcd's, schakelaars en meters leiding per type, + transport en plaatsing). Elke post krijgt zijn kostprijs plus algemene kosten en marge; samen precies het + totaal van de configurator. Schroeven, zaag- en boorwerk staan per hoofdstuk samen als "Bevestiging en + bewerking". In de offerte per post (standaard: aantal × prijs per eenheid) of per hoofdstuk; het + afrondingsverschil van de prijzen per eenheid gaat naar de laatste regel van 1 post zonder product, zodat + het totaal precies klopt. Het bestek zelf komt als bijlage (zonder prijzen) in de afdrukversie en het portaal. +- **Producten uit het bestek:** posten met een vaste prijs per eenheid (wcd, schakelaar, aansluitpunten, + leidingen per type, spots en lampen, toilet, douche, keuken per m, deuren, ramen per maat, gevel- en + dakbedekking per m², isolatie, plaatmateriaal, ...) worden gekoppeld aan een product met de kostencode van de + configurator als SKU (`elektra.wcdDubbel`, `inrichting.toilet`, `kozijnen.raam.1200x500`; dezelfde codes als + in `kosten/leveranciers.csv`). Ontbreekt het product, dan wordt het aangemaakt met de prijs per eenheid van de + berekening; bestaande productprijzen worden alleen bijgewerkt als je dat aanvinkt (met producthistorie). + De regels staan in `PRODUCT_RULES` in `static/bestek.js`. - **Gebruikers** met de rollen beheerder (ook gebruikers en instellingen) en medewerker. - **Klantportaal** (`/portal`): de klant krijgt een uitnodigingslink (kopiëren of mailen vanaf het contact), stelt een wachtwoord in en kan dan offertes bekijken, accepteren of afwijzen, bestanden @@ -44,23 +74,57 @@ python3 -m venv .venv .venv/bin/python app.py # http://127.0.0.1:5000 ``` +De viewer heeft de engine van de configurator nodig (niet in git, ca. 160 MB met texturen): + +```sh +tools/configurator-sync.sh [../configurator] # kopieert js/, assets/, kosten/ naar static/configurator/ +``` + +Opnieuw draaien als er in de configurator iets aan de weergave verandert; `deploy.sh` doet dit zelf bij elke deploy. + +Demoleads (lorem ipsum, elk met een standaardmodel als gekozen model): + +```sh +node tools/export-models.js # standaardmodellen (HouseModel.MODELS) → demo/modellen/*.json +.venv/bin/python app.py demo # leads aanmaken; bestaande demoleads worden overgeslagen +``` + Bij de eerste keer maak je het beheerdersaccount aan. Daarna vul je onder Instellingen de bedrijfsgegevens, de e-mail en de toegestane websites voor de API in. -## Online zetten +## Online zetten (server) -Draai de app achter nginx/Apache met HTTPS, bijvoorbeeld met gunicorn: +Op een nieuwe Ubuntu-server (26.04 LTS) eenmalig, nadat de DNS van het domein naar de server wijst: ```sh -.venv/bin/pip install gunicorn -.venv/bin/gunicorn -w 2 -b 127.0.0.1:8000 app:app +scp -r server ubuntu@SERVER: +ssh ubuntu@SERVER sudo bash server/setup.sh crm.voorbeeld.nl jij@voorbeeld.nl ``` -E-mail automatisch ophalen (cron, elke 10 minuten): +`server/setup.sh` installeert Python, nginx, certbot, rsync en sqlite3, zet de tijdzone op Europe/Amsterdam, +maakt de gebruiker `crm` met `/srv/vanemous-crm` (`releases/`, `current`, `data/`), de systemd-dienst +`vanemous-crm` (gunicorn op 127.0.0.1:5050, `CRM_PROXY=1`), de nginx-site met HTTPS, de firewall (SSH, 80, 443), +automatische beveiligingsupdates en cron (e-mail ophalen elke 10 minuten, elke nacht een databaseback-up, 7 dagen rond). +Een versie uitrollen (of terugrollen) vanaf je eigen machine: + +```sh +tools/release.sh crm@crm.voorbeeld.nl # nieuwste tag +tools/release.sh crm@crm.voorbeeld.nl v0.0.1 # bepaalde versie ``` -*/10 * * * * cd /pad/naar/crm && .venv/bin/python app.py sync -``` + +Dat stuurt `git archive ` en de 3D-engine uit `../configurator` naar de server; `server/activate.sh` maakt daar +een venv, back-upt de database, zet `current` om en herstart de dienst. Start de nieuwe versie niet, dan gaat hij +terug naar de vorige. Logboek: `sudo journalctl -u vanemous-crm`. + +`CRM_PROXY=1` laat de app de `X-Forwarded-Proto`/`-Host`-headers van de proxy volgen (links naar buiten, zoals +portaaluitnodigingen, worden dan `https://domein/...`) en zet sessiecookies op alleen-HTTPS. De proxy moet +uploads tot 25 MB doorlaten (nginx: `client_max_body_size 25m`). + +Lokaal zoals op de server, maar zonder nginx: `./deploy.sh` (zie het script). + +Back-ups buiten de server (bijv. de back-upoptie van OVH of een rsync van `/srv/vanemous-crm/data`) regel je apart: +de nachtelijke kopie staat op dezelfde schijf en dekt `uploads/` niet. ## Opslag @@ -79,5 +143,11 @@ importer.py CSV/JSON inlezen en kolommen herkennen mail.py IMAP/SMTP schema.sql database templates/ pagina's (Jinja) -static/ style.css (huisstijltokens), fx.js (GSAP-effecten) en logo.svg +static/ style.css (huisstijltokens), fx.js (GSAP-effecten), logo.svg, viewer.js (3D-viewer), bestek.js +static/configurator/ engine van de configurator (tools/configurator-sync.sh, niet in git) +tools/ configurator-sync.sh, export-models.js (standaardmodellen als JSON) +demo/modellen standaardmodellen als house-design-JSON voor de demoleads ``` + +De plattegrond in `static/viewer.js` is overgenomen uit `js/light/light.js` (`drawPlan`) van de +configurator; verandert die, dan hier meenemen. diff --git a/app.py b/app.py index 0402b3d..dd67c76 100644 --- a/app.py +++ b/app.py @@ -2,8 +2,9 @@ Starten: python app.py E-mail ophalen: python app.py sync (bijv. elke 10 minuten via cron) +Demoleads: python app.py demo (standaardmodellen uit demo/modellen als gekozen model) """ -__version__ = '0.0.1' +__version__ = '0.0.2' import csv import io @@ -52,6 +53,18 @@ PRODUCT_LABELS = {'name': 'Naam', 'sku': 'SKU', 'description': 'Omschrijving', ' 'supplier': 'Leverancier', 'price': 'Prijs', 'vat': 'Btw', 'active': 'Actief', 'created': 'Aangemaakt'} INLINE_MIME = {'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'application/pdf'} +# Viewer voor woningontwerpen: de engine van de configurator, gekopieerd met tools/configurator-sync.sh +# (zelfde volgorde als light.html, zonder PDF en light-UI). Demo-modellen: tools/export-models.js. +ENGINE = os.path.join(BASE, 'static', 'configurator') +VIEWER_SCRIPTS = ['js/vendor/three.min.js', 'js/vendor/OrbitControls.js', 'js/core/geometry.js', + 'js/core/view-layers.js', 'js/core/ui-kit.js', 'js/core/skybox.js', 'js/wall/kozijn-model.js', + 'js/wall/wall-model.js', 'js/floor/floor-model.js', 'js/roof/roof-model.js', 'js/roof/post-model.js', + 'js/lighting/lighting-model.js', 'js/elektra/elektra-model.js', 'js/sandwich/sandwich-model.js', + 'js/wall/wall.js', 'js/floor/floor.js', 'js/unit/unit-model.js', 'js/room/room-model.js', + 'js/interior/fixture-model.js', 'js/house/chimney-model.js', 'js/house/house-model.js', + 'js/cost/cost-defaults.js', 'js/cost/cost-csv.js', 'js/cost/cost-model.js'] +DEMO_MODELS = os.path.join(BASE, 'demo', 'modellen') + SETTING_KEYS = ['company_name', 'company_address', 'company_postcode_city', 'company_phone', 'company_email', 'company_website', 'company_kvk', 'company_btw', 'company_iban', 'quote_prefix', 'quote_valid_days', 'quote_intro', 'quote_footer', @@ -81,6 +94,12 @@ app = Flask(__name__) app.secret_key = _secret_key() app.config.update(MAX_CONTENT_LENGTH=25 * 1024 * 1024, SESSION_COOKIE_SAMESITE='Lax', PERMANENT_SESSION_LIFETIME=timedelta(days=30)) +# Achter nginx/Apache met HTTPS (CRM_PROXY=1): schema en host uit X-Forwarded-*, zodat links naar buiten +# (portaaluitnodiging, API-URL) https://domein zijn, en sessiecookies alleen over HTTPS. +if os.environ.get('CRM_PROXY'): + from werkzeug.middleware.proxy_fix import ProxyFix + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) + app.config.update(SESSION_COOKIE_SECURE=True) def now(): @@ -109,7 +128,10 @@ MIGRATIONS = { 'products': {'supplier': "TEXT DEFAULT ''", 'updated_at': 'TEXT', 'supplier_id': 'INTEGER REFERENCES relations(id) ON DELETE SET NULL'}, 'contacts': {'relation_id': 'INTEGER REFERENCES relations(id) ON DELETE SET NULL', - 'relation_role': "TEXT DEFAULT ''"}, + 'relation_role': "TEXT DEFAULT ''", + 'design_file_id': 'INTEGER REFERENCES files(id) ON DELETE SET NULL'}, + 'files': {'title': "TEXT DEFAULT ''", 'kind': "TEXT DEFAULT ''", 'design_id': 'INTEGER'}, + 'quotes': {'bestek': "TEXT DEFAULT ''", 'design_file_id': 'INTEGER REFERENCES files(id) ON DELETE SET NULL'}, } @@ -133,6 +155,18 @@ def init_db(): "INSERT INTO relations (kind, type, name, created_at, updated_at) VALUES ('partner', 'Leverancier', ?, ?, ?)", (name, now(), now())).lastrowid con.execute('UPDATE products SET supplier_id=? WHERE supplier=? AND supplier_id IS NULL', (rid, name)) + # woningontwerpen van vóór het gekozen model: titel uit de JSON, het nieuwste wordt het gekozen model + for fid, cid, stored in con.execute("SELECT id, contact_id, stored FROM files WHERE original LIKE '%.json' " + "AND (title IS NULL OR title='') ORDER BY created_at DESC, id DESC").fetchall(): + try: + with open(os.path.join(UPLOADS, os.path.basename(stored)), encoding='utf-8-sig') as f: + design = json.load(f) + except (ValueError, OSError): + continue + if isinstance(design, dict) and design.get('type') == 'house-design': + title = str((design.get('project') or {}).get('naam') or '').strip() or 'Woningontwerp' + con.execute('UPDATE files SET title=? WHERE id=?', (title, fid)) + con.execute('UPDATE contacts SET design_file_id=? WHERE id=? AND design_file_id IS NULL', (fid, cid)) con.execute('PRAGMA journal_mode=WAL') con.commit() con.close() @@ -337,27 +371,62 @@ def save_upload(fs, contact_id=None): path = os.path.join(UPLOADS, stored) fs.save(path) if contact_id: - db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) ' - 'VALUES (?, ?, ?, ?, ?, ?)', - (contact_id, stored, fs.filename[:200], fs.mimetype or '', os.path.getsize(path), now())) + cur = db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) ' + 'VALUES (?, ?, ?, ?, ?, ?)', + (contact_id, stored, fs.filename[:200], fs.mimetype or '', os.path.getsize(path), now())) if ext == '.json': - note_design(contact_id, path) + note_design(contact_id, path, cur.lastrowid) return stored -def note_design(contact_id, path): - """Een woningontwerp uit de configurator krijgt een regel in de tijdlijn.""" +def read_design(path): + """Het woningontwerp (house-design-JSON uit de configurator) in een bestand, of None.""" try: with open(path, encoding='utf-8-sig') as f: design = json.load(f) except (ValueError, OSError): + return None + return design if isinstance(design, dict) and design.get('type') == 'house-design' else None + + +def design_title(design): + return str((design.get('project') or {}).get('naam') or '').strip() or 'Woningontwerp' + + +def note_design(contact_id, path, file_id): + """Een woningontwerp uit de configurator wordt het gekozen model van het contact (het nieuwste telt), + met een regel in de tijdlijn.""" + design = read_design(path) + if not design: return - if isinstance(design, dict) and design.get('type') == 'house-design': - project = design.get('project') or {} - parts = [f"Woningontwerp: {project.get('naam') or 'zonder naam'}", - ' '.join(filter(None, [project.get('straat'), project.get('nummer'), project.get('plaats')])), - project.get('opmerking') or ''] - log(contact_id, 'file', '\n'.join(p for p in parts if p)) + project = design.get('project') or {} + title = design_title(design) + db().execute('UPDATE files SET title=? WHERE id=?', (title, file_id)) + db().execute('UPDATE contacts SET design_file_id=?, updated_at=? WHERE id=?', (file_id, now(), contact_id)) + parts = [f'Woningontwerp: {title} (gekozen model)', + ' '.join(filter(None, [project.get('straat'), project.get('nummer'), project.get('plaats')])), + project.get('opmerking') or ''] + log(contact_id, 'file', '\n'.join(p for p in parts if p)) + + +PDF_KINDS = {'werk': 'werktekening', 'klant': 'klant-PDF'} + + +def design_pdfs(design_ids): + """PDF's (werktekening, klant) per woningontwerp: {design_id: {kind: {url, date, id}}}.""" + out = {} + if design_ids: + for f in q(f"SELECT * FROM files WHERE kind IN ('werk', 'klant') AND design_id IN ({', '.join('?' * len(design_ids))})", + *design_ids): + out.setdefault(f['design_id'], {})[f['kind']] = { + 'url': url_for('upload_file', name=f['stored']), 'date': fmt_dt(f['created_at']), 'id': f['id']} + return out + + +def contact_designs(contact_id): + """Alle woningontwerpen bij een contact, nieuwste eerst.""" + return [f for f in q('SELECT * FROM files WHERE contact_id=? AND original LIKE ? ORDER BY created_at DESC, id DESC', + contact_id, '%.json') if f['title']] def save_design_json(design, contact_id): @@ -366,9 +435,9 @@ def save_design_json(design, contact_id): with open(path, 'w', encoding='utf-8') as f: json.dump(design, f, ensure_ascii=False) name = secure_filename(str((design.get('project') or {}).get('naam') or 'ontwerp')) or 'ontwerp' - db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) VALUES (?, ?, ?, ?, ?, ?)', - (contact_id, stored, f'{name}.json', 'application/json', os.path.getsize(path), now())) - note_design(contact_id, path) + cur = db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) VALUES (?, ?, ?, ?, ?, ?)', + (contact_id, stored, f'{name}.json', 'application/json', os.path.getsize(path), now())) + note_design(contact_id, path, cur.lastrowid) def remove_upload(stored): @@ -637,8 +706,9 @@ def dashboard(): def contacts(): term = request.args.get('q', '').strip() args = {k: request.args.get(k, '') for k in ('type', 'status', 'owner')} - sql = ('SELECT c.*, u.name AS user_name, r.name AS relation_name, r.kind AS relation_kind FROM contacts c ' - 'LEFT JOIN users u ON u.id=c.assigned_user_id LEFT JOIN relations r ON r.id=c.relation_id WHERE 1=1') + sql = ('SELECT c.*, u.name AS user_name, r.name AS relation_name, r.kind AS relation_kind, f.title AS design_title ' + 'FROM contacts c LEFT JOIN users u ON u.id=c.assigned_user_id LEFT JOIN relations r ON r.id=c.relation_id ' + 'LEFT JOIN files f ON f.id=c.design_file_id WHERE 1=1') params = [] if term: sql += (' AND (c.name LIKE ? OR c.email LIKE ? OR c.company LIKE ? OR c.phone LIKE ? OR c.city LIKE ?' @@ -713,6 +783,16 @@ def contact_view(cid): c = q1('SELECT c.*, u.name AS user_name, r.name AS relation_name, r.kind AS relation_kind, r.type AS relation_type ' 'FROM contacts c LEFT JOIN users u ON u.id=c.assigned_user_id LEFT JOIN relations r ON r.id=c.relation_id ' 'WHERE c.id=?', cid) or abort(404) + designs = contact_designs(cid) + design = next((f for f in designs if f['id'] == c['design_file_id']), None) + design_meta = {} + if design: + d = read_design(os.path.join(UPLOADS, design['stored'])) or {} + project = d.get('project') or {} + design_meta = {'units': len(d.get('units') or []), 'exported': (d.get('exportedAt') or '')[:19], + 'address': ' '.join(filter(None, [project.get('straat'), project.get('nummer'), + project.get('plaats')])), + 'note': project.get('opmerking') or '', 'version': d.get('configurator') or ''} return render_template( 'contact.html', c=c, extra=json.loads(c['extra'] or '{}'), activities=q('SELECT a.*, u.name AS user_name FROM activities a LEFT JOIN users u ON u.id=a.user_id ' @@ -721,6 +801,8 @@ def contact_view(cid): emails=q('SELECT * FROM emails WHERE contact_id=? ORDER BY date DESC LIMIT 50', cid), tasks=q('SELECT t.*, u.name AS user_name FROM tasks t LEFT JOIN users u ON u.id=t.user_id ' 'WHERE t.contact_id=? ORDER BY t.done, t.due_date IS NULL, t.due_date', cid), + designs=designs, design=design, design_meta=design_meta, + pdfs=design_pdfs([design['id']]).get(design['id'], {}) if design else {}, PDF_KINDS=PDF_KINDS, quotes=quotes_with_totals('WHERE qu.contact_id=?', cid), portal_link=url_for('portal_invite', token=c['portal_token'], _external=True) if c['portal_token'] else '', klant_relations=q("SELECT id, name, type FROM relations WHERE kind='klant' ORDER BY name"), @@ -864,12 +946,75 @@ def upload_file(name): @app.post('/files//delete') def file_delete(fid): f = get_or_404('files', fid) + chosen = f['contact_id'] and scalar('SELECT design_file_id IS ? FROM contacts WHERE id=?', fid, f['contact_id']) + for pdf in q('SELECT stored FROM files WHERE design_id=?', fid): + remove_upload(pdf['stored']) + db().execute('DELETE FROM files WHERE design_id=?', (fid,)) remove_upload(f['stored']) db().execute('DELETE FROM files WHERE id=?', (fid,)) + if chosen: + rest = contact_designs(f['contact_id']) + db().execute('UPDATE contacts SET design_file_id=? WHERE id=?', (rest[0]['id'] if rest else None, f['contact_id'])) db().commit() return redirect(url_for('contact_view', cid=f['contact_id'])) +@app.post('/contacts//design') +def contact_design(cid): + """Gekozen model: een van de woningontwerpen bij het contact (of geen).""" + c = get_or_404('contacts', cid) + fid = request.form.get('file_id', type=int) + f = next((d for d in contact_designs(cid) if d['id'] == fid), None) + if (f['id'] if f else None) != c['design_file_id']: + db().execute('UPDATE contacts SET design_file_id=?, updated_at=? WHERE id=?', (f['id'] if f else None, now(), cid)) + log(cid, 'status', f"Gekozen model: {f['title']}" if f else 'Geen gekozen model meer') + db().commit() + return redirect(url_for('contact_view', cid=cid)) + + +@app.route('/files//viewer') +def design_viewer(fid): + """3D-viewer van een woningontwerp (in een iframe op de contactpagina, of los).""" + f = get_or_404('files', fid) + design = read_design(os.path.join(UPLOADS, os.path.basename(f['stored']))) or abort(404) + pdfs = design_pdfs([fid]).get(fid, {}) + cfg = {'pdfUrl': url_for('design_pdf', fid=fid) if f['contact_id'] else '', 'pdfs': pdfs, + 'auto': len(pdfs) < len(PDF_KINDS)} + resp = make_response(render_template( + 'viewer.html', design=design, name=f['title'] or design_title(design), scripts=VIEWER_SCRIPTS, cfg=cfg, + engine=os.path.exists(os.path.join(ENGINE, 'js', 'house', 'house-model.js')))) + resp.headers['X-Frame-Options'] = 'SAMEORIGIN' + return resp + + +@app.post('/files//pdf') +def design_pdf(fid): + """PDF van een woningontwerp, gemaakt door de viewer (HousePdf van de configurator). Per ontwerp één werktekening + (intern) en één klant-PDF (ook in het portaal); een nieuwe vervangt de vorige.""" + f = get_or_404('files', fid) + kind, fs = request.form.get('kind'), request.files.get('pdf') + if kind not in PDF_KINDS or not fs or not f['contact_id'] or not f['title']: + return jsonify(ok=False, error='Ongeldige PDF'), 400 + head = fs.stream.read(5) + fs.stream.seek(0) + if head != b'%PDF-': + return jsonify(ok=False, error='Geen PDF'), 400 + for old in q('SELECT * FROM files WHERE design_id=? AND kind=?', fid, kind): + remove_upload(old['stored']) + db().execute('DELETE FROM files WHERE id=?', (old['id'],)) + stored = uuid.uuid4().hex + '.pdf' + fs.save(os.path.join(UPLOADS, stored)) + name = secure_filename(f"{f['title']} {PDF_KINDS[kind]}") or 'ontwerp' + cur = db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at, title, kind, design_id) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + (f['contact_id'], stored, f'{name}.pdf', 'application/pdf', + os.path.getsize(os.path.join(UPLOADS, stored)), now(), '', kind, fid)) + log(f['contact_id'], 'file', f"{PDF_KINDS[kind][:1].upper() + PDF_KINDS[kind][1:]} gemaakt van ontwerp {f['title']}") + db().commit() + row = q1('SELECT * FROM files WHERE id=?', cur.lastrowid) + return jsonify(ok=True, url=url_for('upload_file', name=stored), date=fmt_dt(row['created_at']), id=row['id']) + + # ---------------------------------------------------------------- relaties def relation_form_data(): @@ -969,6 +1114,9 @@ def relation_view(rid): 'WHERE c.relation_id=? ORDER BY c.name', rid) ids = [m['id'] for m in members] or [0] marks = ', '.join('?' * len(ids)) + designs = q(f'SELECT f.*, c.name AS contact_name, c.design_file_id = f.id AS chosen FROM files f ' + f"JOIN contacts c ON c.id=f.contact_id WHERE f.contact_id IN ({marks}) AND f.title<>'' " + f"AND f.original LIKE '%.json' ORDER BY chosen DESC, f.created_at DESC", *ids) return render_template( 'relation.html', r=r, members=members, candidates=q('SELECT id, name, email FROM contacts WHERE relation_id IS NULL ORDER BY name LIMIT 500'), @@ -978,7 +1126,8 @@ def relation_view(rid): f'WHERE a.contact_id IN ({marks}) ORDER BY a.created_at DESC LIMIT 30', *ids), tasks=q(f'SELECT t.*, c.name AS contact_name FROM tasks t JOIN contacts c ON c.id=t.contact_id ' f'WHERE t.done=0 AND t.contact_id IN ({marks}) ORDER BY t.due_date IS NULL, t.due_date', *ids), - products=q('SELECT * FROM products WHERE supplier_id=? ORDER BY active DESC, name', rid)) + products=q('SELECT * FROM products WHERE supplier_id=? ORDER BY active DESC, name', rid), + designs=designs, pdfs=design_pdfs([d['id'] for d in designs]), PDF_KINDS=PDF_KINDS) @app.post('/relations//member') @@ -1442,6 +1591,8 @@ def quote_new(): if cid: log(cid, 'quote', f'Offerte {number} aangemaakt') db().commit() + if request.form.get('bestek'): + return redirect(url_for('quote_bestek', qid=cur.lastrowid)) return redirect(url_for('quote_edit', qid=cur.lastrowid)) @@ -1473,7 +1624,8 @@ def quote_edit(qid): flash('Offerte opgeslagen.') return redirect(url_for('quote_edit', qid=qid)) return render_template( - 'quote_edit.html', qt=qt, + 'quote_edit.html', qt=qt, bestek=quote_bestek_data(qt), + designs=contact_designs(qt['contact_id']) if qt['contact_id'] else [], lines=q('SELECT * FROM quote_lines WHERE quote_id=? ORDER BY position', qid), contacts=q('SELECT id, name, company FROM contacts ORDER BY name'), products=q('SELECT * FROM products WHERE active=1 ORDER BY name'), @@ -1485,7 +1637,7 @@ def render_quote(qt, portal=False): contact = q1('SELECT c.*, r.name AS relation_name, r.kind AS relation_kind FROM contacts c ' 'LEFT JOIN relations r ON r.id=c.relation_id WHERE c.id=?', qt['contact_id']) if qt['contact_id'] else None return render_template('quote_print.html', qt=qt, lines=lines, c=contact, t=quote_totals(lines), - s=settings_dict(), portal=portal) + s=settings_dict(), portal=portal, bestek=quote_bestek_data(qt)) @app.route('/quotes//print') @@ -1493,6 +1645,160 @@ def quote_print(qid): return render_quote(get_or_404('quotes', qid)) +# Bestek uit het woningontwerp: de berekening draait in de browser op de engine van de configurator +# (static/bestek.js); hier worden het bestek bewaard en de offerteregels gemaakt. + +def quote_bestek_data(qt): + try: + data = json.loads(qt['bestek'] or 'null') + except ValueError: + return None + return data if isinstance(data, dict) and isinstance(data.get('chapters'), list) else None + + +def fmt_qty(value, unit=''): + value = round(float(value or 0), 2 if unit == 'm³' else 1) + return f'{value:g}'.replace('.', ',') + + +def clean_bestek(raw): + """Bestek uit het formulier controleren en tot de bekende velden terugbrengen.""" + try: + data = json.loads(raw or '') + except ValueError: + return None + if not isinstance(data, dict) or not isinstance(data.get('chapters'), list): + return None + def product(p): + if not isinstance(p, dict) or not str(p.get('sku') or '').strip(): + return None + return {k: str(p.get(k) or '').strip()[:limit] for k, limit in + (('sku', 80), ('name', 200), ('description', 1000), ('supplier', 200), ('article', 80))} + + def line(l): + out = {'label': str(l.get('label') or '')[:300], 'qty': num(l.get('qty')), 'unit': str(l.get('unit') or '')[:20], + 'price': round(num(l.get('price')), 2), 'unitPrice': round(num(l.get('unitPrice')), 4)} + if product(l.get('product')): + out['product'] = product(l['product']) + return out + + chapters = [{'key': str(ch.get('key') or '')[:40], 'name': str(ch.get('name') or '')[:120], + 'price': round(num(ch.get('price')), 2), + 'lines': [line(l) for l in ch.get('lines') or [] if isinstance(l, dict)], + 'info': [line(l) for l in ch.get('info') or [] if isinstance(l, dict)]} + for ch in data['chapters'] if isinstance(ch, dict)] + if not chapters: + return None + totals = data.get('totals') if isinstance(data.get('totals'), dict) else {} + design = data.get('design') if isinstance(data.get('design'), dict) else {} + return {'version': 1, 'computedAt': str(data.get('computedAt') or now())[:30], 'vat': num(data.get('vat'), 21), + 'units': int(num(data.get('units'))), 'configurator': str(data.get('configurator') or '')[:20], + 'design': {'file_id': design.get('file_id'), 'title': str(design.get('title') or '')[:120]}, + 'totals': {k: round(num(totals.get(k)), 2) for k in ('exBtw', 'btw', 'inclBtw')}, 'chapters': chapters} + + +def bestek_rows(b, mode='post'): + """Offerteregels uit een bestek: dicts met desc, qty, unit, price (per eenheid) en product (of None). + + post: elke post met zijn hoeveelheid (42 spots = 42 st. × prijs per spot); hoofdstuk: één regel per hoofdstuk. + De prijs per eenheid is afgerond op centen; het verschil met het hoofdstuk gaat naar de laatste regel van + 1 stuk/post zonder product (bevestiging en bewerking, plaatsen met kraan), wat dan nog over is naar de + laatste zo'n regel van de offerte, zodat het totaal precies dat van de configurator is.""" + def absorb(rows, target): + diff = round(target - sum(r['qty'] * r['price'] for r in rows), 2) + spot = next((r for r in reversed(rows) if r['qty'] == 1 and not r['product']), None) or \ + next((r for r in reversed(rows) if r['qty'] == 1), None) + if spot and diff: + spot['price'] = round(spot['price'] + diff, 2) + + if mode == 'hoofdstuk': + rows = [{'desc': ch['name'] + '\n' + ', '.join(f"{l['label']} {fmt_qty(l['qty'], l['unit'])} {l['unit']}".strip() + for l in ch['lines']), + 'qty': 1, 'unit': 'post', 'price': ch['price'], 'product': None} for ch in b['chapters']] + else: + rows = [] + for ch in b['chapters']: + part = [{'desc': f"{l['label']}\n{ch['name']}", 'qty': l['qty'] or 1, 'unit': l['unit'], + 'price': round(l.get('unitPrice') or (l['price'] / l['qty'] if l['qty'] else l['price']), 2), + 'product': l.get('product')} for l in ch['lines']] + absorb(part, ch['price']) + rows += part + absorb(rows, b['totals'].get('exBtw') or sum(ch['price'] for ch in b['chapters'])) + return rows + + +def bestek_product(row, vat, update_price, source): + """Product bij een bestekpost (SKU = code uit de kosten van de configurator); bestaat het nog niet, dan wordt + het aangemaakt met de prijs per eenheid van deze berekening.""" + p = row['product'] + existing = find_product(p['sku']) + if existing is None: + description = '\n'.join(filter(None, [p['description'], f"Artikelnummer leverancier: {p['article']}" if p['article'] else ''])) + return upsert_product({'sku': p['sku'], 'name': p['name'] or row['desc'].split('\n')[0], 'unit': row['unit'], + 'price': row['price'], 'vat': vat, 'description': description, 'supplier': p['supplier']}, + source)[2] + if update_price: + upsert_product({'sku': p['sku'], 'price': row['price']}, source, existing=existing) + return existing['id'] + + +@app.route('/quotes//bestek') +def quote_bestek(qid): + qt = get_or_404('quotes', qid) + contact = q1('SELECT * FROM contacts WHERE id=?', qt['contact_id']) if qt['contact_id'] else None + designs = contact_designs(contact['id']) if contact else [] + want = request.args.get('file', type=int) or qt['design_file_id'] or (contact['design_file_id'] if contact else None) + f = next((d for d in designs if d['id'] == want), designs[0] if designs else None) + if not f: + flash('Er is geen woningontwerp bij het contact van deze offerte. Kies eerst een klant met een gekozen model.', 'error') + return redirect(url_for('quote_edit', qid=qid)) + design = read_design(os.path.join(UPLOADS, os.path.basename(f['stored']))) or abort(404) + return render_template('bestek.html', qt=qt, c=contact, design=design, design_file=f, designs=designs, + scripts=VIEWER_SCRIPTS, has_lines=scalar('SELECT COUNT(*) FROM quote_lines WHERE quote_id=?', qid), + engine=os.path.exists(os.path.join(ENGINE, 'js', 'house', 'house-model.js'))) + + +@app.post('/quotes//bestek') +def quote_bestek_save(qid): + qt = get_or_404('quotes', qid) + b = clean_bestek(request.form.get('bestek')) + if not b: + flash('Het bestek kon niet worden overgenomen.', 'error') + return redirect(url_for('quote_bestek', qid=qid)) + fid = request.form.get('file_id', type=int) + if qt['contact_id'] and fid not in {d['id'] for d in contact_designs(qt['contact_id'])}: + fid = None + vat = b['vat'] + rows = bestek_rows(b, request.form.get('mode', 'post')) + if request.form.get('replace'): + db().execute('DELETE FROM quote_lines WHERE quote_id=?', (qid,)) + start = scalar('SELECT COALESCE(MAX(position), -1) + 1 FROM quote_lines WHERE quote_id=?', qid) + source, linked = f"Bestek offerte {qt['number']}", 0 + for pos, r in enumerate(rows, start): + pid = bestek_product(r, vat, bool(request.form.get('update_prices')), source) if r['product'] else None + linked += bool(pid) + db().execute('INSERT INTO quote_lines (quote_id, position, product_id, description, qty, unit, price, vat) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)', (qid, pos, pid, r['desc'], r['qty'], r['unit'], r['price'], vat)) + title = qt['title'] or (f"Woning {b['design']['title']}" if b['design']['title'] else '') + db().execute('UPDATE quotes SET bestek=?, design_file_id=?, title=?, updated_at=? WHERE id=?', + (json.dumps(b, ensure_ascii=False), fid, title, now(), qid)) + if qt['contact_id']: + log(qt['contact_id'], 'quote', f"Offerte {qt['number']}: bestek uit ontwerp {b['design']['title']} " + f"({euro(b['totals']['exBtw'])} excl. btw)") + db().commit() + flash(f'Bestek overgenomen: {len(rows)} regel(s), waarvan {linked} gekoppeld aan een product.') + return redirect(url_for('quote_edit', qid=qid)) + + +@app.post('/quotes//bestek/delete') +def quote_bestek_delete(qid): + get_or_404('quotes', qid) + db().execute("UPDATE quotes SET bestek='', design_file_id=NULL, updated_at=? WHERE id=?", (now(), qid)) + db().commit() + flash('Bestek losgekoppeld; de offerteregels zijn gebleven.') + return redirect(url_for('quote_edit', qid=qid)) + + @app.post('/quotes//delete') def quote_delete(qid): get_or_404('quotes', qid) @@ -1573,7 +1879,7 @@ def portal_home(): return render_template( 'portal.html', c=g.portal, quotes=quotes_with_totals("WHERE qu.contact_id=? AND qu.status<>'concept'", cid), - files=q('SELECT * FROM files WHERE contact_id=? ORDER BY created_at DESC', cid), + files=q("SELECT * FROM files WHERE contact_id=? AND kind<>'werk' ORDER BY created_at DESC", cid), messages=q("SELECT * FROM activities WHERE contact_id=? AND type='portal' AND body LIKE 'Bericht:%' " "ORDER BY created_at DESC LIMIT 20", cid), s=settings_dict()) @@ -1626,7 +1932,7 @@ def portal_message(): @app.route('/portal/files/') def portal_file(name): - f = q1('SELECT * FROM files WHERE stored=? AND contact_id=?', name, g.portal['id']) + f = q1("SELECT * FROM files WHERE stored=? AND contact_id=? AND kind<>'werk'", name, g.portal['id']) if f is None: abort(404) return serve_upload(name, f['original']) @@ -1748,7 +2054,52 @@ def api_products(): return jsonify(ok=True, **result) +# ---------------------------------------------------------------- demo + +DEMO_LEADS = [ + ('Lorem Ipsum', 'Utrecht', 'nieuw', 'amstel'), + ('Dolor Sitamet', 'Amersfoort', 'contact', 'wetering'), + ('Consectetur Adipiscing', 'Zwolle', 'nieuw', 'ijssel'), + ('Sed Doeiusmod', 'Nijmegen', 'offerte', 'waal'), + ('Tempor Incididunt', 'Maastricht', 'contact', 'maas'), + ('Labore Etdolore', 'Arnhem', 'nieuw', 'rijn'), + ('Magna Aliqua', 'Meppel', 'offerte', 'reest'), + ('Enim Adminim', 'Breukelen', 'nieuw', 'vecht'), +] +DEMO_MESSAGE = ('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Wij zoeken een mantelzorgwoning ' + 'voor in de tuin; graag meer informatie over het model en de mogelijkheden.') + + +def seed_demo(): + """Demoleads met elk een standaardmodel als gekozen model (demo/modellen, uit tools/export-models.js). + Bestaat een demolead al (zelfde e-mailadres), dan wordt hij overgeslagen.""" + admin = q1("SELECT id FROM users WHERE role='admin' AND active=1 ORDER BY id") + made = 0 + for i, (name, city, status, model) in enumerate(DEMO_LEADS): + path = os.path.join(DEMO_MODELS, f'{model}.json') + design = read_design(path) + if not design: + print(f'{model}.json ontbreekt in {DEMO_MODELS}; draai node tools/export-models.js') + continue + email = name.lower().replace(' ', '.') + '@example.nl' + cid, outcome = upsert_contact( + {'name': name, 'email': email, 'phone': f'06-{12345670 + i}', 'city': city, 'status': status}, + {'model': design_title(design)}, update_existing=False, + defaults={'source': 'Demo', 'assigned': admin['id'] if admin else None}) + if outcome != 'created': + continue + log(cid, 'form', f'Formulier ontvangen via Demo\n{DEMO_MESSAGE}') + save_design_json(design, cid) + made += 1 + db().commit() + print(f'{made} demolead(s) aangemaakt') + + if __name__ == '__main__': + if len(sys.argv) > 1 and sys.argv[1] == 'demo': + with app.app_context(): + seed_demo() + sys.exit(0) if len(sys.argv) > 1 and sys.argv[1] == 'sync': with app.app_context(): n, err = do_sync() diff --git a/demo/modellen/amstel.json b/demo/modellen/amstel.json new file mode 100644 index 0000000..43d8838 --- /dev/null +++ b/demo/modellen/amstel.json @@ -0,0 +1 @@ +{"type":"house-design","version":1,"exportedAt":"2026-10-14T20:05:43.119Z","model":"amstel","configurator":"0.7.7","project":{"naam":"Amstel","plaats":"","straat":"","nummer":"","tekenaar":"","opmerking":""},"settings":{"spacing":{"exterior":{"min":240,"max":480},"interior":{"min":300,"max":600}},"facade":{"type":"stuc","stone":"K490","rabatColor":"naturel","stucColor":"wit","felsColor":"antraciet"},"cladding":"verticaal","trasraam":{"enabled":false,"height":350,"finish":"cementplaat","capColor":"antraciet"},"interiorPaint":true,"interiorColor":"#f2efe9","ceilingColor":"#f4f3ef","sandwich":true,"groundLevel":0,"floorFinish":"visgraat_001","roof":{"overhang":200,"fasciaHeight":250,"spotMin":800,"spotMax":1200,"trimHeight":30,"fasciaColor":"wit","vent":true,"ventColor":"antraciet","house":{"enabled":true,"form":{"type":"plat","low":2800,"high":2800},"cover":true},"posts":{"enabled":true,"size":200,"wood":"eiken","maxSpan":3600,"minExt":1000,"sideWall":false,"sideColor":"wit"}},"lighting":{"mode":"gevel","direction":"both","lampHeight":1950,"lampMin":500,"lampMax":3000,"minWallWidth":2000,"endOffset":300,"openingOffset":500,"indoor":true,"indoorMin":800,"indoorMax":1500,"indoorOffset":600},"elektra":{"enabled":true,"meterkast":{"w":600,"h":900,"d":250,"y":1000},"switchHeight":1500,"wcdHeight":400,"wcdPerUnit":2,"plateColor":"antraciet","slack":10,"reserve":300,"meterkastReserve":500,"apparaten":{"koken":true,"wasmachine":true,"droger":true,"warmtepomp":true,"oven":true,"vaatwasser":true},"place":{},"vloerWcd":[],"wcdExtra":{},"removed":[],"v":2}},"units":[{"id":"A","attach":null,"unit":{"layout":{"corners":"voor-achter"},"floor":{"floor":{"width":11240,"depth":3600,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"walls":{"voor":{"wall":{"width":11240,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"verticaal","stone":null,"stuc":"wit","trasraam":null,"rabatColor":null,"fels":null},"openings":[{"id":1,"type":"deur","w":1474,"h":2650,"y":0,"anchor":"right","offset":150,"fill":{"type":"deur","rooster":false,"glas":false,"out":"zwart","in":"zwart","leaf":"links","hinge":"rechts","swing":"buiten","volglas":true}},{"id":2,"type":"deur","w":1474,"h":2650,"y":0,"anchor":"right","offset":1776,"fill":{"type":"deur","rooster":false,"glas":false,"out":"zwart","in":"zwart","leaf":"rechts","hinge":"links","swing":"buiten","volglas":true}},{"id":3,"type":"raam","w":1000,"h":2650,"y":0,"anchor":"left","offset":3560,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"achter":{"wall":{"width":11240,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"verticaal","stone":null,"stuc":"wit","trasraam":null,"rabatColor":null,"fels":null},"openings":[{"id":1,"type":"raam","w":480,"h":2650,"y":0,"anchor":"right","offset":80,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"links":{"wall":{"width":3088,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"verticaal","stone":null,"stuc":"wit","trasraam":null,"rabatColor":null,"fels":null},"openings":[{"id":1,"type":"raam","w":1392,"h":2650,"y":0,"anchor":"middle","offset":-772,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":2,"type":"raam","w":1392,"h":2650,"y":0,"anchor":"middle","offset":772,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"rechts":{"wall":{"width":3088,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"verticaal","stone":null,"stuc":"wit","trasraam":null,"rabatColor":null,"fels":null},"openings":[{"id":1,"type":"raam","w":1392,"h":2650,"y":0,"anchor":"middle","offset":-772,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":2,"type":"raam","w":1392,"h":2650,"y":0,"anchor":"middle","offset":772,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]}}},"roof":{"followFloor":true,"offsetX":0,"offsetZ":0,"extend":{"f":0,"r":0,"b":0,"l":0},"fascia":true,"covering":"dakleer","deck":{"floor":{"width":11240,"depth":3600,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"unitWalls":{"voor":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"achter":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"links":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"rechts":{"heightAll":2800,"heightLeft":2800,"heightRight":2800}}},"lighting":{"walls":{}},"facade":{"walls":{}}},{"id":"B","attach":{"to":"A","side":"achter","align":"start","offset":0},"unit":{"layout":{"corners":"voor-achter"},"floor":{"floor":{"width":10600,"depth":3600,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"walls":{"voor":{"wall":{"width":10600,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"verticaal","stone":null,"stuc":"wit","trasraam":null,"rabatColor":null,"fels":null},"openings":[]},"achter":{"wall":{"width":10600,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"verticaal","stone":null,"stuc":"wit","trasraam":null,"rabatColor":null,"fels":null},"openings":[{"id":1,"type":"raam","w":500,"h":700,"y":1400,"anchor":"left","offset":2230,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":2,"type":"voordeur","w":1100,"h":2100,"y":0,"anchor":"left","offset":3080,"fill":{"type":"deur","rooster":false,"glas":false,"out":"zwart","in":"zwart","hinge":"links","swing":"buiten","volglas":true}},{"id":3,"type":"raam","w":500,"h":2650,"y":0,"anchor":"left","offset":6490,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":4,"type":"raam","w":500,"h":2650,"y":0,"anchor":"right","offset":1150,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}],"splits":[{"u":6390,"facade":{"type":"rabat"}}]},"links":{"wall":{"width":3088,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"verticaal","stone":null,"stuc":"wit","trasraam":null,"rabatColor":null,"fels":null},"openings":[{"id":1,"type":"raam","w":1000,"h":2650,"y":0,"anchor":"left","offset":250,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"rechts":{"wall":{"width":3088,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"naturel","fels":null},"openings":[]}}},"roof":{"followFloor":true,"offsetX":320,"offsetZ":0,"extend":{"f":0,"r":0,"b":0,"l":0},"fascia":true,"covering":"dakleer","deck":{"floor":{"width":11240,"depth":3600,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"unitWalls":{"voor":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"achter":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"links":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"rechts":{"heightAll":2800,"heightLeft":2800,"heightRight":2800}}},"lighting":{"walls":{"achter":"openingen"}},"facade":{"walls":{"rechts":{"type":"rabat"}}}}],"interiorWalls":[{"id":1,"x1":2555,"z1":3700,"x2":2555,"z2":4800,"wall":{"wall":{"width":1100,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":850,"h":2115,"y":0,"anchor":"left","offset":80,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"buiten"}}]}},{"id":2,"x1":1980,"z1":4845,"x2":3070,"z2":4845,"wall":{"wall":{"width":1090,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":3,"x1":2555,"z1":4890,"x2":2555,"z2":5655,"wall":{"wall":{"width":765,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":4,"x1":1935,"z1":4800,"x2":1935,"z2":6944,"wall":{"wall":{"width":2144,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":600,"h":2115,"y":0,"anchor":"left","offset":170,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"buiten"}}]}},{"id":5,"x1":1980,"z1":5700,"x2":3070,"z2":5700,"wall":{"wall":{"width":1090,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":6,"x1":3025,"z1":5745,"x2":3025,"z2":6944,"wall":{"wall":{"width":1199,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":80,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"rechts","swing":"binnen"}}]}},{"id":7,"x1":4215,"z1":4800,"x2":4215,"z2":6944,"wall":{"wall":{"width":2144,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":8,"x1":4260,"z1":4845,"x2":6300,"z2":4845,"wall":{"wall":{"width":2040,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":9,"x1":6345,"z1":3700,"x2":6345,"z2":6944,"wall":{"wall":{"width":3244,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":80,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"binnen"}},{"id":2,"type":"deur","w":980,"h":2115,"y":0,"anchor":"left","offset":1990,"fill":{"type":"schuifdeur","rooster":false,"glas":false,"out":"wit","in":"wit","leaves":1,"mount":"opbouw","side":"binnen","dir":"links"}}]}},{"id":10,"seam":{"a":"A","b":"B"},"wall":{"wall":{"width":10088,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":90,"plateThick":38,"plateDepth":90,"sheathingThick":10,"minSpacing":300,"maxSpacing":600,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"geen"},"openings":[{"id":1,"type":"deur","w":2000,"h":2150,"y":0,"anchor":"left","offset":3194,"fill":{"type":"schuifdeur","rooster":false,"glas":true,"out":"zwart","in":"zwart","leaves":2,"mount":"opbouw","side":"binnen"}}]}}],"rooms":[{"id":1,"unitId":"A","x":5600,"z":1200,"type":"woonkeuken","name":"Woonkamer/keuken","floor":null,"paint":null,"fibo":null,"ceiling":null},{"id":2,"unitId":"B","x":1000,"z":2000,"type":"slaapkamer","name":"Logeerkamer","floor":null,"paint":null,"fibo":null,"ceiling":null},{"id":3,"unitId":"B","x":3600,"z":2600,"type":"hal","name":"Entree","floor":null,"paint":null,"fibo":null,"ceiling":null},{"id":4,"unitId":"B","x":2480,"z":2800,"type":"toilet","name":"","floor":"tegel_antraciet","paint":null,"fibo":{"decor":"wit_tegel","height":1200},"ceiling":2600},{"id":5,"unitId":"B","x":5300,"z":2700,"type":"badkamer","name":"","floor":"tegel_antraciet","paint":null,"fibo":{"decor":"wit_tegel","height":null},"ceiling":2600},{"id":6,"unitId":"B","x":8300,"z":1800,"type":"slaapkamer","name":"Slaapkamer","floor":null,"paint":null,"fibo":null,"ceiling":null},{"id":7,"unitId":"B","x":2245,"z":1650,"type":"techniek","name":"Boilerkast","floor":null,"paint":null,"fibo":null,"ceiling":null}],"fixtures":[{"id":1,"type":"keuken","unitId":"A","x":4746,"z":556,"anchor":"west","rot":0,"w":2660,"d":600,"h":910,"opts":{"front":"antraciet","spoel":true,"spoelbak":0.49,"kookplaat":false,"kookplaatPlek":0.2,"hogeKast":"links","vaatwasser":true,"bovenkasten":false}},{"id":2,"type":"kookeiland","unitId":"A","x":4746,"z":2000,"anchor":"west","rot":0,"w":2660,"d":600,"h":910,"opts":{"front":"antraciet","kookplaat":true}},{"id":3,"type":"eettafel","unitId":"A","x":1600,"z":1820,"anchor":"west","rot":90,"w":2000,"d":1100,"h":750,"opts":{"vorm":"ovaal","stoelen":6}},{"id":4,"type":"bank","unitId":"A","x":1680,"z":731,"anchor":"oost","rot":0,"w":2600,"d":950,"h":820,"opts":{"chaise":"rechts","chaiseLengte":1700}},{"id":5,"type":"fauteuil","unitId":"A","x":3840,"z":1750,"anchor":"oost","rot":290,"w":850,"d":850,"h":800,"opts":{}},{"id":6,"type":"salontafel","unitId":"A","x":2100,"z":2040,"anchor":"oost","rot":0,"w":800,"d":800,"h":400,"opts":{"vorm":"rond"}},{"id":7,"type":"tvmeubel","unitId":"A","x":1940,"z":3300,"anchor":"oost","rot":180,"w":1800,"d":400,"h":450,"opts":{"tv":true}},{"id":8,"type":"toilet","unitId":"B","x":4605,"z":2840,"anchor":"west","rot":270,"w":600,"d":690,"h":1200,"opts":{}},{"id":9,"type":"douche","unitId":"B","x":4860,"z":1740,"anchor":"west","rot":270,"w":900,"d":1200,"h":2100,"opts":{"glas":"links","glasLengte":800}},{"id":10,"type":"wastafel","unitId":"B","x":5850,"z":3114,"anchor":"west","rot":180,"w":800,"d":460,"h":850,"opts":{"front":"eiken","spiegel":true}},{"id":11,"type":"toilet","unitId":"B","x":2480,"z":2999,"anchor":"west","rot":180,"w":1000,"d":690,"h":1200,"opts":{}},{"id":12,"type":"fontein","unitId":"B","x":2105,"z":2350,"anchor":"west","rot":270,"w":400,"d":250,"h":850,"opts":{}},{"id":13,"type":"boiler","unitId":"B","x":2285,"z":1670,"anchor":"west","rot":90,"w":450,"d":450,"h":800,"opts":{"liter":80}},{"id":14,"type":"bed","unitId":"B","x":706,"z":2294,"anchor":"west","rot":180,"w":900,"d":2100,"h":520,"opts":{"nachtkastjes":false}},{"id":15,"type":"bed","unitId":"B","x":2600,"z":2294,"anchor":"oost","rot":180,"w":1600,"d":2100,"h":520,"opts":{"nachtkastjes":false}},{"id":16,"type":"kledingkast","unitId":"B","x":556,"z":1650,"anchor":"oost","rot":90,"w":3020,"d":600,"h":2400,"opts":{"front":"wit"}}],"chimneys":[]} \ No newline at end of file diff --git a/demo/modellen/ijssel.json b/demo/modellen/ijssel.json new file mode 100644 index 0000000..76a35a2 --- /dev/null +++ b/demo/modellen/ijssel.json @@ -0,0 +1 @@ +{"type":"house-design","version":1,"exportedAt":"2026-10-14T20:05:43.215Z","model":"ijssel","configurator":"0.7.7","project":{"naam":"IJssel","plaats":"","straat":"","nummer":"","tekenaar":"","opmerking":""},"settings":{"spacing":{"exterior":{"min":240,"max":480},"interior":{"min":300,"max":600}},"facade":{"type":"stuc","stone":"K490","rabatColor":"naturel","stucColor":"wit","felsColor":"antraciet"},"cladding":"horizontaal","trasraam":{"enabled":true,"height":600,"finish":"R510","capColor":"antraciet"},"interiorPaint":true,"interiorColor":"#f2efe9","ceilingColor":"#f4f3ef","sandwich":true,"groundLevel":0,"floorFinish":"visgraat_001","roof":{"overhang":300,"fasciaHeight":300,"spotMin":800,"spotMax":1200,"trimHeight":30,"fasciaColor":"zwart","vent":true,"ventColor":"antraciet","house":{"enabled":false,"form":null,"cover":false},"posts":{"enabled":true,"size":200,"wood":"eiken","maxSpan":3600,"minExt":1000,"sideWall":false,"sideColor":"wit"}},"lighting":{"mode":"spots","direction":"both","lampHeight":1950,"lampMin":500,"lampMax":3000,"minWallWidth":2000,"endOffset":300,"openingOffset":500,"indoor":true,"indoorMin":800,"indoorMax":1500,"indoorOffset":600},"elektra":{"enabled":true,"meterkast":{"w":600,"h":900,"d":250,"y":1000},"switchHeight":1500,"wcdHeight":400,"wcdPerUnit":2,"plateColor":"antraciet","slack":10,"reserve":300,"meterkastReserve":500,"apparaten":{"koken":true,"wasmachine":true,"droger":true,"warmtepomp":true,"oven":true,"vaatwasser":true},"place":{},"vloerWcd":[],"wcdExtra":{},"removed":[],"v":2}},"units":[{"id":"A","attach":null,"unit":{"layout":{"corners":"voor-achter"},"floor":{"floor":{"width":14000,"depth":4600,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"walls":{"voor":{"wall":{"width":14000,"heightAll":3000,"heightLeft":3000,"heightRight":3000,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"horizontaal","stone":null,"stuc":"wit","trasraam":{"bottom":0,"top":600,"finish":"R510","capColor":"antraciet"},"rabatColor":null,"fels":null},"openings":[{"id":1,"type":"raam","w":1500,"h":500,"y":1600,"anchor":"right","offset":5445,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":2,"type":"raam","w":500,"h":700,"y":1400,"anchor":"right","offset":9950,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"achter":{"wall":{"width":14000,"heightAll":3000,"heightLeft":3000,"heightRight":3000,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"horizontaal","stone":null,"stuc":"wit","trasraam":{"bottom":0,"top":600,"finish":"R510","capColor":"antraciet"},"rabatColor":null,"fels":null},"openings":[{"id":1,"type":"raam","w":1800,"h":2800,"y":0,"anchor":"left","offset":3215,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":2,"type":"voordeur","w":1100,"h":2100,"y":0,"anchor":"left","offset":8240,"fill":{"type":"deur","rooster":false,"glas":true,"out":"zwart","in":"zwart","hinge":"rechts","swing":"buiten"}}]},"links":{"wall":{"width":4088,"heightAll":3178,"heightLeft":3178,"heightRight":3178,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":4600},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"horizontaal","stone":null,"stuc":"wit","trasraam":{"bottom":0,"top":600,"finish":"R510","capColor":"antraciet"},"rabatColor":null,"fels":null},"openings":[{"id":1,"type":"deur","w":1424,"h":2100,"y":0,"anchor":"left","offset":610,"fill":{"type":"deur","rooster":false,"glas":false,"out":"zwart","in":"zwart","leaf":"rechts","hinge":"links","swing":"buiten","volglas":true}},{"id":2,"type":"deur","w":1424,"h":2100,"y":0,"anchor":"left","offset":2376,"fill":{"type":"deur","rooster":false,"glas":false,"out":"zwart","in":"zwart","leaf":"links","hinge":"rechts","swing":"buiten","volglas":true}}]},"rechts":{"wall":{"width":4088,"heightAll":3000,"heightLeft":3000,"heightRight":3000,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null,"joinTop":true},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"horizontaal","stone":null,"stuc":"wit","trasraam":{"bottom":0,"top":600,"finish":"R510","capColor":"antraciet"},"rabatColor":null,"fels":null},"openings":[{"id":1,"type":"raam","w":1400,"h":1100,"y":1000,"anchor":"right","offset":608,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":2,"type":"raam","w":1200,"h":500,"y":1600,"anchor":"right","offset":3101,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]}}},"roof":{"followFloor":true,"offsetX":0,"offsetZ":0,"extend":{"f":0,"r":0,"b":0,"l":0},"fascia":true,"covering":"riet_001","deck":{"floor":{"width":14000,"depth":4600,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]}},"lighting":{"walls":{"achter":"openingen"}},"facade":{"walls":{}}},{"id":"B","attach":{"to":"A","side":"achter","align":"end","offset":0},"unit":{"layout":{"corners":"voor-achter"},"floor":{"floor":{"width":4600,"depth":3000,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"walls":{"voor":{"wall":{"width":4600,"heightAll":3000,"heightLeft":3000,"heightRight":3000,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":4600},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"horizontaal","stone":null,"stuc":"wit","trasraam":{"bottom":0,"top":600,"finish":"R510","capColor":"antraciet"},"rabatColor":null,"fels":null},"openings":[]},"achter":{"wall":{"width":4600,"heightAll":3000,"heightLeft":3000,"heightRight":3000,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":4600},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"horizontaal","stone":null,"stuc":"wit","trasraam":{"bottom":0,"top":600,"finish":"R510","capColor":"antraciet"},"rabatColor":null,"fels":null},"openings":[{"id":1,"type":"raam","w":500,"h":1400,"y":700,"anchor":"left","offset":410,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":2,"type":"raam","w":500,"h":1400,"y":700,"anchor":"right","offset":1100,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"links":{"wall":{"width":2488,"heightAll":3000,"heightLeft":3000,"heightRight":3000,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"horizontaal","stone":null,"stuc":"wit","trasraam":{"bottom":0,"top":600,"finish":"R510","capColor":"antraciet"},"rabatColor":null,"fels":null},"openings":[]},"rechts":{"wall":{"width":2488,"heightAll":3000,"heightLeft":3000,"heightRight":3000,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"horizontaal","stone":null,"stuc":"wit","trasraam":{"bottom":0,"top":600,"finish":"R510","capColor":"antraciet"},"rabatColor":null,"fels":null},"openings":[]}}},"roof":{"followFloor":true,"offsetX":0,"offsetZ":0,"extend":{"f":0,"r":0,"b":0,"l":0},"fascia":true,"covering":"riet_001","deck":{"floor":{"width":4600,"depth":3000,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]}},"lighting":{"walls":{}},"facade":{"walls":{}}}],"interiorWalls":[{"id":1,"x1":9580,"z1":190,"x2":9580,"z2":3220,"wall":{"wall":{"width":3030,"heightAll":2900,"heightLeft":2900,"heightRight":2900,"studWidth":38,"studDepth":140,"plateThick":38,"plateDepth":140,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":2,"x1":8060,"z1":3265,"x2":9660,"z2":3265,"wall":{"wall":{"width":1600,"heightAll":2900,"heightLeft":2900,"heightRight":2900,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":3,"x1":8105,"z1":3310,"x2":8105,"z2":4410,"wall":{"wall":{"width":1100,"heightAll":2900,"heightLeft":2900,"heightRight":2900,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":86,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"rechts","swing":"buiten"}}]}},{"id":4,"x1":9660,"z1":1633,"x2":10760,"z2":1633,"wall":{"wall":{"width":1100,"heightAll":2900,"heightLeft":2900,"heightRight":2900,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":85,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"buiten"}}]}},{"id":5,"x1":10805,"z1":190,"x2":10805,"z2":4410,"wall":{"wall":{"width":4220,"heightAll":2900,"heightLeft":2900,"heightRight":2900,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":1725,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"rechts","swing":"binnen"}}]}},{"id":6,"x1":10850,"z1":2965,"x2":13810,"z2":2965,"wall":{"wall":{"width":2960,"heightAll":2900,"heightLeft":2900,"heightRight":2900,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":7,"x1":9590,"z1":4455,"x2":13810,"z2":4455,"wall":{"wall":{"width":4220,"heightAll":2900,"heightLeft":2900,"heightRight":2900,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":115,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"buiten"}},{"id":2,"type":"deur","w":980,"h":2115,"y":0,"anchor":"left","offset":2350,"fill":{"type":"schuifdeur","rooster":false,"glas":false,"out":"wit","in":"wit","leaves":1,"mount":"opbouw","side":"buiten","dir":"links"}}]}}],"rooms":[{"id":1,"unitId":"A","x":3000,"z":1500,"type":"woonkeuken","name":"Woonkamer/keuken","floor":null,"paint":null,"fibo":null,"ceiling":2900},{"id":2,"unitId":"A","x":10210,"z":900,"type":"toilet","name":"","floor":"tegel_antraciet","paint":null,"fibo":{"decor":"wit_tegel","height":1200},"ceiling":2600},{"id":3,"unitId":"A","x":12300,"z":1500,"type":"berging","name":"Berging","floor":null,"paint":null,"fibo":null,"ceiling":2900},{"id":4,"unitId":"A","x":9000,"z":3800,"type":"hal","name":"Hal","floor":null,"paint":null,"fibo":null,"ceiling":2900},{"id":5,"unitId":"A","x":12300,"z":3700,"type":"badkamer","name":"","floor":"tegel_antraciet","paint":null,"fibo":{"decor":"wit_tegel","height":null},"ceiling":2600},{"id":6,"unitId":"B","x":2000,"z":1400,"type":"slaapkamer","name":"Slaapkamer","floor":null,"paint":null,"fibo":null,"ceiling":2900}],"fixtures":[{"id":1,"type":"bank","unitId":"A","x":2030,"z":690,"anchor":"west","rot":0,"w":3600,"d":1000,"h":820,"opts":{"chaise":"links","chaiseLengte":1700}},{"id":2,"type":"salontafel","unitId":"A","x":2410,"z":1980,"anchor":"west","rot":0,"w":800,"d":800,"h":400,"opts":{"vorm":"rond"}},{"id":3,"type":"fauteuil","unitId":"A","x":3120,"z":2950,"anchor":"west","rot":150,"w":850,"d":850,"h":800,"opts":{}},{"id":4,"type":"tvmeubel","unitId":"A","x":1060,"z":4210,"anchor":"west","rot":180,"w":1200,"d":400,"h":450,"opts":{"tv":true}},{"id":5,"type":"eettafel","unitId":"A","x":6100,"z":1115,"anchor":"west","rot":0,"w":2000,"d":1000,"h":750,"opts":{"vorm":"ovaal","stoelen":6}},{"id":6,"type":"keuken","unitId":"A","x":9200,"z":1705,"anchor":"west","rot":90,"w":3030,"d":600,"h":910,"opts":{"front":"antraciet","spoel":true,"spoelbak":0.16,"kookplaat":true,"kookplaatPlek":0.8,"hogeKast":"rechts","vaatwasser":true,"bovenkasten":false}},{"id":7,"type":"toilet","unitId":"A","x":10210,"z":535,"anchor":"west","rot":0,"w":1100,"d":690,"h":1200,"opts":{}},{"id":8,"type":"boiler","unitId":"A","x":11040,"z":415,"anchor":"west","rot":0,"w":450,"d":450,"h":800,"opts":{"liter":80}},{"id":9,"type":"douche","unitId":"A","x":11300,"z":3710,"anchor":"west","rot":0,"w":900,"d":1400,"h":2100,"opts":{"glas":"rechts","glasLengte":1400}},{"id":10,"type":"wastafel","unitId":"A","x":12300,"z":3240,"anchor":"west","rot":0,"w":800,"d":460,"h":850,"opts":{"front":"eiken","spiegel":true}},{"id":11,"type":"toilet","unitId":"A","x":13355,"z":3355,"anchor":"west","rot":0,"w":900,"d":690,"h":1200,"opts":{}},{"id":12,"type":"bed","unitId":"B","x":2000,"z":1760,"anchor":"west","rot":180,"w":1600,"d":2100,"h":520,"opts":{"nachtkastjes":false}},{"id":13,"type":"kledingkast","unitId":"B","x":490,"z":1355,"anchor":"oost","rot":90,"w":2910,"d":600,"h":2400,"opts":{"front":"wit"}}],"chimneys":[]} \ No newline at end of file diff --git a/demo/modellen/index.json b/demo/modellen/index.json new file mode 100644 index 0000000..dd2579a --- /dev/null +++ b/demo/modellen/index.json @@ -0,0 +1,37 @@ +{ + "configurator": "0.7.7", + "models": [ + { + "key": "amstel", + "label": "Woning Amstel" + }, + { + "key": "wetering", + "label": "Woning Wetering" + }, + { + "key": "ijssel", + "label": "Woning IJssel" + }, + { + "key": "waal", + "label": "Woning Waal" + }, + { + "key": "maas", + "label": "Woning Maas" + }, + { + "key": "rijn", + "label": "Woning Rijn" + }, + { + "key": "reest", + "label": "Woning Reest" + }, + { + "key": "vecht", + "label": "Woning Vecht" + } + ] +} diff --git a/demo/modellen/maas.json b/demo/modellen/maas.json new file mode 100644 index 0000000..51d0d05 --- /dev/null +++ b/demo/modellen/maas.json @@ -0,0 +1 @@ +{"type":"house-design","version":1,"exportedAt":"2026-10-14T20:05:43.290Z","model":"maas","configurator":"0.7.7","project":{"naam":"Maas","plaats":"","straat":"","nummer":"","tekenaar":"","opmerking":""},"settings":{"spacing":{"exterior":{"min":240,"max":480},"interior":{"min":300,"max":600}},"facade":{"type":"rabat","stone":"K490","rabatColor":"naturel","stucColor":"wit","felsColor":"antraciet"},"cladding":"verticaal","trasraam":{"enabled":false,"height":350,"finish":"cementplaat","capColor":"antraciet"},"interiorPaint":true,"interiorColor":"#f2efe9","ceilingColor":"#f4f3ef","sandwich":true,"groundLevel":0,"floorFinish":"visgraat_001","roof":{"overhang":300,"fasciaHeight":300,"spotMin":800,"spotMax":1200,"trimHeight":30,"fasciaColor":"antraciet","vent":true,"ventColor":"antraciet","house":{"enabled":true,"form":{"type":"zadel","ridge":"x","low":2800,"high":3500},"cover":true},"posts":{"enabled":true,"size":200,"wood":"eiken","maxSpan":3600,"minExt":1000,"sideWall":false,"sideColor":"wit"}},"lighting":{"mode":"spots","direction":"both","lampHeight":1950,"lampMin":500,"lampMax":3000,"minWallWidth":2000,"endOffset":300,"openingOffset":500,"indoor":true,"indoorMin":800,"indoorMax":1500,"indoorOffset":600},"elektra":{"enabled":true,"meterkast":{"w":600,"h":900,"d":250,"y":1000},"switchHeight":1500,"wcdHeight":400,"wcdPerUnit":2,"plateColor":"antraciet","slack":10,"reserve":300,"meterkastReserve":500,"apparaten":{"koken":true,"wasmachine":true,"droger":true,"warmtepomp":true,"oven":true,"vaatwasser":true},"place":{},"vloerWcd":[],"wcdExtra":{},"removed":[],"v":2}},"units":[{"id":"A","attach":null,"unit":{"layout":{"corners":"voor-achter"},"floor":{"floor":{"width":6700,"depth":4600,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"walls":{"voor":{"wall":{"width":6700,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"naturel","fels":null},"openings":[{"id":1,"type":"raam","w":800,"h":1500,"y":600,"anchor":"right","offset":970,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":2,"type":"raam","w":800,"h":1500,"y":600,"anchor":"right","offset":2715,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":3,"type":"raam","w":800,"h":1100,"y":1000,"anchor":"right","offset":4815,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"achter":{"wall":{"width":6700,"heightAll":3461,"heightLeft":3461,"heightRight":3461,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"naturel","fels":null},"openings":[]},"links":{"wall":{"width":4088,"heightAll":2839,"heightLeft":2839,"heightRight":3461,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"naturel","fels":null},"openings":[{"id":1,"type":"raam","w":600,"h":2100,"y":0,"anchor":"left","offset":770,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":2,"type":"deur","w":900,"h":2100,"y":0,"anchor":"left","offset":1370,"fill":{"type":"deur","rooster":false,"glas":false,"out":"zwart","in":"zwart","leaf":"rechts","hinge":"links","swing":"buiten","volglas":true}},{"id":3,"type":"deur","w":900,"h":2100,"y":0,"anchor":"left","offset":2270,"fill":{"type":"deur","rooster":false,"glas":false,"out":"zwart","in":"zwart","leaf":"links","hinge":"rechts","swing":"buiten","volglas":true}},{"id":4,"type":"raam","w":600,"h":2100,"y":0,"anchor":"left","offset":3170,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"rechts":{"wall":{"width":4088,"heightAll":2839,"heightLeft":3461,"heightRight":2839,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"naturel","fels":null},"openings":[]}}},"roof":{"followFloor":true,"offsetX":-1250,"offsetZ":0,"extend":{"f":0,"r":0,"b":0,"l":0},"fascia":true,"covering":"dakleer","deck":{"floor":{"width":9200,"depth":4600,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"unitWalls":{"voor":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"achter":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"links":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"rechts":{"heightAll":2800,"heightLeft":2800,"heightRight":2800}}},"lighting":{"walls":{}},"facade":{"walls":{}}},{"id":"B","attach":{"to":"A","side":"achter","align":"end","offset":0},"unit":{"layout":{"corners":"voor-achter"},"floor":{"floor":{"width":9200,"depth":4600,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"walls":{"voor":{"wall":{"width":9200,"heightAll":3461,"heightLeft":3461,"heightRight":3461,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"naturel","fels":null},"openings":[{"id":1,"type":"raam","w":1100,"h":1500,"y":600,"anchor":"right","offset":315,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"achter":{"wall":{"width":9200,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"naturel","fels":null},"openings":[{"id":1,"type":"raam","w":500,"h":700,"y":1400,"anchor":"left","offset":3525,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":2,"type":"voordeur","w":1100,"h":2100,"y":0,"anchor":"left","offset":5675,"fill":{"type":"deur","rooster":false,"glas":false,"out":"zwart","in":"zwart","hinge":"links","swing":"buiten"}},{"id":3,"type":"raam","w":500,"h":1500,"y":600,"anchor":"left","offset":8375,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"links":{"wall":{"width":4088,"heightAll":2839,"heightLeft":3461,"heightRight":2839,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"naturel","fels":null},"openings":[{"id":1,"type":"raam","w":800,"h":1500,"y":600,"anchor":"left","offset":200,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"rechts":{"wall":{"width":4088,"heightAll":2839,"heightLeft":2839,"heightRight":3461,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"naturel","fels":null},"openings":[{"id":1,"type":"raam","w":800,"h":1100,"y":1000,"anchor":"right","offset":210,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]}}},"roof":{"followFloor":true,"offsetX":0,"offsetZ":0,"extend":{"f":0,"r":0,"b":0,"l":0},"fascia":true,"covering":"dakleer","deck":{"floor":{"width":9200,"depth":4600,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"unitWalls":{"voor":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"achter":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"links":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"rechts":{"heightAll":2800,"heightLeft":2800,"heightRight":2800}}},"lighting":{"walls":{}},"facade":{"walls":{}}}],"interiorWalls":[{"id":1,"seam":{"a":"A","b":"B"},"wall":{"wall":{"width":6188,"heightAll":2800,"heightLeft":3461,"heightRight":3461,"studWidth":38,"studDepth":90,"plateThick":38,"plateDepth":90,"sheathingThick":10,"minSpacing":300,"maxSpacing":600,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"geen"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":3034,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"rechts","swing":"binnen"}}]}},{"id":2,"x1":750,"z1":4700,"x2":750,"z2":9010,"wall":{"wall":{"width":4310,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":70,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"binnen"}},{"id":2,"type":"deur","w":980,"h":2130,"y":0,"anchor":"left","offset":2780,"fill":{"type":"schuifdeur","rooster":false,"glas":false,"out":"wit","in":"wit","leaves":1,"mount":"opbouw","side":"buiten","dir":"links"}}]}},{"id":3,"x1":795,"z1":5740,"x2":3120,"z2":5740,"wall":{"wall":{"width":2325,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":665,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"binnen"}}]}},{"id":4,"x1":2575,"z1":5785,"x2":2575,"z2":6865,"wall":{"wall":{"width":1080,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":5,"x1":795,"z1":6910,"x2":3030,"z2":6910,"wall":{"wall":{"width":2235,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":6,"x1":3075,"z1":5785,"x2":3075,"z2":9010,"wall":{"wall":{"width":3225,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":7,"x1":4365,"z1":4700,"x2":4365,"z2":9010,"wall":{"wall":{"width":4310,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":70,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"buiten"}},{"id":2,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":3185,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"rechts","swing":"buiten"}}]}},{"id":8,"x1":4410,"z1":6915,"x2":6510,"z2":6915,"wall":{"wall":{"width":2100,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}}],"rooms":[{"id":1,"unitId":"A","x":2500,"z":2000,"type":"woonkeuken","name":"Woonkamer/keuken","floor":null,"paint":null,"fibo":null,"ceiling":null},{"id":2,"unitId":"B","x":1500,"z":2000,"type":"slaapkamer","name":"Slaapkamer","floor":null,"paint":null,"fibo":null,"ceiling":2800},{"id":3,"unitId":"B","x":4100,"z":1700,"type":"toilet","name":"","floor":"tegel_antraciet","paint":null,"fibo":{"decor":"wit_tegel","height":1200},"ceiling":2600},{"id":4,"unitId":"B","x":4000,"z":3300,"type":"badkamer","name":"","floor":"tegel_antraciet","paint":null,"fibo":{"decor":"wit_tegel","height":null},"ceiling":2600},{"id":5,"unitId":"B","x":6200,"z":2500,"type":"hal","name":"Entree","floor":null,"paint":null,"fibo":null,"ceiling":2800},{"id":6,"unitId":"B","x":7900,"z":1100,"type":"slaapkamer","name":"Logeerkamer","floor":null,"paint":null,"fibo":null,"ceiling":2800},{"id":7,"unitId":"B","x":7900,"z":3300,"type":"berging","name":"Berging","floor":null,"paint":null,"fibo":null,"ceiling":2800}],"fixtures":[{"id":1,"type":"keuken","unitId":"A","x":5120,"z":490,"anchor":"west","rot":0,"w":1580,"d":600,"h":910,"opts":{"front":"antraciet","spoel":true,"spoelbak":0.58,"kookplaat":false,"kookplaatPlek":0.2,"hogeKast":"geen","vaatwasser":true,"bovenkasten":false}},{"id":2,"type":"keuken","unitId":"A","x":6210,"z":2290,"anchor":"west","rot":90,"w":4200,"d":600,"h":910,"opts":{"front":"antraciet","spoel":false,"spoelbak":0.6,"kookplaat":true,"kookplaatPlek":0.43,"hogeKast":"rechts","vaatwasser":false,"bovenkasten":false}},{"id":3,"type":"eettafel","unitId":"A","x":3360,"z":1090,"anchor":"west","rot":0,"w":1000,"d":1000,"h":750,"opts":{"vorm":"ovaal","stoelen":4}},{"id":4,"type":"bank","unitId":"A","x":1520,"z":3770,"anchor":"west","rot":180,"w":2550,"d":900,"h":820,"opts":{"chaise":"rechts","chaiseLengte":1300}},{"id":5,"type":"salontafel","unitId":"A","x":1700,"z":2710,"anchor":"west","rot":0,"w":800,"d":800,"h":400,"opts":{"vorm":"rond"}},{"id":6,"type":"fauteuil","unitId":"A","x":980,"z":1460,"anchor":"west","rot":330,"w":850,"d":850,"h":800,"opts":{}},{"id":7,"type":"bed","unitId":"B","x":1240,"z":1920,"anchor":"west","rot":270,"w":1600,"d":2100,"h":520,"opts":{"nachtkastjes":false}},{"id":8,"type":"kledingkast","unitId":"B","x":1700,"z":4110,"anchor":"west","rot":180,"w":3000,"d":600,"h":2400,"opts":{"front":"wit"}},{"id":9,"type":"toilet","unitId":"B","x":3600,"z":1725,"anchor":"west","rot":270,"w":1000,"d":690,"h":1200,"opts":{}},{"id":10,"type":"wastafel","unitId":"B","x":3760,"z":2605,"anchor":"west","rot":0,"w":800,"d":460,"h":850,"opts":{"front":"eiken","spiegel":true}},{"id":11,"type":"toilet","unitId":"B","x":3760,"z":4100,"anchor":"west","rot":180,"w":1000,"d":690,"h":1200,"opts":{}},{"id":12,"type":"douche","unitId":"B","x":5090,"z":3665,"anchor":"west","rot":180,"w":900,"d":1500,"h":2100,"opts":{"glas":"rechts","glasLengte":700}},{"id":13,"type":"bed","unitId":"B","x":7960,"z":1830,"anchor":"west","rot":90,"w":900,"d":2100,"h":520,"opts":{"nachtkastjes":false}},{"id":14,"type":"wasmachine","unitId":"B","x":7280,"z":2660,"anchor":"west","rot":0,"w":600,"d":600,"h":850,"opts":{}},{"id":15,"type":"boiler","unitId":"B","x":8700,"z":2660,"anchor":"west","rot":0,"w":450,"d":450,"h":800,"opts":{"liter":80}}],"chimneys":[]} \ No newline at end of file diff --git a/demo/modellen/reest.json b/demo/modellen/reest.json new file mode 100644 index 0000000..e14ff81 --- /dev/null +++ b/demo/modellen/reest.json @@ -0,0 +1 @@ +{"type":"house-design","version":1,"exportedAt":"2026-10-14T20:05:43.368Z","model":"reest","configurator":"0.7.7","project":{"naam":"Reest","plaats":"","straat":"","nummer":"","tekenaar":"","opmerking":""},"settings":{"spacing":{"exterior":{"min":240,"max":480},"interior":{"min":300,"max":600}},"facade":{"type":"rabat","stone":"K490","rabatColor":"naturel","stucColor":"wit","felsColor":"antraciet"},"cladding":"verticaal","trasraam":{"enabled":false,"height":350,"finish":"cementplaat","capColor":"antraciet"},"interiorPaint":true,"interiorColor":"#f2efe9","ceilingColor":"#f4f3ef","sandwich":true,"groundLevel":0,"floorFinish":"visgraat_001","roof":{"overhang":300,"fasciaHeight":300,"spotMin":800,"spotMax":1200,"trimHeight":30,"fasciaColor":"wit","vent":true,"ventColor":"antraciet","house":{"enabled":false,"form":null,"cover":false},"posts":{"enabled":true,"size":200,"wood":"eiken","maxSpan":6000,"minExt":1000,"sideWall":true,"sideColor":"wit"}},"lighting":{"mode":"gevel","direction":"both","lampHeight":1950,"lampMin":500,"lampMax":3000,"minWallWidth":2000,"endOffset":300,"openingOffset":500,"indoor":true,"indoorMin":800,"indoorMax":1500,"indoorOffset":600},"elektra":{"enabled":true,"meterkast":{"w":600,"h":900,"d":250,"y":1000},"switchHeight":1500,"wcdHeight":400,"wcdPerUnit":2,"plateColor":"antraciet","slack":10,"reserve":300,"meterkastReserve":500,"apparaten":{"koken":true,"wasmachine":true,"droger":true,"warmtepomp":true,"oven":true,"vaatwasser":true},"place":{},"vloerWcd":[],"wcdExtra":{},"removed":[],"v":2}},"units":[{"id":"A","attach":null,"unit":{"layout":{"corners":"voor-achter"},"floor":{"floor":{"width":3890,"depth":3050,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"walls":{"voor":{"wall":{"width":3890,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"naturel","fels":null},"openings":[{"id":1,"type":"raam","w":500,"h":700,"y":1400,"anchor":"right","offset":475,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":2,"type":"raam","w":800,"h":1100,"y":1000,"anchor":"right","offset":2175,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"achter":{"wall":{"width":3890,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"naturel","fels":null},"openings":[{"id":1,"type":"voordeur","w":1100,"h":2100,"y":0,"anchor":"left","offset":230,"fill":{"type":"deur","rooster":false,"glas":true,"out":"zwart","in":"zwart","hinge":"links","swing":"buiten"}}]},"links":{"wall":{"width":2538,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"naturel","fels":null},"openings":[]},"rechts":{"wall":{"width":2538,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"naturel","fels":null},"openings":[]}}},"roof":{"followFloor":true,"offsetX":0,"offsetZ":0,"extend":{"f":0,"r":0,"b":0,"l":0},"fascia":false,"covering":"dakleer","deck":{"floor":{"width":3890,"depth":3050,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]}},"lighting":{"walls":{"voor":"geen","achter":"geen","links":"geen","rechts":"geen"}},"facade":{"walls":{}}},{"id":"B","attach":{"to":"A","side":"achter","align":"start","offset":1400},"unit":{"layout":{"corners":"voor-achter"},"floor":{"floor":{"width":11500,"depth":3400,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"walls":{"voor":{"wall":{"width":11500,"heightAll":3449,"heightLeft":3449,"heightRight":3449,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"naturel","fels":null},"openings":[{"id":1,"type":"raam","w":600,"h":2100,"y":0,"anchor":"right","offset":3390,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":2,"type":"deur","w":900,"h":2100,"y":0,"anchor":"right","offset":3990,"fill":{"type":"deur","rooster":false,"glas":false,"out":"zwart","in":"zwart","leaf":"rechts","hinge":"links","swing":"buiten","volglas":true}},{"id":3,"type":"deur","w":900,"h":2100,"y":0,"anchor":"right","offset":4890,"fill":{"type":"deur","rooster":false,"glas":false,"out":"zwart","in":"zwart","leaf":"links","hinge":"rechts","swing":"buiten","volglas":true}},{"id":4,"type":"raam","w":600,"h":2100,"y":0,"anchor":"right","offset":5790,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":5,"type":"raam","w":500,"h":1400,"y":700,"anchor":"right","offset":7525,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":6,"type":"raam","w":500,"h":1400,"y":700,"anchor":"right","offset":9975,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"achter":{"wall":{"width":11500,"heightAll":2820,"heightLeft":2820,"heightRight":2820,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"naturel","fels":null},"openings":[]},"links":{"wall":{"width":2888,"heightAll":2871,"heightLeft":3449,"heightRight":2871,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"naturel","fels":null},"openings":[{"id":1,"type":"raam","w":1800,"h":2600,"y":0,"anchor":"left","offset":1480,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"rechts":{"wall":{"width":2888,"heightAll":2871,"heightLeft":2871,"heightRight":3449,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"naturel","fels":null},"openings":[]}}},"roof":{"followFloor":true,"offsetX":-700,"offsetZ":0,"extend":{"f":0,"r":0,"b":0,"l":1400},"fascia":true,"covering":"fels_antraciet","deck":{"floor":{"width":12900,"depth":3400,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]}},"lighting":{"walls":{"voor":"geen","achter":"geen","links":"openingen","rechts":"geen"}},"facade":{"walls":{}}},{"id":"C","attach":{"to":"B","side":"achter","align":"start","offset":0},"unit":{"layout":{"corners":"voor-achter"},"floor":{"floor":{"width":11500,"depth":1400,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"walls":{"voor":{"wall":{"width":11500,"heightAll":2769,"heightLeft":2769,"heightRight":2769,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"naturel","fels":null},"openings":[]},"achter":{"wall":{"width":11500,"heightAll":2540,"heightLeft":2540,"heightRight":2540,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":null,"fels":"antraciet"},"openings":[{"id":1,"type":"raam","w":1500,"h":500,"y":1600,"anchor":"left","offset":2050,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":2,"type":"raam","w":1500,"h":500,"y":1600,"anchor":"left","offset":7440,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"links":{"wall":{"width":888,"heightAll":2591,"heightLeft":2769,"heightRight":2591,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"naturel","fels":null},"openings":[]},"rechts":{"wall":{"width":888,"heightAll":2591,"heightLeft":2591,"heightRight":2769,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"naturel","fels":null},"openings":[]}}},"roof":{"followFloor":true,"offsetX":-700,"offsetZ":0,"extend":{"f":0,"r":0,"b":0,"l":1400},"fascia":true,"covering":"fels_antraciet","deck":{"floor":{"width":12900,"depth":1400,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]}},"lighting":{"walls":{"voor":"geen","achter":"geen","links":"geen","rechts":"geen"}},"facade":{"walls":{"achter":{"type":"fels","fels":"antraciet"}}}}],"interiorWalls":[{"id":1,"seam":{"a":"A","b":"B"},"wall":{"wall":{"width":1978,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":90,"plateThick":38,"plateDepth":90,"sheathingThick":10,"minSpacing":300,"maxSpacing":600,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"geen"},"openings":[{"id":1,"type":"deur","w":1190,"h":2300,"y":0,"anchor":"left","offset":199,"fill":{"type":"geen","rooster":false,"glas":false,"out":"antraciet","in":"wit"}}]}},{"id":2,"x1":1335,"z1":190,"x2":1335,"z2":1690,"wall":{"wall":{"width":1500,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":3,"x1":190,"z1":1735,"x2":3700,"z2":1735,"wall":{"wall":{"width":3510,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":70,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"buiten"}},{"id":2,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":1260,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"buiten"}}]}},{"id":4,"x1":8715,"z1":3240,"x2":8715,"z2":7660,"wall":{"wall":{"width":4420,"heightAll":2500,"heightLeft":2500,"heightRight":2500,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":1890,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"rechts","swing":"buiten"}}]}},{"id":5,"x1":8760,"z1":6215,"x2":12710,"z2":6215,"wall":{"wall":{"width":3950,"heightAll":2500,"heightLeft":2500,"heightRight":2500,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":1815,"fill":{"type":"schuifdeur","rooster":false,"glas":false,"out":"wit","in":"wit","leaves":1,"mount":"opbouw","side":"binnen","dir":"links"}}]}}],"rooms":[{"id":1,"unitId":"A","x":700,"z":1000,"type":"toilet","name":"","floor":"tegel_antraciet","paint":null,"fibo":{"decor":"wit_tegel","height":1200},"ceiling":2600},{"id":2,"unitId":"A","x":2500,"z":1000,"type":"berging","name":"Berging","floor":null,"paint":null,"fibo":null,"ceiling":null},{"id":3,"unitId":"A","x":1800,"z":2400,"type":"hal","name":"Entree","floor":null,"paint":null,"fibo":null,"ceiling":null},{"id":4,"unitId":"B","x":3000,"z":1500,"type":"woonkeuken","name":"Woonkamer/keuken","floor":null,"paint":null,"fibo":null,"ceiling":null},{"id":5,"unitId":"B","x":9000,"z":1500,"type":"slaapkamer","name":"Slaapkamer","floor":null,"paint":null,"fibo":null,"ceiling":2500},{"id":6,"unitId":"C","x":9500,"z":600,"type":"badkamer","name":"","floor":"tegel_antraciet","paint":null,"fibo":{"decor":"wit_tegel","height":null},"ceiling":2500}],"fixtures":[{"id":1,"type":"toilet","unitId":"A","x":735,"z":640,"anchor":"west","rot":0,"w":1100,"d":690,"h":1200,"opts":{}},{"id":2,"type":"boiler","unitId":"A","x":3400,"z":1430,"anchor":"west","rot":90,"w":450,"d":450,"h":800,"opts":{"liter":80}},{"id":3,"type":"keuken","unitId":"C","x":1990,"z":910,"anchor":"west","rot":180,"w":3600,"d":600,"h":910,"opts":{"front":"antraciet","spoel":true,"spoelbak":0.29,"kookplaat":true,"kookplaatPlek":0.71,"hogeKast":"geen","vaatwasser":true,"bovenkasten":false}},{"id":4,"type":"eettafel","unitId":"B","x":1090,"z":2110,"anchor":"west","rot":0,"w":1000,"d":1000,"h":750,"opts":{"vorm":"rond","stoelen":4}},{"id":5,"type":"bank","unitId":"B","x":3800,"z":1310,"anchor":"west","rot":270,"w":1600,"d":800,"h":820,"opts":{"chaise":"geen","chaiseLengte":1700}},{"id":6,"type":"salontafel","unitId":"B","x":5340,"z":1150,"anchor":"west","rot":0,"w":800,"d":800,"h":400,"opts":{"vorm":"rond"}},{"id":7,"type":"fauteuil","unitId":"B","x":5540,"z":2550,"anchor":"west","rot":90,"w":850,"d":850,"h":800,"opts":{}},{"id":8,"type":"bed","unitId":"B","x":8990,"z":1270,"anchor":"west","rot":0,"w":1600,"d":2100,"h":520,"opts":{"nachtkastjes":false}},{"id":9,"type":"kledingkast","unitId":"B","x":480,"z":1655,"anchor":"oost","rot":90,"w":2900,"d":600,"h":2400,"opts":{"front":"wit"}},{"id":10,"type":"toilet","unitId":"C","x":8020,"z":850,"anchor":"west","rot":180,"w":1000,"d":690,"h":1200,"opts":{}},{"id":11,"type":"wastafel","unitId":"C","x":9490,"z":970,"anchor":"west","rot":180,"w":800,"d":460,"h":850,"opts":{"front":"eiken","spiegel":true}},{"id":12,"type":"douche","unitId":"C","x":10860,"z":510,"anchor":"west","rot":180,"w":900,"d":1400,"h":2100,"opts":{"glas":"rechts","glasLengte":800}}],"chimneys":[]} \ No newline at end of file diff --git a/demo/modellen/rijn.json b/demo/modellen/rijn.json new file mode 100644 index 0000000..352de68 --- /dev/null +++ b/demo/modellen/rijn.json @@ -0,0 +1 @@ +{"type":"house-design","version":1,"exportedAt":"2026-10-14T20:05:43.341Z","model":"rijn","configurator":"0.7.7","project":{"naam":"Rijn","plaats":"","straat":"","nummer":"","tekenaar":"","opmerking":""},"settings":{"spacing":{"exterior":{"min":240,"max":480},"interior":{"min":300,"max":600}},"facade":{"type":"steen","stone":"R752","rabatColor":"naturel","stucColor":"wit","felsColor":"antraciet"},"cladding":"horizontaal","trasraam":{"enabled":true,"height":500,"finish":"R669","capColor":"antraciet"},"interiorPaint":true,"interiorColor":"#f2efe9","ceilingColor":"#f4f3ef","sandwich":true,"groundLevel":0,"floorFinish":"visgraat_001","roof":{"overhang":300,"fasciaHeight":350,"spotMin":800,"spotMax":1200,"trimHeight":30,"fasciaColor":"wit","vent":true,"ventColor":"antraciet","house":{"enabled":false,"form":null,"cover":false},"posts":{"enabled":true,"size":200,"wood":"eiken","maxSpan":3600,"minExt":1000,"sideWall":false,"sideColor":"wit"}},"lighting":{"mode":"spots","direction":"both","lampHeight":1950,"lampMin":500,"lampMax":3000,"minWallWidth":2000,"endOffset":300,"openingOffset":500,"indoor":true,"indoorMin":800,"indoorMax":1500,"indoorOffset":600},"elektra":{"enabled":true,"meterkast":{"w":600,"h":900,"d":250,"y":1000},"switchHeight":1500,"wcdHeight":400,"wcdPerUnit":2,"plateColor":"antraciet","slack":10,"reserve":300,"meterkastReserve":500,"apparaten":{"koken":true,"wasmachine":true,"droger":true,"warmtepomp":true,"oven":true,"vaatwasser":true},"place":{},"vloerWcd":[],"wcdExtra":{},"removed":[],"v":2}},"units":[{"id":"A","attach":null,"unit":{"layout":{"corners":"voor-achter"},"floor":{"floor":{"width":13000,"depth":3270,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"walls":{"voor":{"wall":{"width":13000,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"horizontaal","stone":"R752","stuc":null,"trasraam":{"bottom":0,"top":500,"finish":"R669","capColor":"antraciet"},"rabatColor":null,"fels":null},"openings":[{"id":1,"type":"raam","w":500,"h":1400,"y":700,"anchor":"right","offset":1045,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":2,"type":"raam","w":500,"h":1400,"y":700,"anchor":"right","offset":3630,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":3,"type":"raam","w":1200,"h":500,"y":1600,"anchor":"right","offset":4760,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":4,"type":"raam","w":1400,"h":1400,"y":700,"anchor":"right","offset":7560,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"achter":{"wall":{"width":13000,"heightAll":3445,"heightLeft":3445,"heightRight":3445,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"horizontaal","stone":"R752","stuc":null,"trasraam":{"bottom":0,"top":500,"finish":"R669","capColor":"antraciet"},"rabatColor":null,"fels":null},"openings":[]},"links":{"wall":{"width":2758,"heightAll":2855,"heightLeft":2855,"heightRight":3445,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"horizontaal","stone":"R752","stuc":null,"trasraam":{"bottom":0,"top":500,"finish":"R669","capColor":"antraciet"},"rabatColor":null,"fels":null},"openings":[]},"rechts":{"wall":{"width":2758,"heightAll":2855,"heightLeft":3445,"heightRight":2855,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"horizontaal","stone":"R752","stuc":null,"trasraam":{"bottom":0,"top":500,"finish":"R669","capColor":"antraciet"},"rabatColor":null,"fels":null},"openings":[{"id":1,"type":"raam","w":1800,"h":2100,"y":0,"anchor":"right","offset":529,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]}}},"roof":{"followFloor":true,"offsetX":0,"offsetZ":0,"extend":{"f":0,"r":0,"b":0,"l":0},"fascia":true,"covering":"dakleer","deck":{"floor":{"width":13000,"depth":3270,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]}},"lighting":{"walls":{}},"facade":{"walls":{}}},{"id":"B","attach":{"to":"A","side":"achter","align":"start","offset":0},"unit":{"layout":{"corners":"voor-achter"},"floor":{"floor":{"width":13000,"depth":3270,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"walls":{"voor":{"wall":{"width":13000,"heightAll":3445,"heightLeft":3445,"heightRight":3445,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"horizontaal","stone":"R752","stuc":null,"trasraam":{"bottom":0,"top":500,"finish":"R669","capColor":"antraciet"},"rabatColor":null,"fels":null},"openings":[]},"achter":{"wall":{"width":13000,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"horizontaal","stone":"R752","stuc":null,"trasraam":{"bottom":0,"top":500,"finish":"R669","capColor":"antraciet"},"rabatColor":null,"fels":null},"openings":[{"id":1,"type":"raam","w":1100,"h":2100,"y":0,"anchor":"left","offset":4680,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":2,"type":"raam","w":1800,"h":2100,"y":0,"anchor":"left","offset":10490,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"links":{"wall":{"width":2758,"heightAll":2855,"heightLeft":3445,"heightRight":2855,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"horizontaal","stone":"R752","stuc":null,"trasraam":{"bottom":0,"top":500,"finish":"R669","capColor":"antraciet"},"rabatColor":null,"fels":null},"openings":[{"id":1,"type":"raam","w":1400,"h":1100,"y":1000,"anchor":"left","offset":465,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"rechts":{"wall":{"width":2758,"heightAll":2855,"heightLeft":2855,"heightRight":3445,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"horizontaal","stone":"R752","stuc":null,"trasraam":{"bottom":0,"top":500,"finish":"R669","capColor":"antraciet"},"rabatColor":null,"fels":null},"openings":[{"id":1,"type":"deur","w":900,"h":2100,"y":0,"anchor":"right","offset":730,"fill":{"type":"deur","rooster":false,"glas":false,"out":"zwart","in":"zwart","leaf":"rechts","hinge":"rechts","swing":"buiten","volglas":true}},{"id":2,"type":"deur","w":900,"h":2100,"y":0,"anchor":"right","offset":1630,"fill":{"type":"deur","rooster":false,"glas":false,"out":"zwart","in":"zwart","leaf":"links","hinge":"links","swing":"buiten","volglas":true}}]}}},"roof":{"followFloor":true,"offsetX":0,"offsetZ":0,"extend":{"f":0,"r":0,"b":0,"l":0},"fascia":true,"covering":"dakleer","deck":{"floor":{"width":13000,"depth":3270,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]}},"lighting":{"walls":{}},"facade":{"walls":{}}},{"id":"C","attach":{"to":"B","side":"achter","align":"start","offset":0},"unit":{"layout":{"corners":"voor-achter"},"floor":{"floor":{"width":4200,"depth":3460,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"walls":{"voor":{"wall":{"width":4200,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"horizontaal","stone":"R752","stuc":null,"trasraam":{"bottom":0,"top":500,"finish":"R669","capColor":"antraciet"},"rabatColor":null,"fels":null},"openings":[]},"achter":{"wall":{"width":4200,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"horizontaal","stone":"R752","stuc":null,"trasraam":{"bottom":0,"top":500,"finish":"R669","capColor":"antraciet"},"rabatColor":null,"fels":null},"openings":[{"id":1,"type":"raam","w":1400,"h":1100,"y":1000,"anchor":"left","offset":1505,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"links":{"wall":{"width":2948,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"horizontaal","stone":"R752","stuc":null,"trasraam":{"bottom":0,"top":500,"finish":"R669","capColor":"antraciet"},"rabatColor":null,"fels":null},"openings":[{"id":1,"type":"raam","w":500,"h":700,"y":1400,"anchor":"left","offset":365,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"rechts":{"wall":{"width":2948,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"horizontaal","stone":"R752","stuc":null,"trasraam":{"bottom":0,"top":500,"finish":"R669","capColor":"antraciet"},"rabatColor":null,"fels":null},"openings":[{"id":1,"type":"voordeur","w":1100,"h":2100,"y":0,"anchor":"right","offset":70,"fill":{"type":"deur","rooster":false,"glas":true,"out":"zwart","in":"zwart","hinge":"links","swing":"buiten"}}]}}},"roof":{"followFloor":true,"offsetX":0,"offsetZ":0,"extend":{"f":0,"r":0,"b":0,"l":0},"fascia":true,"covering":"dakleer","deck":{"floor":{"width":4200,"depth":3460,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]}},"lighting":{"walls":{}},"facade":{"walls":{}}}],"interiorWalls":[{"id":1,"x1":190,"z1":3266,"x2":6513,"z2":3266,"wall":{"wall":{"width":6323,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":140,"plateThick":38,"plateDepth":140,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":2800,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"buiten"}}]}},{"id":2,"x1":4380,"z1":190,"x2":4380,"z2":3186,"wall":{"wall":{"width":2996,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":980,"h":2130,"y":0,"anchor":"left","offset":1136,"fill":{"type":"schuifdeur","rooster":false,"glas":false,"out":"wit","in":"wit","leaves":1,"mount":"opbouw","side":"buiten","dir":"links"}}]}},{"id":3,"x1":6466,"z1":190,"x2":6466,"z2":3186,"wall":{"wall":{"width":2996,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":4,"x1":5800,"z1":1135,"x2":6421,"z2":1135,"wall":{"wall":{"width":621,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":5,"x1":2762,"z1":3346,"x2":2762,"z2":6440,"wall":{"wall":{"width":3094,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":104,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"buiten"}}]}},{"id":6,"x1":1880,"z1":6640,"x2":1880,"z2":7655,"wall":{"wall":{"width":1015,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":40,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"binnen"}}]}},{"id":7,"x1":190,"z1":7700,"x2":3107,"z2":7700,"wall":{"wall":{"width":2917,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":1820,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"buiten"}}]}},{"id":8,"x1":3062,"z1":7745,"x2":3062,"z2":8335,"wall":{"wall":{"width":590,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":9,"x1":3107,"z1":8290,"x2":4010,"z2":8290,"wall":{"wall":{"width":903,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":10,"seam":{"a":"B","b":"C"},"wall":{"wall":{"width":3688,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":90,"plateThick":38,"plateDepth":90,"sheathingThick":10,"minSpacing":300,"maxSpacing":600,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"geen"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":2659,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"buiten"}}]}}],"rooms":[{"id":1,"unitId":"A","x":9500,"z":1500,"type":"woonkeuken","name":"Woonkamer/keuken","floor":null,"paint":null,"fibo":null,"ceiling":null},{"id":2,"unitId":"A","x":2600,"z":1700,"type":"slaapkamer","name":"Slaapkamer","floor":null,"paint":null,"fibo":null,"ceiling":2800},{"id":3,"unitId":"A","x":5400,"z":2000,"type":"badkamer","name":"","floor":"tegel_antraciet","paint":null,"fibo":{"decor":"wit_tegel","height":null},"ceiling":2800},{"id":4,"unitId":"B","x":1400,"z":1500,"type":"berging","name":"Berging","floor":null,"paint":null,"fibo":null,"ceiling":2800},{"id":5,"unitId":"C","x":1000,"z":500,"type":"toilet","name":"","floor":"tegel_antraciet","paint":null,"fibo":{"decor":"wit_tegel","height":1200},"ceiling":2600},{"id":6,"unitId":"C","x":2400,"z":500,"type":"hal","name":"Entree","floor":null,"paint":null,"fibo":null,"ceiling":null},{"id":7,"unitId":"C","x":1500,"z":2300,"type":"slaapkamer","name":"Logeerkamer","floor":null,"paint":null,"fibo":null,"ceiling":null}],"fixtures":[{"id":1,"type":"kledingkast","unitId":"A","x":493,"z":1688,"anchor":"west","rot":270,"w":2990,"d":600,"h":2400,"opts":{"front":"wit"}},{"id":2,"type":"bed","unitId":"A","x":2615,"z":1240,"anchor":"west","rot":0,"w":1600,"d":2100,"h":520,"opts":{"nachtkastjes":false}},{"id":3,"type":"douche","unitId":"A","x":5725,"z":640,"anchor":"west","rot":0,"w":1400,"d":900,"h":2100,"opts":{"glas":"links","glasLengte":900}},{"id":4,"type":"wastafel","unitId":"A","x":6191,"z":2000,"anchor":"west","rot":90,"w":800,"d":460,"h":850,"opts":{"front":"eiken","spiegel":true}},{"id":5,"type":"toilet","unitId":"A","x":4998,"z":2680,"anchor":"west","rot":180,"w":600,"d":690,"h":1200,"opts":{}},{"id":6,"type":"bank","unitId":"A","x":11195,"z":800,"anchor":"west","rot":0,"w":3000,"d":1000,"h":820,"opts":{"chaise":"rechts","chaiseLengte":2100}},{"id":7,"type":"fauteuil","unitId":"A","x":8880,"z":1720,"anchor":"west","rot":315,"w":850,"d":850,"h":800,"opts":{}},{"id":8,"type":"salontafel","unitId":"A","x":10410,"z":2280,"anchor":"west","rot":0,"w":800,"d":800,"h":400,"opts":{"vorm":"rond"}},{"id":9,"type":"keuken","unitId":"B","x":4945,"z":1080,"anchor":"west","rot":90,"w":2000,"d":600,"h":910,"opts":{"front":"antraciet","spoel":true,"spoelbak":0.15,"kookplaat":true,"kookplaatPlek":0.85,"hogeKast":"geen","vaatwasser":true,"bovenkasten":false}},{"id":10,"type":"kookeiland","unitId":"B","x":5545,"z":1080,"anchor":"west","rot":270,"w":2000,"d":600,"h":910,"opts":{"front":"antraciet","kookplaat":false}},{"id":11,"type":"keuken","unitId":"B","x":3110,"z":1810,"anchor":"west","rot":270,"w":1200,"d":600,"h":910,"opts":{"front":"antraciet","spoel":false,"spoelbak":0.6,"kookplaat":false,"kookplaatPlek":0.2,"hogeKast":"rechts","vaatwasser":false,"bovenkasten":false}},{"id":12,"type":"eettafel","unitId":"B","x":10010,"z":1720,"anchor":"west","rot":0,"w":2000,"d":1100,"h":750,"opts":{"vorm":"ovaal","stoelen":6}},{"id":13,"type":"wasmachine","unitId":"B","x":620,"z":2870,"anchor":"west","rot":180,"w":600,"d":600,"h":850,"opts":{}},{"id":14,"type":"wasmachine","unitId":"B","x":1450,"z":2870,"anchor":"west","rot":180,"w":600,"d":600,"h":850,"opts":{}},{"id":15,"type":"boiler","unitId":"B","x":2350,"z":2945,"anchor":"west","rot":180,"w":450,"d":450,"h":800,"opts":{"liter":80}},{"id":16,"type":"toilet","unitId":"C","x":535,"z":610,"anchor":"west","rot":270,"w":600,"d":690,"h":1200,"opts":{}},{"id":17,"type":"bed","unitId":"C","x":650,"z":2205,"anchor":"west","rot":0,"w":900,"d":2000,"h":520,"opts":{"nachtkastjes":false}},{"id":18,"type":"kledingkast","unitId":"C","x":3703,"z":2532,"anchor":"west","rot":90,"w":1450,"d":600,"h":2400,"opts":{"front":"wit"}}],"chimneys":[{"id":1,"unitId":"B","side":"rechts","u":3270,"w":800,"out":180,"above":600,"inside":{"type":"wand","stone":"R777","stuc":"wit"},"outside":{"type":"steen","stone":"R777","stuc":"wit"}}]} \ No newline at end of file diff --git a/demo/modellen/vecht.json b/demo/modellen/vecht.json new file mode 100644 index 0000000..3edad2e --- /dev/null +++ b/demo/modellen/vecht.json @@ -0,0 +1 @@ +{"type":"house-design","version":1,"exportedAt":"2026-10-14T20:05:43.407Z","model":"vecht","configurator":"0.7.7","project":{"naam":"Vecht","plaats":"","straat":"","nummer":"","tekenaar":"","opmerking":""},"settings":{"spacing":{"exterior":{"min":240,"max":480},"interior":{"min":300,"max":600}},"facade":{"type":"rabat","stone":"K490","rabatColor":"zwart","stucColor":"wit","felsColor":"antraciet"},"cladding":"verticaal","trasraam":{"enabled":false,"height":350,"finish":"cementplaat","capColor":"antraciet"},"interiorPaint":true,"interiorColor":"#f2efe9","ceilingColor":"#f4f3ef","sandwich":true,"groundLevel":0,"floorFinish":"visgraat_001","roof":{"overhang":300,"fasciaHeight":300,"spotMin":800,"spotMax":1200,"trimHeight":30,"fasciaColor":"zwart","vent":true,"ventColor":"antraciet","house":{"enabled":false,"form":null,"cover":false},"posts":{"enabled":true,"size":150,"wood":"eiken","maxSpan":4500,"minExt":1000,"sideWall":false,"sideColor":"wit"}},"lighting":{"mode":"spots","direction":"both","lampHeight":1950,"lampMin":500,"lampMax":3000,"minWallWidth":2000,"endOffset":300,"openingOffset":500,"indoor":true,"indoorMin":800,"indoorMax":1500,"indoorOffset":600},"elektra":{"enabled":true,"meterkast":{"w":600,"h":900,"d":250,"y":1000},"switchHeight":1500,"wcdHeight":400,"wcdPerUnit":2,"plateColor":"antraciet","slack":10,"reserve":300,"meterkastReserve":500,"apparaten":{"koken":true,"wasmachine":true,"droger":true,"warmtepomp":true,"oven":true,"vaatwasser":true},"place":{},"vloerWcd":[],"wcdExtra":{},"removed":[],"v":2}},"units":[{"id":"A","attach":null,"unit":{"layout":{"corners":"voor-achter"},"floor":{"floor":{"width":4600,"depth":9500,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"walls":{"voor":{"wall":{"width":4600,"heightAll":2800,"heightLeft":2800,"heightRight":3600,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"zwart","fels":null},"openings":[{"id":1,"type":"raam","w":1600,"h":1500,"y":600,"anchor":"right","offset":2600,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"achter":{"wall":{"width":4600,"heightAll":2800,"heightLeft":3600,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"zwart","fels":null},"openings":[{"id":1,"type":"raam","w":1200,"h":1400,"y":700,"anchor":"left","offset":900,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"links":{"wall":{"width":8988,"heightAll":3555,"heightLeft":3555,"heightRight":3555,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"zwart","fels":null},"openings":[{"id":1,"type":"raam","w":1600,"h":500,"y":1700,"anchor":"left","offset":600,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":2,"type":"raam","w":800,"h":600,"y":1500,"anchor":"left","offset":4644,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":3,"type":"raam","w":800,"h":1400,"y":700,"anchor":"left","offset":7344,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"rechts":{"wall":{"width":8988,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"verticaal","stone":null,"stuc":"wit","trasraam":null,"rabatColor":null,"fels":null},"openings":[{"id":1,"type":"raam","w":1400,"h":2100,"y":300,"anchor":"right","offset":944,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":2,"type":"raam","w":1500,"h":2400,"y":0,"anchor":"right","offset":3994,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]}}},"roof":{"followFloor":true,"offsetX":0,"offsetZ":0,"extend":{"f":0,"r":0,"b":0,"l":0},"fascia":true,"covering":"fels_antraciet","deck":{"floor":{"width":4600,"depth":9500,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]}},"lighting":{"walls":{}},"facade":{"walls":{"rechts":{"type":"stuc","stuc":"wit"}}}},{"id":"B","attach":{"to":"A","side":"rechts","align":"end","offset":0},"unit":{"layout":{"corners":"voor-achter"},"floor":{"floor":{"width":4000,"depth":3600,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"walls":{"voor":{"wall":{"width":4000,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"verticaal","stone":null,"stuc":"wit","trasraam":null,"rabatColor":null,"fels":null},"openings":[{"id":1,"type":"raam","w":2000,"h":2400,"y":0,"anchor":"right","offset":400,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"achter":{"wall":{"width":4000,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"zwart","fels":null},"openings":[{"id":1,"type":"voordeur","w":1100,"h":2100,"y":0,"anchor":"left","offset":800,"fill":{"type":"deur","rooster":false,"glas":true,"out":"zwart","in":"zwart","hinge":"links","swing":"buiten"}},{"id":2,"type":"raam","w":400,"h":2100,"y":0,"anchor":"left","offset":2000,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":3,"type":"raam","w":500,"h":700,"y":1400,"anchor":"left","offset":3100,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"links":{"wall":{"width":3088,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"zwart","fels":null},"openings":[]},"rechts":{"wall":{"width":3088,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"zwart","fels":null},"openings":[]}}},"roof":{"followFloor":true,"offsetX":0,"offsetZ":-1200,"extend":{"f":2400,"r":0,"b":0,"l":0},"fascia":true,"covering":"dakleer","deck":{"floor":{"width":4000,"depth":6000,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]}},"lighting":{"walls":{}},"facade":{"walls":{"voor":{"type":"stuc","stuc":"wit"}}}},{"id":"C","attach":{"to":"B","side":"rechts","align":"end","offset":0},"unit":{"layout":{"corners":"voor-achter"},"floor":{"floor":{"width":4400,"depth":7600,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"walls":{"voor":{"wall":{"width":4400,"heightAll":2800,"heightLeft":3600,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"zwart","fels":null},"openings":[{"id":1,"type":"raam","w":2400,"h":2100,"y":300,"anchor":"right","offset":1000,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"achter":{"wall":{"width":4400,"heightAll":2800,"heightLeft":2800,"heightRight":3600,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"zwart","fels":null},"openings":[{"id":1,"type":"raam","w":1400,"h":1000,"y":1100,"anchor":"left","offset":1400,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"links":{"wall":{"width":7088,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"cementplaat","cladding":"verticaal","stone":null,"stuc":"wit","trasraam":null,"rabatColor":null,"fels":null},"openings":[{"id":1,"type":"deur","w":900,"h":2100,"y":0,"anchor":"left","offset":1744,"fill":{"type":"deur","rooster":false,"glas":false,"out":"zwart","in":"zwart","leaf":"rechts","hinge":"links","swing":"buiten","volglas":true}},{"id":2,"type":"deur","w":900,"h":2100,"y":0,"anchor":"left","offset":2644,"fill":{"type":"deur","rooster":false,"glas":false,"out":"zwart","in":"zwart","leaf":"links","hinge":"rechts","swing":"buiten","volglas":true}}]},"rechts":{"wall":{"width":7088,"heightAll":3553,"heightLeft":3553,"heightRight":3553,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"zwart","fels":null},"openings":[{"id":1,"type":"raam","w":2000,"h":900,"y":1900,"anchor":"right","offset":500,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":2,"type":"raam","w":1000,"h":2100,"y":300,"anchor":"right","offset":2844,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":3,"type":"raam","w":1200,"h":1100,"y":1000,"anchor":"right","offset":4744,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]}}},"roof":{"followFloor":true,"offsetX":0,"offsetZ":0,"extend":{"f":0,"r":0,"b":0,"l":0},"fascia":true,"covering":"fels_antraciet","deck":{"floor":{"width":4400,"depth":7600,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]}},"lighting":{"walls":{}},"facade":{"walls":{"links":{"type":"stuc","stuc":"wit"}}}}],"interiorWalls":[{"id":1,"x1":256,"z1":3902,"x2":4344,"z2":3902,"wall":{"wall":{"width":4088,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":1944,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"rechts","swing":"buiten"}},{"id":2,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":3124,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"rechts","swing":"binnen"}}]}},{"id":2,"x1":3300,"z1":3948,"x2":3300,"z2":9244,"wall":{"wall":{"width":5296,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":2452,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"buiten"}}]}},{"id":3,"x1":256,"z1":6293,"x2":3254,"z2":6293,"wall":{"wall":{"width":2998,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":1944,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"rechts","swing":"binnen"}}]}},{"id":4,"x1":3346,"z1":7405,"x2":4500,"z2":7405,"wall":{"wall":{"width":1154,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":800,"h":2115,"y":0,"anchor":"middle","offset":0,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"buiten"}}]}},{"id":5,"x1":7260,"z1":7555,"x2":8500,"z2":7555,"wall":{"wall":{"width":1240,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":6,"x1":7305,"z1":7601,"x2":7305,"z2":9244,"wall":{"wall":{"width":1643,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":850,"h":2115,"y":0,"anchor":"left","offset":100,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"buiten"}}]}},{"id":7,"seam":{"a":"A","b":"B"},"wall":{"wall":{"width":3088,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":90,"plateThick":38,"plateDepth":90,"sheathingThick":10,"minSpacing":300,"maxSpacing":600,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"geen"},"openings":[{"id":1,"type":"deur","w":1000,"h":2300,"y":0,"anchor":"left","offset":1994,"fill":{"type":"geen","rooster":false,"glas":false,"out":"antraciet","in":"wit"}}]}},{"id":8,"seam":{"a":"B","b":"C"},"wall":{"wall":{"width":3088,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":90,"plateThick":38,"plateDepth":90,"sheathingThick":10,"minSpacing":300,"maxSpacing":600,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"geen"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":2014,"fill":{"type":"deur","rooster":false,"glas":true,"out":"wit","in":"wit","hinge":"rechts","swing":"binnen"}}]}}],"rooms":[{"id":1,"unitId":"A","x":2000,"z":1500,"type":"slaapkamer","name":"Slaapkamer","floor":null,"paint":null,"fibo":null,"ceiling":2800},{"id":2,"unitId":"A","x":2300,"z":5100,"type":"badkamer","name":"","floor":"tegel_antraciet","paint":null,"fibo":{"decor":"wit_tegel","height":null},"ceiling":2600},{"id":3,"unitId":"A","x":1500,"z":8800,"type":"slaapkamer","name":"Slaapkamer 2","floor":null,"paint":null,"fibo":null,"ceiling":2800},{"id":4,"unitId":"A","x":3900,"z":5000,"type":"hal","name":"Gang","floor":null,"paint":null,"fibo":null,"ceiling":2800},{"id":5,"unitId":"A","x":3900,"z":8000,"type":"wasruimte","name":"Techniek/was","floor":"tegel_grijs","paint":null,"fibo":null,"ceiling":2800},{"id":6,"unitId":"B","x":1200,"z":1500,"type":"hal","name":"Entree","floor":null,"paint":null,"fibo":null,"ceiling":null},{"id":7,"unitId":"B","x":3325,"z":2400,"type":"toilet","name":"","floor":"tegel_antraciet","paint":null,"fibo":{"decor":"wit_tegel","height":1200},"ceiling":2600},{"id":8,"unitId":"C","x":2000,"z":3500,"type":"woonkeuken","name":"Woonkamer/keuken","floor":null,"paint":null,"fibo":null,"ceiling":null}],"fixtures":[{"id":1,"type":"bed","unitId":"A","x":1306,"z":1656,"anchor":"west","rot":270,"w":1600,"d":2100,"h":520,"opts":{"nachtkastjes":false}},{"id":2,"type":"kledingkast","unitId":"A","x":1206,"z":3556,"anchor":"west","rot":180,"w":1800,"d":600,"h":2400,"opts":{"front":"wit"}},{"id":3,"type":"douche","unitId":"A","x":856,"z":4398,"anchor":"west","rot":0,"w":1200,"d":900,"h":2100,"opts":{"glas":"rechts","glasLengte":900}},{"id":4,"type":"toilet","unitId":"A","x":601,"z":5250,"anchor":"west","rot":270,"w":600,"d":690,"h":1200,"opts":{}},{"id":5,"type":"wastafel","unitId":"A","x":900,"z":6017,"anchor":"west","rot":180,"w":1000,"d":460,"h":850,"opts":{"front":"eiken","spiegel":true}},{"id":6,"type":"bed","unitId":"A","x":1300,"z":7339,"anchor":"west","rot":0,"w":1400,"d":2000,"h":520,"opts":{"nachtkastjes":false}},{"id":7,"type":"kledingkast","unitId":"A","x":2954,"z":8300,"anchor":"west","rot":90,"w":1800,"d":600,"h":2400,"opts":{"front":"wit"}},{"id":8,"type":"wasmachine","unitId":"A","x":3700,"z":8944,"anchor":"west","rot":180,"w":600,"d":600,"h":850,"opts":{}},{"id":9,"type":"boiler","unitId":"A","x":4200,"z":9019,"anchor":"west","rot":180,"w":450,"d":450,"h":800,"opts":{"liter":80}},{"id":10,"type":"toilet","unitId":"B","x":3325,"z":2999,"anchor":"west","rot":180,"w":600,"d":690,"h":1200,"opts":{}},{"id":11,"type":"fontein","unitId":"B","x":3325,"z":1826,"anchor":"west","rot":0,"w":400,"d":250,"h":850,"opts":{}},{"id":12,"type":"keuken","unitId":"C","x":2344,"z":7044,"anchor":"west","rot":180,"w":3600,"d":600,"h":910,"opts":{"front":"antraciet","spoel":true,"spoelbak":0.57,"kookplaat":false,"kookplaatPlek":0.2,"hogeKast":"links","vaatwasser":true,"bovenkasten":false}},{"id":13,"type":"kookeiland","unitId":"C","x":2200,"z":5194,"anchor":"west","rot":0,"w":2400,"d":900,"h":910,"opts":{"front":"antraciet","kookplaat":true}},{"id":14,"type":"eettafel","unitId":"C","x":1900,"z":3400,"anchor":"west","rot":0,"w":1800,"d":1000,"h":750,"opts":{"vorm":"ovaal","stoelen":6}},{"id":15,"type":"bank","unitId":"C","x":3694,"z":1756,"anchor":"west","rot":90,"w":2400,"d":900,"h":820,"opts":{"chaise":"geen","chaiseLengte":1700}},{"id":16,"type":"salontafel","unitId":"C","x":2600,"z":1600,"anchor":"west","rot":0,"w":800,"d":800,"h":400,"opts":{"vorm":"rond"}},{"id":17,"type":"fauteuil","unitId":"C","x":1700,"z":950,"anchor":"west","rot":45,"w":850,"d":850,"h":800,"opts":{}},{"id":18,"type":"tvmeubel","unitId":"C","x":456,"z":1200,"anchor":"west","rot":270,"w":1400,"d":400,"h":450,"opts":{"tv":true}}],"chimneys":[]} \ No newline at end of file diff --git a/demo/modellen/waal.json b/demo/modellen/waal.json new file mode 100644 index 0000000..47e78b5 --- /dev/null +++ b/demo/modellen/waal.json @@ -0,0 +1 @@ +{"type":"house-design","version":1,"exportedAt":"2026-10-14T20:05:43.251Z","model":"waal","configurator":"0.7.7","project":{"naam":"Waal","plaats":"","straat":"","nummer":"","tekenaar":"","opmerking":""},"settings":{"spacing":{"exterior":{"min":240,"max":480},"interior":{"min":300,"max":600}},"facade":{"type":"rabat","stone":"K490","rabatColor":"grijs","stucColor":"wit","felsColor":"antraciet"},"cladding":"verticaal","trasraam":{"enabled":true,"height":600,"finish":"R510","capColor":"antraciet"},"interiorPaint":true,"interiorColor":"#f2efe9","ceilingColor":"#f4f3ef","sandwich":true,"groundLevel":0,"floorFinish":"visgraat_001","roof":{"overhang":400,"fasciaHeight":350,"spotMin":800,"spotMax":1200,"trimHeight":30,"fasciaColor":"wit","vent":true,"ventColor":"antraciet","house":{"enabled":true,"form":{"type":"zadel","ridge":"x","low":2800,"high":3400},"cover":false},"posts":{"enabled":true,"size":200,"wood":"eiken","maxSpan":3600,"minExt":1000,"sideWall":false,"sideColor":"wit"}},"lighting":{"mode":"spots","direction":"both","lampHeight":1950,"lampMin":500,"lampMax":3000,"minWallWidth":2000,"endOffset":300,"openingOffset":500,"indoor":true,"indoorMin":800,"indoorMax":1500,"indoorOffset":600},"elektra":{"enabled":true,"meterkast":{"w":600,"h":900,"d":250,"y":1000},"switchHeight":1500,"wcdHeight":400,"wcdPerUnit":2,"plateColor":"antraciet","slack":10,"reserve":300,"meterkastReserve":500,"apparaten":{"koken":true,"wasmachine":true,"droger":true,"warmtepomp":true,"oven":true,"vaatwasser":true},"place":{},"vloerWcd":[],"wcdExtra":{},"removed":[],"v":2}},"units":[{"id":"A","attach":null,"unit":{"layout":{"corners":"voor-achter"},"floor":{"floor":{"width":12850,"depth":3500,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"walls":{"voor":{"wall":{"width":12850,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":{"bottom":0,"top":600,"finish":"R510","capColor":"antraciet"},"rabatColor":"grijs","fels":null},"openings":[{"id":1,"type":"raam","w":800,"h":500,"y":1600,"anchor":"right","offset":3490,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":2,"type":"raam","w":800,"h":1100,"y":1000,"anchor":"right","offset":6530,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":3,"type":"raam","w":1400,"h":1100,"y":1000,"anchor":"right","offset":8510,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"achter":{"wall":{"width":12850,"heightAll":3356,"heightLeft":3356,"heightRight":3356,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":{"bottom":0,"top":600,"finish":"R510","capColor":"antraciet"},"rabatColor":"grijs","fels":null},"openings":[]},"links":{"wall":{"width":2988,"heightAll":2844,"heightLeft":2844,"heightRight":3356,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":{"bottom":0,"top":600,"finish":"R510","capColor":"antraciet"},"rabatColor":"grijs","fels":null},"openings":[{"id":1,"type":"raam","w":500,"h":1400,"y":700,"anchor":"left","offset":1170,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"rechts":{"wall":{"width":2988,"heightAll":2844,"heightLeft":3356,"heightRight":2844,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":{"bottom":0,"top":600,"finish":"R510","capColor":"antraciet"},"rabatColor":"grijs","fels":null},"openings":[{"id":1,"type":"deur","w":1800,"h":2100,"y":0,"anchor":"right","offset":880,"fill":{"type":"schuifdeur","rooster":false,"glas":true,"out":"zwart","in":"zwart","leaves":2,"mount":"opbouw","side":"binnen"}}]}}},"roof":{"followFloor":true,"offsetX":750,"offsetZ":0,"extend":{"f":0,"r":1500,"b":0,"l":0},"fascia":true,"covering":"dakleer","deck":{"floor":{"width":14350,"depth":3500,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"unitWalls":{"voor":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"achter":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"links":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"rechts":{"heightAll":2800,"heightLeft":2800,"heightRight":2800}}},"lighting":{"walls":{}},"facade":{"walls":{}}},{"id":"B","attach":{"to":"A","side":"achter","align":"start","offset":0},"unit":{"layout":{"corners":"voor-achter"},"floor":{"floor":{"width":12850,"depth":3500,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"walls":{"voor":{"wall":{"width":12850,"heightAll":3356,"heightLeft":3356,"heightRight":3356,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":{"bottom":0,"top":600,"finish":"R510","capColor":"antraciet"},"rabatColor":"grijs","fels":null},"openings":[]},"achter":{"wall":{"width":12850,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":{"bottom":0,"top":600,"finish":"R510","capColor":"antraciet"},"rabatColor":"grijs","fels":null},"openings":[{"id":1,"type":"raam","w":500,"h":1400,"y":700,"anchor":"left","offset":3530,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":2,"type":"voordeur","w":1100,"h":2100,"y":0,"anchor":"left","offset":4310,"fill":{"type":"deur","rooster":false,"glas":true,"out":"zwart","in":"zwart","hinge":"links","swing":"buiten"}},{"id":3,"type":"raam","w":500,"h":2100,"y":0,"anchor":"left","offset":7130,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":4,"type":"raam","w":500,"h":2100,"y":0,"anchor":"left","offset":8230,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":5,"type":"raam","w":500,"h":2100,"y":0,"anchor":"left","offset":10470,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":6,"type":"raam","w":500,"h":2100,"y":0,"anchor":"left","offset":11570,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"links":{"wall":{"width":2988,"heightAll":2844,"heightLeft":3356,"heightRight":2844,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":{"bottom":0,"top":600,"finish":"R510","capColor":"antraciet"},"rabatColor":"grijs","fels":null},"openings":[{"id":1,"type":"raam","w":500,"h":1400,"y":700,"anchor":"left","offset":390,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":2,"type":"raam","w":1400,"h":1100,"y":1000,"anchor":"left","offset":1570,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"rechts":{"wall":{"width":2988,"heightAll":2844,"heightLeft":2844,"heightRight":3356,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":{"bottom":0,"top":600,"finish":"R510","capColor":"antraciet"},"rabatColor":"grijs","fels":null},"openings":[{"id":1,"type":"deur","w":900,"h":2100,"y":0,"anchor":"right","offset":860,"fill":{"type":"deur","rooster":false,"glas":false,"out":"zwart","in":"zwart","leaf":"rechts","hinge":"rechts","swing":"buiten","volglas":true}},{"id":2,"type":"deur","w":900,"h":2100,"y":0,"anchor":"right","offset":1760,"fill":{"type":"deur","rooster":false,"glas":false,"out":"zwart","in":"zwart","leaf":"links","hinge":"links","swing":"buiten","volglas":true}}]}}},"roof":{"followFloor":true,"offsetX":750,"offsetZ":0,"extend":{"f":0,"r":1500,"b":0,"l":0},"fascia":true,"covering":"dakleer","deck":{"floor":{"width":14350,"depth":3500,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"unitWalls":{"voor":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"achter":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"links":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"rechts":{"heightAll":2800,"heightLeft":2800,"heightRight":2800}}},"lighting":{"walls":{"achter":"openingen"}},"facade":{"walls":{}}}],"interiorWalls":[{"id":1,"x1":3235,"z1":190,"x2":3235,"z2":3430,"wall":{"wall":{"width":3240,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":980,"h":2130,"y":0,"anchor":"left","offset":1530,"fill":{"type":"schuifdeur","rooster":false,"glas":false,"out":"wit","in":"wit","leaves":1,"mount":"opbouw","side":"buiten","dir":"links"}}]}},{"id":2,"x1":5325,"z1":190,"x2":5325,"z2":3430,"wall":{"wall":{"width":3240,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":3,"x1":3280,"z1":3510,"x2":6535,"z2":3510,"wall":{"wall":{"width":3255,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":140,"plateThick":38,"plateDepth":140,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":4,"x1":3235,"z1":3590,"x2":3235,"z2":4655,"wall":{"wall":{"width":1065,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":75,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"buiten"}}]}},{"id":5,"x1":6490,"z1":3590,"x2":6490,"z2":6810,"wall":{"wall":{"width":3220,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":72,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"binnen"}}]}},{"id":6,"x1":190,"z1":4700,"x2":6445,"z2":4700,"wall":{"wall":{"width":6255,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":120,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"binnen"}},{"id":2,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":4200,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"rechts","swing":"buiten"}}]}},{"id":7,"x1":2248,"z1":4745,"x2":2248,"z2":5819,"wall":{"wall":{"width":1074,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":8,"x1":2203,"z1":5864,"x2":3607,"z2":5864,"wall":{"wall":{"width":1404,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":9,"x1":2938,"z1":5909,"x2":2938,"z2":6810,"wall":{"wall":{"width":901,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":10,"x1":3562,"z1":4745,"x2":3562,"z2":5819,"wall":{"wall":{"width":1074,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":70,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"binnen"}}]}}],"rooms":[{"id":1,"unitId":"A","x":9500,"z":1800,"type":"woonkeuken","name":"Woonkamer/keuken","floor":null,"paint":null,"fibo":null,"ceiling":2800},{"id":2,"unitId":"A","x":1500,"z":1800,"type":"slaapkamer","name":"Slaapkamer","floor":null,"paint":null,"fibo":null,"ceiling":2800},{"id":3,"unitId":"A","x":4300,"z":2200,"type":"badkamer","name":"","floor":"tegel_antraciet","paint":null,"fibo":{"decor":"wit_tegel","height":null},"ceiling":2800},{"id":4,"unitId":"B","x":4800,"z":600,"type":"hal","name":"Hal","floor":null,"paint":null,"fibo":null,"ceiling":2800},{"id":5,"unitId":"B","x":2900,"z":1800,"type":"toilet","name":"","floor":"tegel_antraciet","paint":null,"fibo":{"decor":"wit_tegel","height":1200},"ceiling":2800},{"id":6,"unitId":"B","x":4700,"z":2700,"type":"hal","name":"Entree","floor":null,"paint":null,"fibo":null,"ceiling":2800},{"id":7,"unitId":"B","x":1200,"z":2400,"type":"berging","name":"Berging","floor":null,"paint":null,"fibo":null,"ceiling":2800}],"fixtures":[{"id":1,"type":"kledingkast","unitId":"A","x":1690,"z":490,"anchor":"west","rot":0,"w":3000,"d":600,"h":2400,"opts":{"front":"wit"}},{"id":2,"type":"bed","unitId":"A","x":1240,"z":2770,"anchor":"west","rot":270,"w":1600,"d":2100,"h":520,"opts":{"nachtkastjes":false}},{"id":3,"type":"douche","unitId":"A","x":4585,"z":640,"anchor":"west","rot":90,"w":900,"d":1390,"h":2100,"opts":{"glas":"rechts","glasLengte":700}},{"id":4,"type":"wastafel","unitId":"A","x":5050,"z":1920,"anchor":"west","rot":90,"w":800,"d":460,"h":850,"opts":{"front":"eiken","spiegel":true}},{"id":5,"type":"toilet","unitId":"A","x":3790,"z":3085,"anchor":"west","rot":180,"w":1000,"d":690,"h":1200,"opts":{}},{"id":6,"type":"keuken","unitId":"A","x":5670,"z":1810,"anchor":"west","rot":270,"w":3240,"d":600,"h":910,"opts":{"front":"antraciet","spoel":false,"spoelbak":0.6,"kookplaat":true,"kookplaatPlek":0.45,"hogeKast":"links","vaatwasser":false,"bovenkasten":false}},{"id":7,"type":"keuken","unitId":"A","x":7025,"z":490,"anchor":"west","rot":0,"w":2110,"d":600,"h":910,"opts":{"front":"antraciet","spoel":true,"spoelbak":0.5,"kookplaat":false,"kookplaatPlek":0.2,"hogeKast":"geen","vaatwasser":true,"bovenkasten":false}},{"id":8,"type":"eettafel","unitId":"A","x":10230,"z":1730,"anchor":"west","rot":0,"w":2000,"d":1100,"h":750,"opts":{"vorm":"ovaal","stoelen":6}},{"id":9,"type":"bank","unitId":"B","x":9820,"z":1865,"anchor":"west","rot":90,"w":2500,"d":900,"h":820,"opts":{"chaise":"rechts","chaiseLengte":1300}},{"id":10,"type":"fauteuil","unitId":"B","x":8420,"z":410,"anchor":"west","rot":330,"w":850,"d":850,"h":800,"opts":{}},{"id":11,"type":"salontafel","unitId":"B","x":8180,"z":1680,"anchor":"west","rot":0,"w":800,"d":800,"h":400,"opts":{"vorm":"rond"}},{"id":12,"type":"tvmeubel","unitId":"B","x":6735,"z":2315,"anchor":"west","rot":270,"w":1500,"d":400,"h":450,"opts":{"tv":true}},{"id":13,"type":"kledingkast","unitId":"B","x":6145,"z":2277,"anchor":"west","rot":90,"w":2060,"d":600,"h":2400,"opts":{"front":"wit"}},{"id":14,"type":"toilet","unitId":"B","x":2638,"z":1812,"anchor":"west","rot":270,"w":1000,"d":690,"h":1200,"opts":{}},{"id":15,"type":"boiler","unitId":"B","x":1810,"z":1470,"anchor":"west","rot":0,"w":450,"d":450,"h":800,"opts":{"liter":80}},{"id":16,"type":"wasmachine","unitId":"B","x":2500,"z":3010,"anchor":"west","rot":180,"w":600,"d":600,"h":850,"opts":{}}],"chimneys":[]} \ No newline at end of file diff --git a/demo/modellen/wetering.json b/demo/modellen/wetering.json new file mode 100644 index 0000000..4847725 --- /dev/null +++ b/demo/modellen/wetering.json @@ -0,0 +1 @@ +{"type":"house-design","version":1,"exportedAt":"2026-10-14T20:05:43.141Z","model":"wetering","configurator":"0.7.7","project":{"naam":"Wetering","plaats":"","straat":"","nummer":"","tekenaar":"","opmerking":""},"settings":{"spacing":{"exterior":{"min":240,"max":480},"interior":{"min":300,"max":600}},"facade":{"type":"rabat","stone":"K490","rabatColor":"grijs","stucColor":"wit","felsColor":"antraciet"},"cladding":"verticaal","trasraam":{"enabled":false,"height":350,"finish":"cementplaat","capColor":"antraciet"},"interiorPaint":true,"interiorColor":"#f2efe9","ceilingColor":"#f4f3ef","sandwich":true,"groundLevel":0,"floorFinish":"visgraat_001","roof":{"overhang":300,"fasciaHeight":300,"spotMin":800,"spotMax":1200,"trimHeight":30,"fasciaColor":"zwart","vent":true,"ventColor":"antraciet","house":{"enabled":true,"form":{"type":"plat","low":2800,"high":2800},"cover":false},"posts":{"enabled":true,"size":200,"wood":"eiken","maxSpan":3600,"minExt":1000,"sideWall":false,"sideColor":"wit"}},"lighting":{"mode":"gevel","direction":"both","lampHeight":1950,"lampMin":500,"lampMax":3000,"minWallWidth":2000,"endOffset":300,"openingOffset":500,"indoor":true,"indoorMin":800,"indoorMax":1500,"indoorOffset":600},"elektra":{"enabled":true,"meterkast":{"w":600,"h":900,"d":250,"y":1000},"switchHeight":1500,"wcdHeight":400,"wcdPerUnit":2,"plateColor":"antraciet","slack":10,"reserve":300,"meterkastReserve":500,"apparaten":{"koken":true,"wasmachine":true,"droger":true,"warmtepomp":true,"oven":true,"vaatwasser":true},"place":{},"vloerWcd":[],"wcdExtra":{},"removed":[],"v":2}},"units":[{"id":"A","attach":null,"unit":{"layout":{"corners":"voor-achter"},"floor":{"floor":{"width":10500,"depth":2600,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"walls":{"voor":{"wall":{"width":10500,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"grijs","fels":null},"openings":[{"id":1,"type":"raam","w":500,"h":700,"y":1400,"anchor":"right","offset":5670,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"achter":{"wall":{"width":10500,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"grijs","fels":null},"openings":[]},"links":{"wall":{"width":2088,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"grijs","fels":null},"openings":[{"id":1,"type":"raam","w":700,"h":2100,"y":0,"anchor":"left","offset":300,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}},{"id":2,"type":"raam","w":700,"h":2100,"y":0,"anchor":"left","offset":1500,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]},"rechts":{"wall":{"width":2088,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"grijs","fels":null},"openings":[{"id":1,"type":"raam","w":500,"h":1400,"y":700,"anchor":"right","offset":270,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]}}},"roof":{"followFloor":true,"offsetX":0,"offsetZ":0,"extend":{"f":0,"r":0,"b":0,"l":0},"fascia":true,"covering":"dakleer","deck":{"floor":{"width":10500,"depth":2600,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"unitWalls":{"voor":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"achter":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"links":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"rechts":{"heightAll":2800,"heightLeft":2800,"heightRight":2800}}},"lighting":{"walls":{}},"facade":{"walls":{}}},{"id":"B","attach":{"to":"A","side":"achter","align":"start","offset":0},"unit":{"layout":{"corners":"voor-achter"},"floor":{"floor":{"width":10500,"depth":2200,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"walls":{"voor":{"wall":{"width":10500,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"grijs","fels":null},"openings":[]},"achter":{"wall":{"width":10500,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"grijs","fels":null},"openings":[{"id":1,"type":"deur","w":824,"h":2100,"y":0,"anchor":"left","offset":1200,"fill":{"type":"deur","rooster":false,"glas":false,"out":"zwart","in":"zwart","hinge":"links","swing":"buiten","volglas":true}},{"id":2,"type":"deur","w":824,"h":2100,"y":0,"anchor":"left","offset":2176,"fill":{"type":"deur","rooster":false,"glas":false,"out":"zwart","in":"zwart","hinge":"rechts","swing":"buiten","volglas":true}},{"id":3,"type":"voordeur","w":1100,"h":2100,"y":0,"anchor":"left","offset":5350,"fill":{"type":"deur","rooster":false,"glas":false,"out":"zwart","in":"zwart","hinge":"rechts","swing":"buiten","volglas":true}}]},"links":{"wall":{"width":1688,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"grijs","fels":null},"openings":[]},"rechts":{"wall":{"width":1688,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":235,"plateThick":38,"plateDepth":235,"sheathingThick":10,"minSpacing":240,"maxSpacing":480,"heightMid":null},"materials":{"interior":"spaanplaat","exterior":"vochtdoek","cladding":"verticaal","stone":null,"stuc":null,"trasraam":null,"rabatColor":"grijs","fels":null},"openings":[{"id":1,"type":"raam","w":500,"h":1400,"y":700,"anchor":"right","offset":540,"fill":{"type":"raam","rooster":false,"glas":false,"out":"zwart","in":"zwart"}}]}}},"roof":{"followFloor":true,"offsetX":0,"offsetZ":0,"extend":{"f":0,"r":0,"b":0,"l":0},"fascia":true,"covering":"dakleer","deck":{"floor":{"width":10500,"depth":2200,"joistWidth":70,"joistHeight":220,"minSpacing":300,"maxSpacing":600,"underlayThick":22},"chassis":{"enabled":false,"wheelDiameter":400,"trackWidth":1200,"wheelbase":1200},"openings":[]},"unitWalls":{"voor":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"achter":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"links":{"heightAll":2800,"heightLeft":2800,"heightRight":2800},"rechts":{"heightAll":2800,"heightLeft":2800,"heightRight":2800}}},"lighting":{"walls":{"achter":"openingen"}},"facade":{"walls":{}}}],"interiorWalls":[{"id":1,"x1":4675,"z1":190,"x2":4675,"z2":2210,"wall":{"wall":{"width":2020,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}},{"id":2,"x1":4630,"z1":2255,"x2":7120,"z2":2255,"wall":{"wall":{"width":2490,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":1390,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"rechts","swing":"buiten"}}]}},{"id":3,"x1":7165,"z1":190,"x2":7165,"z2":4610,"wall":{"wall":{"width":4420,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[{"id":1,"type":"deur","w":980,"h":2130,"y":0,"anchor":"left","offset":360,"fill":{"type":"schuifdeur","rooster":false,"glas":false,"out":"wit","in":"wit","leaves":1,"mount":"opbouw","side":"binnen","dir":"rechts"}},{"id":2,"type":"deur","w":930,"h":2115,"y":0,"anchor":"left","offset":2277,"fill":{"type":"deur","rooster":false,"glas":false,"out":"wit","in":"wit","hinge":"links","swing":"buiten"}}]}},{"id":4,"x1":6455,"z1":3765,"x2":7120,"z2":3765,"wall":{"wall":{"width":665,"heightAll":2800,"heightLeft":2800,"heightRight":2800,"studWidth":38,"studDepth":70,"plateThick":38,"plateDepth":70,"sheathingThick":10,"minSpacing":300,"maxSpacing":600},"materials":{"interior":"spaanplaat","exterior":"spaanplaat"},"openings":[]}}],"rooms":[{"id":1,"unitId":"A","x":2000,"z":1500,"type":"woonkeuken","name":"Woonkamer/keuken","floor":null,"paint":null,"fibo":null,"ceiling":null},{"id":2,"unitId":"A","x":5900,"z":1500,"type":"badkamer","name":"","floor":"tegel_antraciet","paint":null,"fibo":{"decor":"wit_tegel","height":null},"ceiling":2600},{"id":3,"unitId":"A","x":8700,"z":1500,"type":"slaapkamer","name":"Slaapkamer","floor":null,"paint":null,"fibo":null,"ceiling":null}],"fixtures":[{"id":1,"type":"keuken","unitId":"A","x":4330,"z":1245,"anchor":"west","rot":90,"w":2110,"d":600,"h":910,"opts":{"front":"antraciet","spoel":true,"spoelbak":0.85,"kookplaat":true,"kookplaatPlek":0.2,"hogeKast":"geen","vaatwasser":true,"bovenkasten":false}},{"id":2,"type":"keuken","unitId":"A","x":4630,"z":2600,"anchor":"west","rot":0,"w":1200,"d":600,"h":910,"opts":{"front":"antraciet","spoel":false,"spoelbak":0.6,"kookplaat":false,"kookplaatPlek":0.2,"hogeKast":"rechts","vaatwasser":false,"bovenkasten":false}},{"id":3,"type":"boiler","unitId":"A","x":5515,"z":2525,"anchor":"west","rot":0,"w":450,"d":450,"h":800,"opts":{"liter":80}},{"id":4,"type":"eettafel","unitId":"A","x":1620,"z":1160,"anchor":"west","rot":0,"w":1000,"d":1000,"h":750,"opts":{"vorm":"ovaal","stoelen":4}},{"id":5,"type":"bank","unitId":"B","x":615,"z":1040,"anchor":"west","rot":270,"w":1600,"d":850,"h":820,"opts":{"chaise":"geen","chaiseLengte":1700}},{"id":6,"type":"salontafel","unitId":"B","x":1770,"z":910,"anchor":"west","rot":0,"w":600,"d":600,"h":400,"opts":{"vorm":"rond"}},{"id":7,"type":"fauteuil","unitId":"B","x":2760,"z":1460,"anchor":"west","rot":150,"w":850,"d":850,"h":800,"opts":{}},{"id":8,"type":"douche","unitId":"A","x":5245,"z":640,"anchor":"west","rot":270,"w":900,"d":1050,"h":2100,"opts":{"glas":"links","glasLengte":800}},{"id":9,"type":"toilet","unitId":"A","x":5065,"z":1760,"anchor":"west","rot":270,"w":900,"d":690,"h":1200,"opts":{}},{"id":10,"type":"wastafel","unitId":"A","x":6150,"z":420,"anchor":"west","rot":0,"w":600,"d":460,"h":850,"opts":{"front":"eiken","spiegel":false}},{"id":11,"type":"bed","unitId":"A","x":1240,"z":2050,"anchor":"oost","rot":90,"w":1600,"d":2100,"h":520,"opts":{"nachtkastjes":false}},{"id":12,"type":"kledingkast","unitId":"B","x":8760,"z":1710,"anchor":"west","rot":180,"w":3100,"d":600,"h":2400,"opts":{"front":"wit"}}],"chimneys":[]} \ No newline at end of file diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..27a7344 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Van Emous CRM lokaal deployen vanaf een git-tag (zelfde werkwijze als straks op de server). +# +# ./deploy.sh nieuwste tag installeren en starten +# ./deploy.sh v0.0.1 een bepaalde versie (ook om terug te rollen) +# ./deploy.sh start de huidige versie starten zonder opnieuw te installeren +# ./deploy.sh list geïnstalleerde versies tonen +# +# Elke versie komt uit `git archive ` (precies wat getagd is, niet de werkkopie) in +# $CRM_HOME/releases/ met een eigen venv. `current` wijst naar de actieve versie. +# De data ($CRM_HOME/data) is gedeeld en blijft bestaan; vóór elke versiewissel gaat er +# een kopie van de database naar data/backups/, omdat migraties bij het starten draaien. +# +# De 3D-engine voor de viewer (gekozen model) zit niet in git: bij elke deploy komt die uit de +# configurator ($CONFIGURATOR, standaard ../configurator) in de actieve versie (tools/configurator-sync.sh). +# +# Instellingen via omgevingsvariabelen: CRM_HOME (standaard ~/.local/share/vanemous-crm), +# PORT (5050), HOST (127.0.0.1), WORKERS (2), CONFIGURATOR (../configurator). +set -euo pipefail + +REPO="$(cd "$(dirname "$0")" && pwd)" +CRM_HOME="${CRM_HOME:-$HOME/.local/share/vanemous-crm}" +RELEASES="$CRM_HOME/releases" +DATA="$CRM_HOME/data" +PORT="${PORT:-5050}" +HOST="${HOST:-127.0.0.1}" +WORKERS="${WORKERS:-2}" +CONFIGURATOR="${CONFIGURATOR:-$REPO/../configurator}" + +say() { printf '\033[38;2;185;89;19m▸\033[0m %s\n' "$*"; } +fail() { printf 'Fout: %s\n' "$*" >&2; exit 1; } + +current_version() { [ -L "$CRM_HOME/current" ] && basename "$(readlink "$CRM_HOME/current")" || true; } + +start() { + local dir="$CRM_HOME/current" + [ -d "$dir" ] || fail "nog geen versie geïnstalleerd; draai eerst ./deploy.sh" + say "Van Emous CRM $(current_version) op http://$HOST:$PORT (Ctrl+C om te stoppen)" + say "data: $DATA" + cd "$dir" + CRM_DATA="$DATA" exec "$dir/.venv/bin/gunicorn" --workers "$WORKERS" --bind "$HOST:$PORT" \ + --access-logfile - --error-logfile - app:app +} + +case "${1:-}" in + list) + active="$(current_version)" + for d in "$RELEASES"/*/; do + [ -d "$d" ] || { echo "Nog geen versies geïnstalleerd."; exit 0; } + v="$(basename "$d")"; [ "$v" = "$active" ] && echo "* $v (actief)" || echo " $v" + done + exit 0 ;; + start) start ;; +esac + +# --- welke versie --------------------------------------------------------------- +git -C "$REPO" rev-parse --git-dir >/dev/null 2>&1 || fail "$REPO is geen git-repository" +TAG="${1:-$(git -C "$REPO" tag --list 'v*' --sort=-v:refname | head -n1)}" +[ -n "$TAG" ] || fail "geen versietags gevonden (verwacht bijv. v0.0.1)" +[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "onbekende versie '$TAG' (verwacht vX.Y.Z)" +git -C "$REPO" rev-parse -q --verify "refs/tags/$TAG" >/dev/null || fail "tag $TAG bestaat niet" + +# versie in de code moet bij de tag horen +code_version="$(git -C "$REPO" show "$TAG:app.py" | sed -nE "s/^__version__ = '([^']+)'.*/\1/p")" +[ "v$code_version" = "$TAG" ] || fail "tag $TAG bevat __version__ = '$code_version' in app.py; tag en code komen niet overeen" + +if [ -n "$(git -C "$REPO" status --porcelain)" ]; then + say "let op: je werkkopie heeft niet-gecommitte wijzigingen; die zitten niet in $TAG" +fi + +# --- installeren ---------------------------------------------------------------- +target="$RELEASES/$TAG" +mkdir -p "$RELEASES" "$DATA/backups" +if [ -f "$target/.installed" ]; then + say "$TAG is al geïnstalleerd" +else + say "$TAG uitpakken naar $target" + rm -rf "$target"; mkdir -p "$target" + git -C "$REPO" archive "$TAG" | tar -x -C "$target" + say "venv en afhankelijkheden installeren" + python3 -m venv "$target/.venv" + "$target/.venv/bin/pip" install --quiet --upgrade pip + "$target/.venv/bin/pip" install --quiet -r "$target/requirements.txt" gunicorn + "$target/.venv/bin/python" -m py_compile "$target"/*.py + git -C "$REPO" rev-parse "$TAG^{commit}" > "$target/.installed" +fi + +# --- wisselen --------------------------------------------------------------------- +previous="$(current_version)" +if [ -f "$DATA/crm.db" ] && [ "$previous" != "$TAG" ]; then + backup="$DATA/backups/crm-$(date +%Y%m%d-%H%M%S)-${previous:-onbekend}-naar-$TAG.db" + # sqlite3 .backup is veilig bij WAL; anders een gewone kopie + if command -v sqlite3 >/dev/null; then sqlite3 "$DATA/crm.db" ".backup '$backup'"; else cp "$DATA/crm.db" "$backup"; fi + say "database geback-upt: $backup" +fi +ln -sfn "$target" "$CRM_HOME/current" +say "actief: $TAG${previous:+ (was $previous)}" + +# --- 3D-engine (configurator) ------------------------------------------------------ +if [ -x "$target/tools/configurator-sync.sh" ]; then + "$target/tools/configurator-sync.sh" "$(cd "$CONFIGURATOR" 2>/dev/null && pwd || echo "$CONFIGURATOR")" "$target/static/configurator" \ + || say "let op: geen configurator in $CONFIGURATOR; de viewer van het gekozen model werkt dan niet" +fi + +start diff --git a/server/activate.sh b/server/activate.sh new file mode 100755 index 0000000..68c9c24 --- /dev/null +++ b/server/activate.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Op de server, als gebruiker crm (tools/release.sh roept dit aan): een geüploade versie installeren en actief maken. +# +# bash /srv/vanemous-crm/releases/vX.Y.Z/server/activate.sh vX.Y.Z +# +# Eigen venv per versie; vóór een versiewissel gaat er een kopie van de database naar data/backups/ (migraties +# draaien bij het starten). Start de nieuwe versie niet, dan gaat `current` terug naar de vorige. +# De laatste 5 versies blijven staan, dus terugrollen is `tools/release.sh crm@server vOUD`. +set -euo pipefail + +TAG="${1:?gebruik: activate.sh vX.Y.Z}" +ROOT=/srv/vanemous-crm +APP=vanemous-crm +target="$ROOT/releases/$TAG" + +say() { printf '\033[38;2;185;89;19m▸\033[0m %s\n' "$*"; } +fail() { printf 'Fout: %s\n' "$*" >&2; exit 1; } +current_version() { [ -L "$ROOT/current" ] && basename "$(readlink "$ROOT/current")" || true; } + +[ "$(id -un)" = crm ] || fail "draai dit als gebruiker crm" +[ -f "$target/app.py" ] || fail "$target bestaat niet of is onvolledig" + +if [ ! -f "$target/.installed" ]; then + say "venv en afhankelijkheden voor $TAG" + python3 -m venv "$target/.venv" + "$target/.venv/bin/pip" install --quiet --upgrade pip + "$target/.venv/bin/pip" install --quiet -r "$target/requirements.txt" gunicorn + "$target/.venv/bin/python" -m compileall -q "$target"/*.py + touch "$target/.installed" +fi + +previous="$(current_version)" +if [ -f "$ROOT/data/crm.db" ] && [ "$previous" != "$TAG" ]; then + backup="$ROOT/data/backups/crm-$(date +%Y%m%d-%H%M%S)-${previous:-leeg}-naar-$TAG.db" + sqlite3 "$ROOT/data/crm.db" ".backup '$backup'" + say "database geback-upt: $(basename "$backup")" +fi + +ln -sfn "$target" "$ROOT/current" +sudo /usr/bin/systemctl restart $APP + +# even wachten en controleren of de app antwoordt +ok= +for _ in 1 2 3 4 5 6 7 8 9 10; do + sleep 1 + if curl -fsS -o /dev/null http://127.0.0.1:5050/login; then ok=1; break; fi +done +if [ -z "$ok" ]; then + if [ -n "$previous" ] && [ "$previous" != "$TAG" ]; then + ln -sfn "$ROOT/releases/$previous" "$ROOT/current" + sudo /usr/bin/systemctl restart $APP + fail "$TAG start niet; terug naar $previous. Log: sudo journalctl -u $APP -n 50" + fi + fail "$TAG start niet. Log: sudo journalctl -u $APP -n 50" +fi +say "actief: $TAG${previous:+ (was $previous)}" + +# oude versies opruimen (de 5 nieuwste blijven) +ls -1d "$ROOT"/releases/v*/ 2>/dev/null | xargs -n1 basename | sort -V | head -n -5 | while read -r old; do + [ "$old" = "$TAG" ] || rm -rf "${ROOT:?}/releases/$old" +done diff --git a/server/nginx-site.conf b/server/nginx-site.conf new file mode 100644 index 0000000..3d7b084 --- /dev/null +++ b/server/nginx-site.conf @@ -0,0 +1,25 @@ +# Van Emous CRM achter nginx (server/setup.sh vult __DOMAIN__ in; certbot voegt HTTPS en de doorverwijzing toe). +server { + listen 80; + listen [::]:80; + server_name __DOMAIN__; + + # uploads tot 25 MB (MAX_CONTENT_LENGTH in app.py) + client_max_body_size 25m; + + # css, js en de 3D-engine direct door nginx; steeds even navragen zodat een nieuwe release meteen zichtbaar is + location /static/ { + alias /srv/vanemous-crm/current/static/; + add_header Cache-Control "no-cache"; + access_log off; + } + + location / { + proxy_pass http://127.0.0.1:5050; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_read_timeout 120s; + } +} diff --git a/server/setup.sh b/server/setup.sh new file mode 100755 index 0000000..a027981 --- /dev/null +++ b/server/setup.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Eenmalig op een nieuwe Ubuntu-server (getest voor 26.04 LTS, OVHcloud-image): alles klaarzetten voor de CRM. +# +# scp -r server ubuntu@SERVER: +# ssh ubuntu@SERVER sudo bash server/setup.sh crm.voorbeeld.nl jij@voorbeeld.nl +# +# Daarna rolt tools/release.sh (op je eigen machine) een versie uit naar crm@SERVER. +# Opnieuw draaien kan: elke stap controleert wat er al is. +# +# Wat het doet: pakketten (Python, nginx, certbot, rsync, sqlite3), tijdzone Europe/Amsterdam, gebruiker `crm` +# met /srv/vanemous-crm, SSH-toegang voor releases (zelfde sleutels als de sudo-gebruiker), systemd-dienst, +# nginx-site met HTTPS (Let's Encrypt), firewall (alleen SSH, 80, 443), automatische beveiligingsupdates, +# cron voor e-mail ophalen (elke 10 min) en een dagelijkse databaseback-up (7 dagen rond). +set -euo pipefail + +DOMAIN="${1:-}" +EMAIL="${2:-}" +APP=vanemous-crm +ROOT=/srv/$APP +HERE="$(cd "$(dirname "$0")" && pwd)" + +say() { printf '\033[38;2;185;89;19m▸\033[0m %s\n' "$*"; } +fail() { printf 'Fout: %s\n' "$*" >&2; exit 1; } + +[ "$(id -u)" = 0 ] || fail "draai dit met sudo" +[ -n "$DOMAIN" ] && [ -n "$EMAIL" ] || fail "gebruik: sudo bash setup.sh " + +# --- pakketten ------------------------------------------------------------------------ +say "pakketten installeren" +export DEBIAN_FRONTEND=noninteractive +apt-get update -q +apt-get upgrade -yq +apt-get install -yq python3 python3-venv python3-pip nginx certbot python3-certbot-nginx \ + rsync sqlite3 ufw unattended-upgrades cron curl +python3 -c 'import sys; assert sys.version_info >= (3, 11), sys.version' || fail "Python 3.11 of nieuwer nodig" + +say "tijdzone Europe/Amsterdam" +timedatectl set-timezone Europe/Amsterdam + +say "automatische beveiligingsupdates aan" +dpkg-reconfigure -f noninteractive unattended-upgrades + +# --- gebruiker en mappen ----------------------------------------------------------------- +if ! id crm >/dev/null 2>&1; then + say "gebruiker crm aanmaken" + useradd --system --create-home --home-dir "$ROOT" --shell /bin/bash crm +fi +install -d -o crm -g crm -m 755 "$ROOT" "$ROOT/releases" +install -d -o crm -g crm -m 700 "$ROOT/data" "$ROOT/data/backups" + +# SSH-sleutels van de gebruiker die sudo deed (bij OVH: ubuntu), zodat releases als crm kunnen inloggen. +src_user="${SUDO_USER:-ubuntu}" +src_keys="$(getent passwd "$src_user" | cut -d: -f6)/.ssh/authorized_keys" +install -d -o crm -g crm -m 700 "$ROOT/.ssh" +if [ -f "$src_keys" ]; then + cat "$src_keys" "$ROOT/.ssh/authorized_keys" 2>/dev/null | sort -u > "$ROOT/.ssh/authorized_keys.new" + mv "$ROOT/.ssh/authorized_keys.new" "$ROOT/.ssh/authorized_keys" + chown crm:crm "$ROOT/.ssh/authorized_keys"; chmod 600 "$ROOT/.ssh/authorized_keys" + say "SSH-sleutels van $src_user toegevoegd aan crm" +else + say "let op: geen $src_keys; zet zelf een sleutel in $ROOT/.ssh/authorized_keys" +fi + +# crm mag alleen de eigen dienst herstarten +cat > /etc/sudoers.d/$APP </dev/null + +# --- systemd ----------------------------------------------------------------------------- +say "systemd-dienst $APP" +install -m 644 "$HERE/$APP.service" /etc/systemd/system/$APP.service +systemctl daemon-reload +systemctl enable $APP >/dev/null 2>&1 +# starten gebeurt bij de eerste release (er is nog geen code) + +# --- nginx ------------------------------------------------------------------------------- +say "nginx voor $DOMAIN" +if [ ! -f /etc/nginx/sites-available/$APP ] || ! grep -q "listen 443" /etc/nginx/sites-available/$APP; then + sed "s/__DOMAIN__/$DOMAIN/g" "$HERE/nginx-site.conf" > /etc/nginx/sites-available/$APP +fi +ln -sfn /etc/nginx/sites-available/$APP /etc/nginx/sites-enabled/$APP +rm -f /etc/nginx/sites-enabled/default +nginx -t +systemctl reload nginx + +# --- firewall ------------------------------------------------------------------------------ +say "firewall: alleen SSH, HTTP en HTTPS" +ufw allow OpenSSH >/dev/null +ufw allow 'Nginx Full' >/dev/null +ufw --force enable >/dev/null + +# --- HTTPS ------------------------------------------------------------------------------- +say "certificaat aanvragen (DNS van $DOMAIN moet al naar deze server wijzen)" +if certbot --nginx -d "$DOMAIN" -m "$EMAIL" --agree-tos --non-interactive --redirect; then + say "HTTPS actief; verlengen gaat automatisch (certbot.timer)" +else + say "let op: certificaat mislukt. Klopt de DNS? Daarna opnieuw: sudo certbot --nginx -d $DOMAIN -m $EMAIL --agree-tos --redirect" +fi + +# --- cron: e-mail en back-up ------------------------------------------------------------------- +say "cron: e-mail elke 10 minuten, database-back-up elke nacht" +cat > /etc/cron.d/$APP <&1 | logger -t $APP-sync +15 3 * * * crm [ -f $ROOT/data/crm.db ] && sqlite3 $ROOT/data/crm.db ".backup '$ROOT/data/backups/nacht-\$(date +\%a).db'" +EOF +chmod 644 /etc/cron.d/$APP + +say "klaar. Rol nu vanaf je eigen machine een versie uit: tools/release.sh crm@$DOMAIN" diff --git a/server/vanemous-crm.service b/server/vanemous-crm.service new file mode 100644 index 0000000..db46288 --- /dev/null +++ b/server/vanemous-crm.service @@ -0,0 +1,26 @@ +# Van Emous CRM: gunicorn achter nginx (server/setup.sh installeert dit naar /etc/systemd/system/). +[Unit] +Description=Van Emous CRM +After=network-online.target +Wants=network-online.target + +[Service] +User=crm +Group=crm +WorkingDirectory=/srv/vanemous-crm/current +Environment=CRM_DATA=/srv/vanemous-crm/data +Environment=CRM_PROXY=1 +ExecStart=/srv/vanemous-crm/current/.venv/bin/gunicorn --workers 2 --bind 127.0.0.1:5050 \ + --timeout 120 --access-logfile - --error-logfile - app:app +Restart=on-failure +RestartSec=3 + +# afscherming: alleen de datamap is schrijfbaar +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/srv/vanemous-crm/data + +[Install] +WantedBy=multi-user.target diff --git a/static/bestek.js b/static/bestek.js new file mode 100644 index 0000000..f4f8cc0 --- /dev/null +++ b/static/bestek.js @@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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 => ` +
+

${esc(ch.name)}

${eur(ch.price)}
+ ${ch.lines.map(l => ` + `).join('')} + ${ch.info.map(l => ``).join('')}
${esc(l.label)}${l.product ? ` ${esc(l.product.sku)}` : ''}${nl(l.qty, l.qty % 1 ? (l.unit === 'm³' ? 2 : 1) : 0)} ${esc(l.unit)}× ${eur(l.unitPrice)}${eur(l.price)}
${esc(l.label)}${nl(l.qty, 1)} ${esc(l.unit)}
+
`).join(''); + $('.bs-total').innerHTML = `
Totaal excl. btw${eur(b.totals.exBtw)}
+
Btw ${nl(b.vat)}%${eur(b.totals.btw)}
+
Totaal incl. btw${eur(b.totals.inclBtw)}
`; + 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 = `

Dit ontwerp kan niet worden doorgerekend: ${esc(err.message)}

`; + 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 = `

Het bestek kon niet worden berekend: ${esc(err.message)}

`; + } + }; + // 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(); }); +})(); diff --git a/static/style.css b/static/style.css index c7cd546..1cb47f6 100644 --- a/static/style.css +++ b/static/style.css @@ -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); } diff --git a/static/viewer.js b/static/viewer.js new file mode 100644 index 0000000..06bb711 --- /dev/null +++ b/static/viewer.js @@ -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]) => `
${k}${v}
`).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 = `` + + ViewLayers.PHASES.slice(0, -1).map((p, i) => ``).join(''); + const LAYER_KEYS = ViewLayers.LIST.filter(l => l.key !== 'maten'); + layerPop.querySelector('.vw-checks').innerHTML = LAYER_KEYS.map(l => + ``).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 `
${PDF_LABEL[k]}${k === 'werk' ? 'Alle tekenbladen, elektra, zaaglijst en kosten (intern)' : 'Zoals de configurator: voorblad, aanzichten, plattegrond en werkwijze (ook in het klantportaal)'} + ${f ? `Gemaakt ${f.date}` : ''}
+
${f ? `Openen` : ''} + ${cfg.pdfUrl ? `` : ''}
`; + }).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); + })(); +})(); diff --git a/templates/account.html b/templates/account.html index 6884d47..d746c16 100644 --- a/templates/account.html +++ b/templates/account.html @@ -2,12 +2,12 @@ {% block title %}Mijn account{% endblock %} {% block content %}
{{ ROLE.get(g.user.role) }}

{{ g.user.name }}

{{ g.user.email }}
-
+

Wachtwoord wijzigen

-
-
-
+
+
+
-
- +
+
{% endblock %} diff --git a/templates/auth.html b/templates/auth.html index 54da680..417c053 100644 --- a/templates/auth.html +++ b/templates/auth.html @@ -2,32 +2,36 @@ {% set titles = {'setup': 'Welkom', 'login': 'Inloggen', 'portal_login': 'Klantportaal', 'invite': 'Wachtwoord instellen'} %} {% block title %}{{ titles[mode] }}{% endblock %} {% block body %} +
Van Emous {% with msgs = get_flashed_messages(with_categories=true) %} {% for cat, m in msgs %}
{{ m }}
{% endfor %} {% endwith %} -
+

{{ titles[mode] }}

+
{% if mode == 'setup' %} -

Maak het eerste beheerdersaccount aan.

- - +

Maak het eerste beheerdersaccount aan.

+
+
{% elif mode == 'invite' %} -

Welkom {{ c.name }}. Kies een wachtwoord voor het klantportaal. Daarna logt u in met {{ c.email }}.

+

Welkom {{ c.name }}. Kies een wachtwoord voor het klantportaal. Daarna logt u in met {{ c.email }}.

{% else %} - {% if mode == 'portal_login' %}

Bekijk uw offertes en deel bestanden met ons.

{% endif %} - + {% if mode == 'portal_login' %}

Bekijk uw offertes en deel bestanden met ons.

{% endif %} +
{% endif %} - - +
+
{% if mode in ('setup', 'invite') %} - - +
+
{% endif %} -
+
+
-
+
+
{% endblock %} diff --git a/templates/base.html b/templates/base.html index 02aac00..b70920c 100644 --- a/templates/base.html +++ b/templates/base.html @@ -7,6 +7,7 @@ + + {% block body %} {% set ep = request.endpoint or '' %} +
-
- {% with msgs = get_flashed_messages(with_categories=true) %} - {% for cat, m in msgs %}
{{ m }}
{% endfor %} - {% endwith %} - {% block content %}{% endblock %} -
+
+
+ + Van Emous + {% if unread %}{{ unread }}{% endif %} +
+
+ {% with msgs = get_flashed_messages(with_categories=true) %} + {% for cat, m in msgs %}
{{ m }}
{% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+
+
{% endblock %} diff --git a/templates/bestek.html b/templates/bestek.html new file mode 100644 index 0000000..a4bb385 --- /dev/null +++ b/templates/bestek.html @@ -0,0 +1,79 @@ + + + + + + {# De engine haalt kosten/*.csv en assets/ relatief op: base = de gekopieerde configurator. Alle CRM-links zijn absoluut. #} + + Bestek · Offerte {{ qt.number }} · Van Emous CRM + + + + + + + + +
+
+
+ ← Offerte {{ qt.number }} +

Bestek uit ontwerp

+
{{ design_file.title }}{% if c %} · {{ c.name }}{% endif %} · ontvangen {{ design_file.created_at|dt }}
+
+ {% if designs|length > 1 %} +
+ +
+ {% endif %} +
+ +
+
+

Hoeveelheden en prijzen uit de configurator (zelfde berekening als Kosten & tijden: kostprijs met + algemene kosten en marge, prijzen uit de kosten-CSV's). Bedragen excl. btw. Schroeven, zaag- en boorwerk staan per hoofdstuk samen onder "Bevestiging en bewerking". + Posten met een code worden gekoppeld aan het product met die SKU (bestaat het nog niet, dan wordt het aangemaakt).

+
{% if engine %}

Bestek berekenen…

{% else %}

De 3D-engine van de configurator ontbreekt. Draai tools/configurator-sync.sh in de CRM-map.

{% endif %}
+
+
+ +
+ + +

In de offerte zetten

+
+
+
+
+

Het bestek (alle posten met hoeveelheden) komt als bijlage bij de offerte, ook in de afdrukversie en het klantportaal.

+
+ Annuleren
+
+
+
+{% if engine %} + + +{% for src in scripts %} +{% endfor %} +{% endif %} + + diff --git a/templates/contact.html b/templates/contact.html index 6d971cf..8abf230 100644 --- a/templates/contact.html +++ b/templates/contact.html @@ -1,25 +1,5 @@ {% extends 'base.html' %} -{% block title %}{{ c.name }}{% if c.type == 'lead' %} - -
-

Omzetten naar klant

-

De contactpersoon komt in een klantrelatie, bijvoorbeeld een huishouden of familie.

- - - {% if klant_relations %} - - - {% endif %} -
- - -
-
-
-{% endif %} -{% endblock %} +{% block title %}{{ c.name }}{% endblock %} {% block content %}
@@ -30,24 +10,77 @@
- {% for k, l in STATUSES %}{% endfor %}
{% if c.type == 'lead' %} - + {% endif %}
- +
- Bewerken + Bewerken
-
-
-
+
+
+
+
+

Gekozen model

+ {% if design %} +
+ {% if designs|length > 1 %} +
+ +
+ {% endif %} +
+ + +
+ Openen + JSON +
+ {% endif %} +
+ {% if design %} +
+

{{ design.title }} + · {{ design_meta.units }} unit{{ 's' if design_meta.units != 1 }}{% if design_meta.address %} · {{ design_meta.address }}{% endif %} + · ontvangen {{ design.created_at|dt }}{% if design_meta.version %} · configurator {{ design_meta.version }}{% endif %}

+ {% if design_meta.note %}

{{ design_meta.note }}

{% endif %} + + + {% elif designs %} +

Er is geen model gekozen.

+
+ + +
+ {% else %} +

Nog geen model. Een ontwerp uit de configurator (Bestand › Opslaan, JSON) komt hier vanzelf als je het bij Bestanden uploadt of als het met het formulier meekomt.

+ {% endif %} +
+ +

Taken

{% for t in tasks %}
@@ -56,46 +89,44 @@
{{ t.user_name or '' }}{% if t.due_date %} · {{ t.due_date|dt('%d-%m-%Y') }}{% endif %}
{% endfor %} -
-
- - -
-
-
+
+
+ -
+
-
+
-
+

Notitie toevoegen

- -
+ +
-
+
{% if c.email %} -
+
E-mail sturen
-
-
-
-
+
+
+
+
-
+
-
+
{% endif %} -
+

Tijdlijn

    {% for a in activities %} @@ -107,28 +138,28 @@
  • Nog geen activiteit.
  • {% endfor %}
-
+
{% if emails %} -
+

E-mail

-
+
{% for e in emails %} - - - + + + {% endfor %}
-
+
{% endif %}
-
-
+
+

Gegevens

Status
{{ STATUS.get(c.status, c.status) }}
@@ -151,19 +182,23 @@ {% for k, v in extra.items() %}
{{ k }}
{{ v if v is string else v|tojson }}
{% endfor %}
{% endif %} -
+
-
+

Bestanden

{% if files %}
{% for f in files %}
- + {% if f.mime.startswith('image/') %}{{ f.original }} - {% else %}{{ f.original.rsplit('.', 1)[-1][:4] if '.' in f.original else 'bestand' }}{% endif %} + {% elif f.title %}3D{% else %}{{ f.original.rsplit('.', 1)[-1][:4] if '.' in f.original else 'bestand' }}{% endif %} -
{{ f.original }}
+
{{ f.title or f.original }}
+ {% if f.kind == 'werk' %}
Intern, niet in portaal
{% endif %} + {% if f.title %}{% if c.design_file_id == f.id %}
Gekozen model
{% else %} +
+ {% endif %}{% endif %}
@@ -172,12 +207,12 @@
{% endif %}
- -
+ +
-
+
-
+

Offertes

{% for qt in quotes %}

{{ qt.number }} {{ QSTATUS.get(qt.status) }}
@@ -185,27 +220,27 @@ {% else %}

Nog geen offertes.

{% endfor %} -
+
-
+

Klantportaal

{% if c.portal_password_hash %} -

Actief{% if c.portal_last_login %} · laatst ingelogd {{ c.portal_last_login|dt }}{% endif %}

+

Actief{% if c.portal_last_login %} · laatst ingelogd {{ c.portal_last_login|dt }}{% endif %}

{% elif portal_link %} -

Uitnodiging klaar, nog niet geactiveerd:

+

Uitnodiging klaar, nog niet geactiveerd:

{{ portal_link }}

{% else %} -

De klant kan via het portaal offertes bekijken en accepteren, en bestanden en berichten sturen.

+

De klant kan via het portaal offertes bekijken en accepteren, en bestanden en berichten sturen.

{% endif %}
- {% if portal_link and c.email %}{% endif %} - {% if not c.portal_password_hash %}{% endif %} - {% if c.portal_password_hash or portal_link %}{% endif %} + {% if portal_link and c.email %}{% endif %} + {% if not c.portal_password_hash %}{% endif %} + {% if c.portal_password_hash or portal_link %}{% endif %}
-
+
-
- + +
@@ -214,17 +249,23 @@

Omzetten naar klant

De contactpersoon komt in een klantrelatie, bijvoorbeeld een huishouden of familie.

- - +
+ + +
+ {% if klant_relations %} - - + +
+ {% endif %}
- - + +
diff --git a/templates/contact_form.html b/templates/contact_form.html index f5ea870..e1998b3 100644 --- a/templates/contact_form.html +++ b/templates/contact_form.html @@ -7,33 +7,33 @@

{{ ('Nieuwe ' ~ TYPE.get(c.type, 'lead')|lower) if new else 'Contact bewerken' }}

-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
- {% for k, l in TYPES %}{% endfor %}
-
- {% for u in USERS %}{% endfor %}
-
- {% for kind, label in RELATION_KINDS %} @@ -43,14 +43,14 @@

Huishouden, familie of bedrijf van de klant, of de partner waar deze persoon werkt.

-
-
-
-
+
+
+
+
- - Annuleren + + Annuleren
-
+
{% endblock %} diff --git a/templates/contacts.html b/templates/contacts.html index 0f9bc1d..06f0986 100644 --- a/templates/contacts.html +++ b/templates/contacts.html @@ -6,10 +6,10 @@ @@ -30,25 +30,26 @@ Niet toegewezen -
+
{% if rows %} -
- +
NaamRelatieTelefoonPlaatsBronToegewezenStatusAangemaakt
+ {% for c in rows %} - - - - - - - - + + + + + + + + + {% endfor %}
NaamRelatieTelefoonPlaatsModelBronToegewezenStatusAangemaakt
{% else %}

Geen contacten gevonden.

{% endif %} -
+
{% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html index b72c24b..70590ee 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -3,101 +3,109 @@ {% block content %}
Overzicht

Dashboard

- Nieuw contact +
-
-
Nieuwe leads · 30 dagen
{{ stats.new30 }}
{{ stats.customers }} klantrelaties · {{ stats.partners }} partners
-
Open leads
{{ stats.open_leads }}
-
{{ stats.pipeline_value|euro }} verwacht{% if stats.unassigned %} · {{ stats.unassigned }} niet toegewezen{% endif %}
-
Open offertes
{{ stats.open_quotes }}
{{ stats.open_value|euro }} excl. btw
-
Gewonnen · 30 dagen
{{ stats.won_value|euro }}
excl. btw
+
+
Nieuwe leads · 30 dagen
{{ stats.new30 }}
{{ stats.customers }} klantrelaties · {{ stats.partners }} partners
+
Open leads
{{ stats.open_leads }}
+
{{ stats.pipeline_value|euro }} verwacht{% if stats.unassigned %} · {{ stats.unassigned }} niet toegewezen{% endif %}
+
Open offertes
{{ stats.open_quotes }}
{{ stats.open_value|euro }} excl. btw
+
Gewonnen · 30 dagen
{{ stats.won_value|euro }}
excl. btw
-
-
-

Leads per status

-
- {% for key, label, n in pipeline %} - - {{ label }} - - {{ n }} - - {% endfor %} -
-
-
-

Leads per week

-
- {% for wk, n in weekly %} -
- {{ n or '' }} - - {{ wk }} +
+
+
+

Leads per status

+
+ {% for key, label, n in pipeline %} + + {{ label }} + + {{ n }} + + {% endfor %}
- {% endfor %} -
+
-
-

Bronnen

- {% if sources %} -
- {% for s in sources %} -
- {{ s.s }} - - {{ s.n }} +
+
+

Leads per week

+
+ {% for wk, n in weekly %} +
+ {{ n or '' }} + + {{ wk }} +
+ {% endfor %}
- {% endfor %} -
- {% else %}

Nog geen leads.

{% endif %} +
+
+
+
+

Bronnen

+ {% if sources %} +
+ {% for s in sources %} +
+ {{ s.s }} + + {{ s.n }} +
+ {% endfor %} +
+ {% else %}

Nog geen leads.

{% endif %} +
-
-
-

Laatste leads

- {% if recent %} -
- - - {% for c in recent %} - - - - - - - +
+
+
+

Laatste leads

+ {% if recent %} +
NaamBronToegewezenStatusBinnen
+ + + {% for c in recent %} + + + + + + + + {% endfor %} + +
NaamBronToegewezenStatusBinnen
+ {% else %}

Nog geen contacten. Voeg er een toe of koppel een formulier via Instellingen.

{% endif %} +
+
+
+
+

Mijn taken

+ {% for t in tasks %} +
+ +
{{ t.title }}
+
{% if t.contact_id %}{{ t.contact_name }}{% endif %} + {% if t.due_date %}· {{ t.due_date|dt('%d-%m-%Y') }}{% endif %}
+
+ {% else %} +

Geen openstaande taken.

{% endfor %} - -
- {% else %}

Nog geen contacten. Voeg er een toe of koppel een formulier via Instellingen.

{% endif %} -
-
-
-

Mijn taken

- {% for t in tasks %} -
- -
{{ t.title }}
-
{% if t.contact_id %}{{ t.contact_name }}{% endif %} - {% if t.due_date %}· {{ t.due_date|dt('%d-%m-%Y') }}{% endif %}
-
- {% else %} -

Geen openstaande taken.

- {% endfor %} -
-
-

Ongelezen e-mail

- {% for e in mails %} -

{{ e.subject }}
{{ e.from_name or e.from_addr }} · {{ e.date|dt }}

- {% else %} -

Geen ongelezen berichten.

- {% endfor %} - Naar inbox -
+
+
+

Ongelezen e-mail

+ {% for e in mails %} +

{{ e.subject }}
{{ e.from_name or e.from_addr }} · {{ e.date|dt }}

+ {% else %} +

Geen ongelezen berichten.

+ {% endfor %} + Naar inbox +
{% endblock %} diff --git a/templates/email.html b/templates/email.html index a4ba2f5..c88a9bb 100644 --- a/templates/email.html +++ b/templates/email.html @@ -5,35 +5,35 @@
← Inbox

{{ e.subject }}

-
+
{% if e.direction == 'in' %}Van {{ e.from_name }} <{{ e.from_addr }}>{% else %}Aan {{ e.to_addr }}{% endif %} · {{ e.date|dt }}
{% if contact %} - Naar {{ contact.name }} + Naar {{ contact.name }} {% elif e.direction == 'in' %} -
+
{% endif %}
-
{{ e.body }}
+
{{ e.body }}
{% if e.direction == 'in' %} -
+

Beantwoorden

{% if contact %}{% endif %} -
-
-
-
+
+
+
+
-
+
-
+
{% endif %} {% endblock %} diff --git a/templates/import.html b/templates/import.html index 4321d8c..ef233ab 100644 --- a/templates/import.html +++ b/templates/import.html @@ -2,27 +2,31 @@ {% block title %}Importeren{% endblock %} {% block content %}
Gegevens

Importeren

-
-
-

Bestand kiezen

-
-
- +
+
+
+

Bestand kiezen

+
+
+ +
+
-
-
-
- -
-

Formaten

-

CSV: scheidingsteken ;, , of tab, de eerste regel met kolomnamen. Regels die beginnen met # worden overgeslagen. Bedragen als 6,00, 1.234,50 of € 6.

-

JSON: een lijst met objecten, of een object met een lijst erin, zoals {"leads": [{"naam": "…", "email": "…"}]}.

-

Configurator: kosten/leveranciers.csv en intern.csv worden als producten herkend (code, omschrijving, eenheid, prijs, leverancier). Een woningontwerp (house-design-JSON) upload je bij een contact onder Bestanden.

-

Je ziet eerst een voorbeeld en kiest per kolom waar die heen moet. Bestaande contacten (zelfde e-mail) en producten (zelfde SKU) worden bijgewerkt in plaats van dubbel aangemaakt. Elke productwijziging komt in de mutatiehistorie; offertes houden hun eigen prijs.

+
+
+
+
+
+

Formaten

+

CSV: scheidingsteken ;, , of tab, de eerste regel met kolomnamen. Regels die beginnen met # worden overgeslagen. Bedragen als 6,00, 1.234,50 of € 6.

+

JSON: een lijst met objecten, of een object met een lijst erin, zoals {"leads": [{"naam": "…", "email": "…"}]}.

+

Configurator: kosten/leveranciers.csv en intern.csv worden als producten herkend (code, omschrijving, eenheid, prijs, leverancier). Een woningontwerp (house-design-JSON) upload je bij een contact onder Bestanden.

+

Je ziet eerst een voorbeeld en kiest per kolom waar die heen moet. Bestaande contacten (zelfde e-mail) en producten (zelfde SKU) worden bijgewerkt in plaats van dubbel aangemaakt. Elke productwijziging komt in de mutatiehistorie; offertes houden hun eigen prijs.

+
{% endblock %} diff --git a/templates/import_preview.html b/templates/import_preview.html index 4504f7a..0396f3a 100644 --- a/templates/import_preview.html +++ b/templates/import_preview.html @@ -7,17 +7,18 @@
← Ander bestand

{{ filename }}

{{ total }} rij(en) · {{ {'contacts': 'leads / contactpersonen', 'relations': 'relaties', 'products': 'producten'}[kind] }}
-
+
-
+

Kolommen koppelen

-
+

Schuif de tabel opzij voor de andere kolommen.

+
{% for col in cols %} @@ -30,30 +31,30 @@
{{ col }}
- {% for k, l in targets %}{% endfor %}
{% if total > rows|length %}

Eerste {{ rows|length }} van {{ total }} rijen.

{% endif %} -
+
-
+

Opties

-
+
{% if kind == 'contacts' %} -
-
-
-
+
+
+
+
{% endif %} {% if kind == 'relations' %} -
-
- {% for k, l in RELATION_KINDS %}{% endfor %}
+
+
{% endif %} {% if kind == 'products' %} -
+

Alleen als er een kolom aan SKU gekoppeld is. Inactieve producten blijven bestaan, met hun mutaties en offertes.

{% endif %} -
+
-
+
{% endblock %} diff --git a/templates/inbox.html b/templates/inbox.html index 24c7f42..a41d581 100644 --- a/templates/inbox.html +++ b/templates/inbox.html @@ -2,10 +2,9 @@ {% block title %}Inbox{% endblock %} {% block content %}
-
E-mail

Inbox

+
E-mail

Inbox

{% if last_sync %}
Laatst opgehaald: {{ last_sync|dt }}
{% endif %}
- {% if last_sync %}Laatst opgehaald: {{ last_sync|dt }}{% endif %} -
+
@@ -19,21 +18,21 @@ Niet gekoppeld aan contact
-
+
{% if rows %} -
+
{% for e in rows %} - - - - + + + + {% endfor %}
VanOnderwerpContactDatum
{% else %}

Geen berichten.

{% endif %} -
+
{% endblock %} diff --git a/templates/portal.html b/templates/portal.html index a799fbe..340b897 100644 --- a/templates/portal.html +++ b/templates/portal.html @@ -1,70 +1,73 @@ {% extends 'base.html' %} {% block title %}Klantportaal{% endblock %} {% block body %} -
-
-
+
{% with msgs = get_flashed_messages(with_categories=true) %} {% for cat, m in msgs %}
{{ m }}
{% endfor %} {% endwith %}
Klantportaal

Welkom, {{ c.name }}

-
+

Uw offertes

{% if quotes %} -
+
{% for qt in quotes %} - - - - - + + + + + {% endfor %}
NummerOnderwerpDatumStatusTotaal incl. btw
{% else %}

Er staan nog geen offertes voor u klaar.

{% endif %} -
+
-
-
-

Bestanden

- {% if files %} - - {% endif %} -
- - -
-
+
+
+
+

Bestanden

+ {% if files %} + + {% endif %} +
+ + +
+
+
-
-

Bericht sturen

-
- -
-
- {% for m in messages %} -

{{ m.created_at|dt }}
{{ m.body[8:] }}

- {% endfor %} +
+
+

Bericht sturen

+
+ +
+
+ {% for m in messages %} +

{{ m.created_at|dt }}
{{ m.body[8:] }}

+ {% endfor %} +
{% if s.company_phone or s.company_email %} diff --git a/templates/product_form.html b/templates/product_form.html index 628e294..95537ba 100644 --- a/templates/product_form.html +++ b/templates/product_form.html @@ -4,61 +4,61 @@
← Producten

{{ 'Product bewerken' if p.id else 'Nieuw product' }}

-
-
-
-
+
+
+
+

Vaste sleutel: imports en de API werken het product met deze SKU bij.

-
+
{% for r in suppliers %}

{% if p.supplier_id %}Naar partner · {% endif %}Een nieuwe naam wordt een partner van het type Leverancier.

-
-
-
-
+
+
+
-
+

Wordt standaard onder de productnaam op de offerte gezet.

-
- - {% if p.image %}{% endif %} - +
+ + {% if p.image %}{% endif %} +
-
+
- - Annuleren + + Annuleren
- +
{% if p.id %} -
+

Mutaties

-

Bestaande offertes houden hun eigen prijs; wijzigingen gelden voor nieuwe offerteregels.{% if used %} Gebruikt in {{ used }} offerte(s).{% endif %}

+

Bestaande offertes houden hun eigen prijs; wijzigingen gelden voor nieuwe offerteregels.{% if used %} Gebruikt in {{ used }} offerte(s).{% endif %}

{% if history %} -
+
{% for h in history %} - - - {% if h.field == 'price' %} - {% elif h.field == 'created' %} - {% elif h.field == 'active' %} - {% else %}{% endif %} - + + + {% if h.field == 'price' %} + {% elif h.field == 'created' %} + {% elif h.field == 'active' %} + {% else %}{% endif %} + {% endfor %}
DatumVeldOudNieuwBron
{{ h.created_at|dt }}{{ LABELS.get(h.field, h.field) }}{{ h.old_value|float|euro }}{{ h.new_value|float|euro }} - {% set diff = h.new_value|float - h.old_value|float %}{% if h.old_value|float %}({{ '+' if diff > 0 }}{{ (diff / h.old_value|float * 100)|round(1)|nr }}%){% endif %}{{ h.new_value|float|euro }}{{ 'ja' if h.old_value == '1' else 'nee' }}{{ 'ja' if h.new_value == '1' else 'nee' }}{{ h.old_value|truncate(60) }}{{ h.new_value|truncate(60) }}{{ h.source }}{% if h.user_name %} · {{ h.user_name }}{% endif %}{{ h.created_at|dt }}{{ LABELS.get(h.field, h.field) }}{{ h.old_value|float|euro }}{{ h.new_value|float|euro }} + {% set diff = h.new_value|float - h.old_value|float %}{% if h.old_value|float %}({{ '+' if diff > 0 }}{{ (diff / h.old_value|float * 100)|round(1)|nr }}%){% endif %}{{ h.new_value|float|euro }}{{ 'ja' if h.old_value == '1' else 'nee' }}{{ 'ja' if h.new_value == '1' else 'nee' }}{{ h.old_value|truncate(60) }}{{ h.new_value|truncate(60) }}{{ h.source }}{% if h.user_name %} · {{ h.user_name }}{% endif %}
{% else %}

Nog geen mutaties.

{% endif %} -
+
- +
{% endif %} {% endblock %} diff --git a/templates/products.html b/templates/products.html index 40bd24a..49fde58 100644 --- a/templates/products.html +++ b/templates/products.html @@ -6,11 +6,11 @@
@@ -19,26 +19,26 @@ {% endfor %} {{ rows|length }} product(en)
-
+
{% if rows %} -
- +
NaamSKULeverancierEenheidPrijs excl.BtwBijgewerktStatus
+ {% for p in rows %} - - - - - - - - - + + + + + + + + + {% endfor %}
NaamSKULeverancierEenheidPrijs excl.BtwBijgewerktStatus
{% else %}

{{ 'Geen producten gevonden.' if term or show != 'active' else 'Nog geen producten. Voeg ze toe of importeer een CSV; daarna kies je ze in offertes.' }}

{% endif %} -
+
{% endblock %} diff --git a/templates/quote_edit.html b/templates/quote_edit.html index 5973e46..4eec9de 100644 --- a/templates/quote_edit.html +++ b/templates/quote_edit.html @@ -2,8 +2,8 @@ {% block title %}Offerte {{ qt.number }}{% endblock %} {% macro row(l) %} - - {% for p in products %}
-
+

Regels

-
+
{% for l in lines %}{{ row(l) }}{% endfor %}
ProductOmschrijvingAantalEenheidPrijs excl.BtwTotaal
-
+
Subtotaal
Totaal
-
+
-
- - -
+
+ + +
+
+
+

Bestek uit ontwerp

+
+ {% if bestek %} + Bekijken + {% if designs %}Opnieuw berekenen{% endif %} +
+ {% elif designs %} + Bestek maken + {% endif %} +
+
+ {% if bestek %} +

{{ bestek.design.title }} · berekend {{ bestek.computedAt[:19]|dt }} · {{ bestek.chapters|length }} hoofdstukken · + {{ bestek.totals.exBtw|euro }} excl. btw ({{ bestek.totals.inclBtw|euro }} incl.). Het bestek staat als bijlage bij de offerte, ook in het klantportaal.

+ {% elif designs %} +

Hoeveelheden en prijzen uit het gekozen model van de klant (gevel, dak, kozijnen, stopcontacten, leidingen, …) als offerteregels en bijlage.

+ {% else %} +

Kies (en sla op) een klant met een gekozen model om een bestek uit het ontwerp te maken.

+ {% endif %} +
+
- +
diff --git a/templates/quote_print.html b/templates/quote_print.html index e5e4627..2131057 100644 --- a/templates/quote_print.html +++ b/templates/quote_print.html @@ -40,6 +40,13 @@ .notes { margin-top: 32px; } .sign { margin-top: 40px; display: flex; gap: 48px; } .sign div { flex: 1; border-top: 1px solid rgba(0,0,0,.3); padding-top: 6px; color: #5c6670; font-size: 12px; } + .bestek { break-before: page; margin-top: 48px; } + .bestek h2 { font-family: "Kantumruy Pro", sans-serif; font-weight: 500; font-size: 15px; margin: 22px 0 4px; display: flex; justify-content: space-between; border-bottom: 2px solid #212121; padding-bottom: 3px; } + .bestek table { width: 100%; border-collapse: collapse; } + .bestek td { padding: 4px 8px 4px 0; border-bottom: 1px solid rgba(0,0,0,.08); vertical-align: top; } + .bestek tr { break-inside: avoid; } + .bestek .info td { color: #5c6670; font-style: italic; } + .bestek .intro { color: #5c6670; font-size: 13px; } footer { margin-top: 40px; padding-top: 12px; border-top: 1px solid rgba(0,0,0,.08); color: #5c6670; font-size: 12px; text-align: center; } @media print { body { background: #fff; } @@ -124,6 +131,22 @@ {% if s.quote_footer %}
{{ s.quote_footer }}
{% endif %} {{ [('KvK ' ~ s.company_kvk) if s.company_kvk, ('Btw ' ~ s.company_btw) if s.company_btw, ('IBAN ' ~ s.company_iban) if s.company_iban]|select|join(' · ') }} + + {% if bestek %} +
+

Bestek{% if bestek.design.title %}: {{ bestek.design.title }}{% endif %}

+

Bijlage bij offerte {{ qt.number }}. Omschrijving en hoeveelheden van de woning volgens het ontwerp{% if bestek.units %} + ({{ bestek.units }} module{{ 's' if bestek.units != 1 }}){% endif %}, berekend op {{ bestek.computedAt[:19]|dt('%d-%m-%Y') }}. Hoeveelheden zijn netto + volgens tekening; de prijzen staan in de offerte.

+ {% for ch in bestek.chapters %} +

{{ loop.index }}. {{ ch.name }}

+ + {% for l in ch.lines %}{% endfor %} + {% for l in ch.info %}{% endfor %} +
{{ l.label }}{{ l.qty|nr }} {{ l.unit }}
{{ l.label }}{{ l.qty|nr }} {{ l.unit }}
+ {% endfor %} +
+ {% endif %}
diff --git a/templates/quotes.html b/templates/quotes.html index 468c9f1..71ef876 100644 --- a/templates/quotes.html +++ b/templates/quotes.html @@ -3,29 +3,31 @@ {% block content %}
Verkoop

Offertes

-
+
+
+
Alle {% for k, l in QUOTE_STATUSES %}{{ l }}{% endfor %}
-
+
{% if rows %} -
+
{% for qt in rows %} - - - - - - + + + + + + {% endfor %}
NummerKlantOnderwerpDatumStatusTotaal incl.
{% else %}

Nog geen offertes.

{% endif %} -
+
{% endblock %} diff --git a/templates/relation.html b/templates/relation.html index 671e77b..a97181a 100644 --- a/templates/relation.html +++ b/templates/relation.html @@ -11,16 +11,16 @@ {% if r.kind == 'klant' and members %}
- +
{% endif %} - Bewerken + Bewerken
-
-
-
+
+
+

Contactpersonen

{% for m in members %} @@ -28,9 +28,9 @@
{{ m.name }} {% if m.relation_role %}· {{ m.relation_role }}{% endif %} -
{{ [m.email, m.phone]|select|join(' · ') }}
+
{{ [m.email, m.phone]|select|join(' · ') }}
-
+
{% if m.type != 'partner' %}{{ STATUS.get(m.status) }}{% endif %}
@@ -41,41 +41,63 @@

Nog geen contactpersonen.

{% endfor %}
- + {% if candidates %} - - - +
+ +
+
+
{% endif %} - Nieuwe contactpersoon + -
+
+ + {% if designs %} +
+

Ontwerpen en tekeningen

+
+ + + {% for d in designs %}{% set p = pdfs.get(d.id, {}) %} + + + + + + + {% endfor %} + +
OntwerpContactTekeningenOntvangen
{{ d.title }}{% if d.chosen %} Gekozen model{% endif %}{{ d.contact_name }}{% for k, label in PDF_KINDS.items() %}{% if p.get(k) %}{{ (label[:1]|upper) ~ label[1:] }}{% if not loop.last %} · {% endif %}{% endif %}{% endfor %} + {% if not p %}Wordt gemaakt bij openen van het contact{% endif %}{{ d.created_at|dt('%d-%m-%Y') }}
+
+ {% endif %} {% if r.kind == 'partner' %} -
+

Producten van deze leverancier

{% if products %} -
+
{% for p in products %} - - - - + + + + {% endfor %}
SKUNaamPrijs excl.Bijgewerkt
{% else %}

Nog geen producten gekoppeld. Vul deze naam in als leverancier bij een product of in een import.

{% endif %} -
+
{% endif %} -
+

Recente activiteit

    {% for a in activities %} @@ -87,11 +109,11 @@
  • Nog geen activiteit.
  • {% endfor %}
-
+
-
-
+
+

Gegevens

{% if r.type %}
Type
{{ r.type }}
{% endif %} @@ -103,10 +125,10 @@
Aangemaakt
{{ r.created_at|dt('%d-%m-%Y') }}
{% if r.notes %}

Notities

{{ r.notes }}

{% endif %} -
+
{% if tasks %} -
+

Open taken

{% for t in tasks %}
@@ -114,11 +136,11 @@
{{ t.title }}
{{ t.contact_name }}{% if t.due_date %} · {{ t.due_date|dt('%d-%m-%Y') }}{% endif %}
{% endfor %} -
+
{% endif %} {% if r.kind == 'klant' %} -
+

Offertes

{% for qt in quotes %}

{{ qt.number }} {{ QSTATUS.get(qt.status) }}
@@ -126,7 +148,7 @@ {% else %}

Nog geen offertes.

{% endfor %} -
+
{% endif %}
diff --git a/templates/relation_form.html b/templates/relation_form.html index 03eec2e..01ac9e0 100644 --- a/templates/relation_form.html +++ b/templates/relation_form.html @@ -7,33 +7,33 @@

{{ 'Relatie bewerken' if r.id else ('Nieuwe ' ~ RKIND.get(r.kind, 'relatie')|lower) }}

-
-
-
-
- +
+
+
+
+
-
- +
+ {% for t in types %}
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
- - Annuleren + + Annuleren
- +
{% if r.id %}
- +
{% endif %} {% endblock %} diff --git a/templates/relations.html b/templates/relations.html index 7b47eb8..46eddcc 100644 --- a/templates/relations.html +++ b/templates/relations.html @@ -11,10 +11,10 @@ - Importeren - {{ 'Nieuwe klant' if kind == 'klant' else 'Nieuwe partner' }} + Importeren + {{ 'Nieuwe klant' if kind == 'klant' else 'Nieuwe partner' }}
@@ -25,23 +25,23 @@ {% endfor %}
-
+
{% if rows %} -
+
{% if kind == 'partner' %}{% endif %} {% for r in rows %} - - - - - - {% if kind == 'partner' %}{% endif %} + + + + + + {% if kind == 'partner' %}{% endif %} {% endfor %}
NaamTypeContactpersonenPlaatsContactProducten
{% else %}

Nog geen {{ 'klantrelaties. Een lead die je omzet naar klant komt hier vanzelf.' if kind == 'klant' else 'partners. Leveranciers uit een productimport komen hier vanzelf.' }}

{% endif %} -
+
{% endblock %} diff --git a/templates/settings.html b/templates/settings.html index fb5cdac..dcfb1e5 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -1,16 +1,16 @@ {% extends 'base.html' %} {% block title %}Instellingen{% endblock %} {% macro field(key, label, type='text', placeholder='', full=false) %} -
+
{% endmacro %} {% block content %}
Beheer

Instellingen

-
+

Bedrijfsgegevens

-

Deze gegevens staan op de offertes.

-
+

Deze gegevens staan op de offertes.

+
{{ field('company_name', 'Bedrijfsnaam', full=true) }} {{ field('company_address', 'Adres') }} {{ field('company_postcode_city', 'Postcode en plaats') }} @@ -21,66 +21,66 @@ {{ field('company_btw', 'Btw-nummer') }} {{ field('company_iban', 'IBAN') }}
-
+
-
+

Offertes

-
+
{{ field('quote_prefix', 'Voorvoegsel nummer', placeholder='OFF') }} {{ field('quote_valid_days', 'Standaard geldig (dagen)', 'number') }} -
-
+
+
-
+
-
+

E-mail koppelen

-

Gebruik voor Gmail of Microsoft 365 een app-wachtwoord. Wachtwoorden staan in de database: houd de map data/ privé.

+

Gebruik voor Gmail of Microsoft 365 een app-wachtwoord. Wachtwoorden staan in de database: houd de map data/ privé.

Ontvangen (IMAP)

-
+
{{ field('imap_host', 'Server', placeholder='imap.gmail.com') }} {{ field('imap_port', 'Poort', 'number', '993') }} {{ field('imap_user', 'Gebruikersnaam', placeholder='info@vanemous.nl') }} -
+
{{ field('imap_folder', 'Map', placeholder='INBOX') }} {{ field('imap_days', 'Aantal dagen terug ophalen', 'number', '30') }}

Versturen (SMTP)

-
+
{{ field('smtp_host', 'Server', placeholder='smtp.gmail.com') }} {{ field('smtp_port', 'Poort', 'number', '587') }} {{ field('smtp_user', 'Gebruikersnaam', placeholder='leeg = zelfde als IMAP') }} -
+
{{ field('smtp_from', 'Afzender', placeholder='Van Emous ', full=true) }}
-
+
-
+

Formulieren en API

-
+
{{ field('allowed_origins', 'Toegestane websites (komma-gescheiden)', placeholder='https://www.vanemous.nl', full=true) }} -
- {% for u in USERS %}{% endfor %}
-
+
-
+

Relatietypes

-

Komma-gescheiden. Bestaande relaties houden hun type als je er een weghaalt.

-
-
-
+

Komma-gescheiden. Bestaande relaties houden hun type als je er een weghaalt.

+
+
+
-
+
-
+
-
+

API-koppeling

Endpoint
POST {{ api_url }}
@@ -123,7 +123,7 @@ const { ok, id } = await res.json();{% endfilter %}
- +
-
+
{% endblock %} diff --git a/templates/user_form.html b/templates/user_form.html index 7566abd..d086686 100644 --- a/templates/user_form.html +++ b/templates/user_form.html @@ -4,21 +4,21 @@
← Gebruikers

{{ 'Gebruiker bewerken' if u.id else 'Nieuwe gebruiker' }}

-
-
-
-
-
- +
+
+
+
+
+
-
- +
+ {% if u.id %}

Leeg laten om het huidige wachtwoord te houden.

{% endif %}
-
+
- - Annuleren + + Annuleren
- +
{% endblock %} diff --git a/templates/users.html b/templates/users.html index 7a82592..82d9d42 100644 --- a/templates/users.html +++ b/templates/users.html @@ -3,23 +3,23 @@ {% block content %}
Team

Gebruikers

- Nieuwe gebruiker +
-
-
+
+
{% for u in rows %} - - - - - + + + + + {% endfor %}
NaamE-mailRolContactenStatus
-
+

Beheerders kunnen ook gebruikers en instellingen beheren. Klanten krijgen toegang via het klantportaal (op de pagina van het contact).

{% endblock %} diff --git a/templates/viewer.html b/templates/viewer.html new file mode 100644 index 0000000..eb752ac --- /dev/null +++ b/templates/viewer.html @@ -0,0 +1,124 @@ + + + + + + {# De engine verwijst relatief naar assets/ en kosten/: base = de gekopieerde configurator. #} + + {{ name }} · Van Emous CRM + + + + + + +
+
+
+ +
+
+ + + +
+
+
+ + + + +
+ + +
+
+ +
+
+
Ontwerp opbouwen…
+
+
+
+
+{% if engine %} + + +{% for src in scripts %} +{% endfor %} +{% else %} + +{% endif %} + + diff --git a/tools/configurator-sync.sh b/tools/configurator-sync.sh new file mode 100755 index 0000000..d801d2c --- /dev/null +++ b/tools/configurator-sync.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Kopieert de 3D-engine van de Tectum-configurator (../configurator) naar static/configurator/, voor de +# viewer van gekozen modellen (templates/viewer.html, static/viewer.js). Zelfde werkwijze als +# tools/configurator-sync.sh van de website; alleen wat de engine nodig heeft (js, assets, kosten). +# Gebruik: tools/configurator-sync.sh [pad-naar-configurator] [doelmap] +cd "$(dirname "$0")/.." || exit 1 +SRC="${1:-../configurator}" +DEST="${2:-static/configurator}" +[ -f "$SRC/js/house/house-model.js" ] || { echo "Geen configurator gevonden in $SRC" >&2; exit 1; } +mkdir -p "$DEST" +rsync -a --delete \ + --include='/LICENTIE.md' --include='/assets/***' --include='/js/***' --include='/kosten/***' \ + --exclude='*' \ + "$SRC/" "$DEST/" +echo "Configurator-engine gekopieerd naar $DEST/ ($(du -sh "$DEST" | cut -f1))" diff --git a/tools/export-models.js b/tools/export-models.js new file mode 100644 index 0000000..1cae602 --- /dev/null +++ b/tools/export-models.js @@ -0,0 +1,55 @@ +#!/usr/bin/env node +// Exporteert de standaardmodellen van de configurator (HouseModel.MODELS) als house-design-JSON, +// precies zoals Bestand › Opslaan in de configurator ze wegschrijft. +// Gebruik: node tools/export-models.js [pad-naar-configurator] [uitvoermap] +// Standaard: ../configurator → demo/modellen/ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const root = path.dirname(__dirname); +const SRC = path.resolve(process.argv[2] || path.join(root, '..', 'configurator')); +const OUT = path.resolve(process.argv[3] || path.join(root, 'demo', 'modellen')); + +// De rekenmodules in de volgorde van light.html; alleen wat nodig is om een model op te bouwen. +const SCRIPTS = [ + 'js/vendor/three.min.js', 'js/core/geometry.js', 'js/core/view-layers.js', 'js/wall/kozijn-model.js', + 'js/core/ui-kit.js', 'js/wall/wall-model.js', 'js/floor/floor-model.js', 'js/roof/roof-model.js', 'js/roof/post-model.js', + 'js/lighting/lighting-model.js', 'js/elektra/elektra-model.js', 'js/sandwich/sandwich-model.js', 'js/wall/wall.js', 'js/floor/floor.js', + 'js/unit/unit-model.js', 'js/room/room-model.js', 'js/interior/fixture-model.js', + 'js/house/chimney-model.js', 'js/house/house-model.js' +]; + +// Minimale browseromgeving: de modules raken het DOM alleen bij het tekenen, niet bij het opbouwen. +const noop = () => {}; +const el = () => new Proxy(function () {}, { get: (t, k) => (k === Symbol.toPrimitive ? () => '' : el()), apply: () => el(), construct: () => el() }); +const ctx = { console, Math, JSON, Date, setTimeout, clearTimeout, URL, Blob: function () {}, Image: function () { return el(); } }; +ctx.window = ctx; ctx.self = ctx; ctx.globalThis = ctx; +ctx.document = { createElement: () => el(), getElementById: () => null, querySelector: () => null, querySelectorAll: () => [], addEventListener: noop, head: el(), body: el(), currentScript: null }; +ctx.navigator = { userAgent: 'node', platform: 'node', maxTouchPoints: 0 }; +ctx.location = { href: 'http://localhost/', search: '' }; +ctx.fetch = () => Promise.reject(new Error('geen fetch')); +ctx.addEventListener = noop; +vm.createContext(ctx); +for (const s of SCRIPTS) { + const file = path.join(SRC, s); + // const/let op het hoogste niveau blijven in het script; zet de modulenamen expliciet op het globale object. + let code = fs.readFileSync(file, 'utf8'); + const names = [...code.matchAll(/^(?:const|let|var|class)\s+([A-Z][A-Za-z0-9_]*)\s*=?/gm)].map(m => m[1]); + code += '\n' + names.map(n => `try { globalThis.${n} = ${n}; } catch (e) {}`).join('\n'); + vm.runInContext(code, ctx, { filename: file }); +} + +const version = (fs.readFileSync(path.join(SRC, 'js/app.js'), 'utf8').match(/APP_VERSION\s*=\s*['"]([^'"]+)/) || [])[1] || ''; +fs.mkdirSync(OUT, { recursive: true }); +const index = []; +for (const [key, def] of Object.entries(ctx.HouseModel.MODELS)) { + const state = ctx.HouseModel.normalize(ctx.HouseModel.clone(def.create())); + state.project = Object.assign({}, state.project, { naam: state.project.naam || def.label }); + const design = { type: 'house-design', version: 1, exportedAt: new Date().toISOString(), model: key, configurator: version, ...state }; + fs.writeFileSync(path.join(OUT, `${key}.json`), JSON.stringify(design)); + index.push({ key, label: def.label }); + console.log(`${key}.json ${def.label} (${state.units.length} units)`); +} +fs.writeFileSync(path.join(OUT, 'index.json'), JSON.stringify({ configurator: version, models: index }, null, 2) + '\n'); diff --git a/tools/release.sh b/tools/release.sh new file mode 100755 index 0000000..501b23a --- /dev/null +++ b/tools/release.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Een getagde versie van de CRM naar de server brengen (eerst eenmalig server/setup.sh op de server). +# +# tools/release.sh crm@crm.voorbeeld.nl nieuwste tag +# tools/release.sh crm@crm.voorbeeld.nl v0.0.2 een bepaalde versie (ook om terug te rollen) +# +# De code komt uit `git archive ` (precies wat getagd is, niet de werkkopie); de 3D-engine van de +# configurator ($CONFIGURATOR, standaard ../configurator) gaat er met rsync bij, met hardlinks naar de vorige +# versie zodat alleen wijzigingen over de lijn gaan. Op de server maakt server/activate.sh hem actief. +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +HOST="${1:-}" +CONFIGURATOR="${CONFIGURATOR:-$REPO/../configurator}" +ROOT=/srv/vanemous-crm + +say() { printf '\033[38;2;185;89;19m▸\033[0m %s\n' "$*"; } +fail() { printf 'Fout: %s\n' "$*" >&2; exit 1; } + +[ -n "$HOST" ] || fail "gebruik: tools/release.sh crm@server [vX.Y.Z]" +TAG="${2:-$(git -C "$REPO" tag --list 'v*' --sort=-v:refname | head -n1)}" +[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "onbekende versie '$TAG' (verwacht vX.Y.Z)" +git -C "$REPO" rev-parse -q --verify "refs/tags/$TAG" >/dev/null || fail "tag $TAG bestaat niet" +code_version="$(git -C "$REPO" show "$TAG:app.py" | sed -nE "s/^__version__ = '([^']+)'.*/\1/p")" +[ "v$code_version" = "$TAG" ] || fail "tag $TAG bevat __version__ = '$code_version' in app.py" +git -C "$REPO" cat-file -e "$TAG:server/activate.sh" 2>/dev/null || fail "$TAG heeft nog geen server/activate.sh; tag een nieuwere versie" +[ -f "$CONFIGURATOR/js/house/house-model.js" ] || fail "geen configurator in $CONFIGURATOR (zet CONFIGURATOR=pad)" +[ -z "$(git -C "$REPO" status --porcelain)" ] || say "let op: niet-gecommitte wijzigingen zitten niet in $TAG" + +target="$ROOT/releases/$TAG" +if ssh "$HOST" "test -f $target/.installed"; then + say "$TAG staat al op de server" +else + say "$TAG uploaden naar $HOST" + git -C "$REPO" archive "$TAG" | ssh "$HOST" "rm -rf $target && mkdir -p $target && tar -x -C $target" +fi + +say "3D-engine bijwerken uit $CONFIGURATOR" +ssh "$HOST" "mkdir -p $target/static/configurator" +rsync -az --delete --link-dest="$ROOT/current/static/configurator/" \ + --include='/LICENTIE.md' --include='/assets/***' --include='/js/***' --include='/kosten/***' --exclude='*' \ + "$CONFIGURATOR/" "$HOST:$target/static/configurator/" + +ssh "$HOST" "bash $target/server/activate.sh $TAG"