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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-15 11:58:52 +02:00
co-authored by Claude Opus 5.5
parent bffa053c05
commit 7d302dc405
48 changed files with 2769 additions and 698 deletions
+1
View File
@@ -1,3 +1,4 @@
data/ data/
.venv/ .venv/
__pycache__/ __pycache__/
static/configurator/
+78 -8
View File
@@ -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, 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 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. 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/<id>/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. - **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. - **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 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. formaat als de import.
- **Offertes** met regels (product of vrije regel), btw per tarief en een afdrukversie die je als PDF - **Offertes** met regels (product of vrije regel), btw per tarief en een afdrukversie die je als PDF
opslaat via de browser. 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. - **Gebruikers** met de rollen beheerder (ook gebruikers en instellingen) en medewerker.
- **Klantportaal** (`/portal`): de klant krijgt een uitnodigingslink (kopiëren of mailen vanaf het - **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 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 .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 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. 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 ```sh
.venv/bin/pip install gunicorn scp -r server ubuntu@SERVER:
.venv/bin/gunicorn -w 2 -b 127.0.0.1:8000 app:app 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 <tag>` 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 ## Opslag
@@ -79,5 +143,11 @@ importer.py CSV/JSON inlezen en kolommen herkennen
mail.py IMAP/SMTP mail.py IMAP/SMTP
schema.sql database schema.sql database
templates/ pagina's (Jinja) 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.
+375 -24
View File
@@ -2,8 +2,9 @@
Starten: python app.py Starten: python app.py
E-mail ophalen: python app.py sync (bijv. elke 10 minuten via cron) 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 csv
import io import io
@@ -52,6 +53,18 @@ PRODUCT_LABELS = {'name': 'Naam', 'sku': 'SKU', 'description': 'Omschrijving', '
'supplier': 'Leverancier', 'price': 'Prijs', 'vat': 'Btw', 'active': 'Actief', 'created': 'Aangemaakt'} 'supplier': 'Leverancier', 'price': 'Prijs', 'vat': 'Btw', 'active': 'Actief', 'created': 'Aangemaakt'}
INLINE_MIME = {'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'application/pdf'} 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', SETTING_KEYS = ['company_name', 'company_address', 'company_postcode_city', 'company_phone',
'company_email', 'company_website', 'company_kvk', 'company_btw', 'company_iban', 'company_email', 'company_website', 'company_kvk', 'company_btw', 'company_iban',
'quote_prefix', 'quote_valid_days', 'quote_intro', 'quote_footer', 'quote_prefix', 'quote_valid_days', 'quote_intro', 'quote_footer',
@@ -81,6 +94,12 @@ app = Flask(__name__)
app.secret_key = _secret_key() app.secret_key = _secret_key()
app.config.update(MAX_CONTENT_LENGTH=25 * 1024 * 1024, SESSION_COOKIE_SAMESITE='Lax', app.config.update(MAX_CONTENT_LENGTH=25 * 1024 * 1024, SESSION_COOKIE_SAMESITE='Lax',
PERMANENT_SESSION_LIFETIME=timedelta(days=30)) 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(): def now():
@@ -109,7 +128,10 @@ MIGRATIONS = {
'products': {'supplier': "TEXT DEFAULT ''", 'updated_at': 'TEXT', 'products': {'supplier': "TEXT DEFAULT ''", 'updated_at': 'TEXT',
'supplier_id': 'INTEGER REFERENCES relations(id) ON DELETE SET NULL'}, 'supplier_id': 'INTEGER REFERENCES relations(id) ON DELETE SET NULL'},
'contacts': {'relation_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', ?, ?, ?)", "INSERT INTO relations (kind, type, name, created_at, updated_at) VALUES ('partner', 'Leverancier', ?, ?, ?)",
(name, now(), now())).lastrowid (name, now(), now())).lastrowid
con.execute('UPDATE products SET supplier_id=? WHERE supplier=? AND supplier_id IS NULL', (rid, name)) 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.execute('PRAGMA journal_mode=WAL')
con.commit() con.commit()
con.close() con.close()
@@ -337,27 +371,62 @@ def save_upload(fs, contact_id=None):
path = os.path.join(UPLOADS, stored) path = os.path.join(UPLOADS, stored)
fs.save(path) fs.save(path)
if contact_id: if contact_id:
db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) ' cur = db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) '
'VALUES (?, ?, ?, ?, ?, ?)', 'VALUES (?, ?, ?, ?, ?, ?)',
(contact_id, stored, fs.filename[:200], fs.mimetype or '', os.path.getsize(path), now())) (contact_id, stored, fs.filename[:200], fs.mimetype or '', os.path.getsize(path), now()))
if ext == '.json': if ext == '.json':
note_design(contact_id, path) note_design(contact_id, path, cur.lastrowid)
return stored return stored
def note_design(contact_id, path): def read_design(path):
"""Een woningontwerp uit de configurator krijgt een regel in de tijdlijn.""" """Het woningontwerp (house-design-JSON uit de configurator) in een bestand, of None."""
try: try:
with open(path, encoding='utf-8-sig') as f: with open(path, encoding='utf-8-sig') as f:
design = json.load(f) design = json.load(f)
except (ValueError, OSError): 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 return
if isinstance(design, dict) and design.get('type') == 'house-design': project = design.get('project') or {}
project = design.get('project') or {} title = design_title(design)
parts = [f"Woningontwerp: {project.get('naam') or 'zonder naam'}", db().execute('UPDATE files SET title=? WHERE id=?', (title, file_id))
' '.join(filter(None, [project.get('straat'), project.get('nummer'), project.get('plaats')])), db().execute('UPDATE contacts SET design_file_id=?, updated_at=? WHERE id=?', (file_id, now(), contact_id))
project.get('opmerking') or ''] parts = [f'Woningontwerp: {title} (gekozen model)',
log(contact_id, 'file', '\n'.join(p for p in parts if p)) ' '.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): 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: with open(path, 'w', encoding='utf-8') as f:
json.dump(design, f, ensure_ascii=False) json.dump(design, f, ensure_ascii=False)
name = secure_filename(str((design.get('project') or {}).get('naam') or 'ontwerp')) or 'ontwerp' 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 (?, ?, ?, ?, ?, ?)', 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())) (contact_id, stored, f'{name}.json', 'application/json', os.path.getsize(path), now()))
note_design(contact_id, path) note_design(contact_id, path, cur.lastrowid)
def remove_upload(stored): def remove_upload(stored):
@@ -637,8 +706,9 @@ def dashboard():
def contacts(): def contacts():
term = request.args.get('q', '').strip() term = request.args.get('q', '').strip()
args = {k: request.args.get(k, '') for k in ('type', 'status', 'owner')} 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 ' sql = ('SELECT c.*, u.name AS user_name, r.name AS relation_name, r.kind AS relation_kind, f.title AS design_title '
'LEFT JOIN users u ON u.id=c.assigned_user_id LEFT JOIN relations r ON r.id=c.relation_id WHERE 1=1') '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 = [] params = []
if term: if term:
sql += (' AND (c.name LIKE ? OR c.email LIKE ? OR c.company LIKE ? OR c.phone LIKE ? OR c.city LIKE ?' 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 ' 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 ' '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) '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( return render_template(
'contact.html', c=c, extra=json.loads(c['extra'] or '{}'), '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 ' 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), 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 ' 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), '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), 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 '', 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"), 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/<int:fid>/delete') @app.post('/files/<int:fid>/delete')
def file_delete(fid): def file_delete(fid):
f = get_or_404('files', 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']) remove_upload(f['stored'])
db().execute('DELETE FROM files WHERE id=?', (fid,)) 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() db().commit()
return redirect(url_for('contact_view', cid=f['contact_id'])) return redirect(url_for('contact_view', cid=f['contact_id']))
@app.post('/contacts/<int:cid>/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/<int:fid>/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/<int:fid>/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 # ---------------------------------------------------------------- relaties
def relation_form_data(): def relation_form_data():
@@ -969,6 +1114,9 @@ def relation_view(rid):
'WHERE c.relation_id=? ORDER BY c.name', rid) 'WHERE c.relation_id=? ORDER BY c.name', rid)
ids = [m['id'] for m in members] or [0] ids = [m['id'] for m in members] or [0]
marks = ', '.join('?' * len(ids)) 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( return render_template(
'relation.html', r=r, members=members, 'relation.html', r=r, members=members,
candidates=q('SELECT id, name, email FROM contacts WHERE relation_id IS NULL ORDER BY name LIMIT 500'), 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), 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 ' 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), 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/<int:rid>/member') @app.post('/relations/<int:rid>/member')
@@ -1442,6 +1591,8 @@ def quote_new():
if cid: if cid:
log(cid, 'quote', f'Offerte {number} aangemaakt') log(cid, 'quote', f'Offerte {number} aangemaakt')
db().commit() 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)) return redirect(url_for('quote_edit', qid=cur.lastrowid))
@@ -1473,7 +1624,8 @@ def quote_edit(qid):
flash('Offerte opgeslagen.') flash('Offerte opgeslagen.')
return redirect(url_for('quote_edit', qid=qid)) return redirect(url_for('quote_edit', qid=qid))
return render_template( 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), lines=q('SELECT * FROM quote_lines WHERE quote_id=? ORDER BY position', qid),
contacts=q('SELECT id, name, company FROM contacts ORDER BY name'), contacts=q('SELECT id, name, company FROM contacts ORDER BY name'),
products=q('SELECT * FROM products WHERE active=1 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 ' 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 '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), 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/<int:qid>/print') @app.route('/quotes/<int:qid>/print')
@@ -1493,6 +1645,160 @@ def quote_print(qid):
return render_quote(get_or_404('quotes', 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/<int:qid>/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/<int:qid>/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/<int:qid>/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/<int:qid>/delete') @app.post('/quotes/<int:qid>/delete')
def quote_delete(qid): def quote_delete(qid):
get_or_404('quotes', qid) get_or_404('quotes', qid)
@@ -1573,7 +1879,7 @@ def portal_home():
return render_template( return render_template(
'portal.html', c=g.portal, 'portal.html', c=g.portal,
quotes=quotes_with_totals("WHERE qu.contact_id=? AND qu.status<>'concept'", cid), 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:%' " messages=q("SELECT * FROM activities WHERE contact_id=? AND type='portal' AND body LIKE 'Bericht:%' "
"ORDER BY created_at DESC LIMIT 20", cid), "ORDER BY created_at DESC LIMIT 20", cid),
s=settings_dict()) s=settings_dict())
@@ -1626,7 +1932,7 @@ def portal_message():
@app.route('/portal/files/<name>') @app.route('/portal/files/<name>')
def portal_file(name): 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: if f is None:
abort(404) abort(404)
return serve_upload(name, f['original']) return serve_upload(name, f['original'])
@@ -1748,7 +2054,52 @@ def api_products():
return jsonify(ok=True, **result) 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 __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': if len(sys.argv) > 1 and sys.argv[1] == 'sync':
with app.app_context(): with app.app_context():
n, err = do_sync() n, err = do_sync()
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+37
View File
@@ -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"
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Executable
+105
View File
@@ -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 <tag>` (precies wat getagd is, niet de werkkopie) in
# $CRM_HOME/releases/<tag> 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
+61
View File
@@ -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
+25
View File
@@ -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;
}
}
+111
View File
@@ -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 <domein> <e-mail voor Let's Encrypt>"
# --- 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 <<EOF
crm ALL=(root) NOPASSWD: /usr/bin/systemctl restart $APP, /usr/bin/systemctl is-active $APP, /usr/bin/journalctl -u $APP *
EOF
chmod 440 /etc/sudoers.d/$APP
visudo -cf /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 <<EOF
# Van Emous CRM (server/setup.sh)
*/10 * * * * crm [ -d $ROOT/current ] && cd $ROOT/current && CRM_DATA=$ROOT/data .venv/bin/python app.py sync 2>&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"
+26
View File
@@ -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
+209
View File
@@ -0,0 +1,209 @@
/* Bestek uit een woningontwerp (house-design-JSON), voor een offerte.
*
* Rekent met de engine van de configurator (static/configurator/): HouseModel.computeParts zoals de PDF,
* HouseModel.computeTotals en CostModel.compute met de kosten-CSV's. Elke kostenpost (materiaal + arbeid +
* machine-uren, zoals CostModel.costOf) krijgt de algemene kosten en marge erbij en gaat naar een hoofdstuk;
* transport en kraan worden een eigen hoofdstuk. Samen precies het totaal excl. btw van de configurator.
* Productieposten (schroeven, zaagsneden, boorgaten, plaatsen) komen per hoofdstuk samen in één regel
* "Bevestiging en bewerking"; zo heeft elke andere post een zuivere prijs per eenheid.
* Posten met een vaste prijs per eenheid (wcd, toilet, rabat per m², ...) krijgen een product: SKU = de code uit
* de kosten van de configurator (cost-defaults.js / leveranciers.csv), bijv. elektra.wcdDubbel.
*/
(() => {
const root = document.getElementById('bestek');
const $ = (sel) => root.querySelector(sel);
const esc = (s) => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
const nl = (n, d = 0) => n.toLocaleString('nl-NL', { minimumFractionDigits: d, maximumFractionDigits: d });
const eur = (n) => '€ ' + nl(n, 2);
// Hoofdstukken van het bestek; een post gaat naar het hoofdstuk van zijn activiteit (ACT) of anders zijn categorie.
const CHAPTERS = [
['casco', 'Casco en constructie'], ['isolatie', 'Isolatie'], ['gevel', 'Gevelbekleding'],
['dak', 'Dak, dakrand en overkapping'], ['vloer', 'Vloer en fundering'], ['kozijnen', 'Kozijnen, ramen en deuren'],
['afwerking', 'Binnenafwerking'], ['inrichting', 'Inrichting'], ['verlichting', 'Verlichting'], ['elektra', 'Elektra'],
['transport', 'Transport en plaatsing']
];
const BY_CAT = { Hout: 'casco', Staal: 'casco', Schroeven: 'casco', Plaatwerk: 'casco', Isolatie: 'isolatie', Gevel: 'gevel',
Dak: 'dak', Vloer: 'vloer', Kozijnen: 'kozijnen', Afwerking: 'afwerking', Inrichting: 'inrichting', Verlichting: 'verlichting', Elektra: 'elektra' };
const BY_ACT = { Latwerk: 'gevel', Dak: 'dak' };
const chapterOf = (l) => BY_ACT[l.act] || BY_CAT[l.cat] || 'casco';
// Productieposten: wel in de prijs, niet als regel in het bestek.
const hidden = (l) => l.cat === 'Schroeven' || l.unit === 'sneden' || /^(Boorgaten|Plaatsen en vastzetten|Plaatschroeven)/.test(l.label);
// Omschrijving zonder de details per element (aantallen tussen haakjes), zodat gelijke posten samengaan;
// kozijnen houden hun maat en uitvoering.
function clean(l) {
let s = l.label;
if (l.cat !== 'Kozijnen') s = s.replace(/\s*\([^)]*\)/g, '');
return s.replace(/^Regelwerk \d+ st\.$/, 'Regelwerk wanden').trim();
}
const roundQty = (q, unit) => Math.round(q * (unit === 'm³' ? 100 : 10)) / (unit === 'm³' ? 100 : 10);
const HIDDEN_LABEL = { casco: 'Bevestiging en bewerking (schroeven, zaagwerk, plaatsen)', elektra: 'Bevestiging en bewerking (boorwerk door het regelwerk)' };
const slug = (t) => t.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/²/g, '2').replace(/×/g, 'x').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
// Product bij een post: [test, code | (label, regel) => code, naam]. Alleen posten waarvan de prijs per eenheid vast
// is (niet: regelwerk, vochtdoek, latwerk, groepenkast, verdeeldozen; die hangen af van het ontwerp).
const PRODUCT_RULES = [
[/^Wandcontactdozen dubbel/, 'elektra.wcdDubbel', 'Wandcontactdoos dubbel, inbouw'],
[/^Schakelaars/, 'elektra.schakelaar', 'Schakelaar, inbouw'],
[/^Aansluitpunt koken/, 'elektra.perilex', 'Aansluitpunt kookgroep (perilex)'],
[/^Aansluitpunt warmtepomp/, 'elektra.werkschakelaar', 'Aansluitpunt warmtepomp (werkschakelaar)'],
[/^Aansluitpunt /, 'elektra.wcdEnkel', 'Aansluitpunt apparaat (wcd enkel, eigen groep)'],
[/^Vloerdoos/, 'elektra.vloerdoosSt', 'Vloerdoos met dubbele wcd'],
[/^Spotdozen/, 'elektra.spotdoosSt', 'Spotdoos plafond, luchtdicht'],
[/^Kabeldoorvoer/, 'elektra.doorvoer', 'Kabeldoorvoer naar buiten, luchtdicht'],
[/^Steekverbinder module-naad 3/, 'elektra.steekverbinder3', 'Steekverbinder module-naad 3-polig (set)'],
[/^Steekverbinder module-naad 5/, 'elektra.steekverbinder5', 'Steekverbinder module-naad 5-polig (set)'],
[(l) => l.cat === 'Elektra' && l.unit === 'm', (l) => `elektra.leiding.${slug(l.label.split(':')[0])}`, (l) => `Leiding ${l.label.split(':')[0]}, gelegd`],
[/^Gevellampen/, 'verlichting.gevellamp', 'Gevellamp'],
[/^Boeideel-spots/, 'verlichting.boeideelSpot', 'Boeideel-spot'],
[/^Binnenspots/, 'verlichting.binnenspot', 'Binnenspot'],
[/^Voordeur /, 'kozijnen.voordeur', 'Voordeur incl. kozijn'],
[/^Deur /, 'kozijnen.deur', 'Deur incl. kozijn'],
[/^Schuifdeur /, 'kozijnen.schuifdeurBlad', 'Schuifdeur per blad'],
[/^Raam \d+×\d+/, (l) => `kozijnen.raam.${l.label.match(/\d+×\d+/)[0].replace('×', 'x')}`, (l) => `Raamkozijn ${l.label.match(/\d+×\d+/)[0]} mm incl. glas`],
[/^Rabat$/, 'gevel.rabatM2', 'Rabat gevelbekleding'],
[/^Steenstrips$/, 'gevel.steenM2', 'Steenstrips'],
[/^Stucwerk$/, 'gevel.stucM2', 'Stucwerk gevel'],
[(l) => l.cat === 'Gevel' && /^Felsplaten/.test(l.label), 'gevel.felsM2', 'Felsplaten gevel (staande naad)'],
[/^Afdekker trasraam/, 'gevel.afdekkerM', 'Afdekker trasraam'],
[/^Isolatieblok steen/, 'isolatie.steenIsolatieM3', 'Isolatieblok steengevel'],
[/^Glaswol/, 'isolatie.glaswolM3', 'Glaswol isolatie'],
[/^Dakleer/, 'dak.dakleerM2', 'Dakleer (EPDM)'],
[/^Riet$/, 'dak.rietM2', 'Rieten dakbedekking'],
[(l) => l.cat === 'Dak' && /^Felsplaten/.test(l.label), 'dak.felsM2', 'Felsplaten dak (staande naad)'],
[/^Boeideel \d+/, 'dak.boeideelM', 'Boeideel'],
[/^Onderplaat boeideel/, 'dak.onderplaatM2', 'Onderplaat boeideel'],
[/^Daktrim/, 'dak.daktrimM', 'Daktrim'],
[/^Ventilatierooster/, 'dak.ventRoosterM', 'Ventilatierooster onder boeideel'],
[/^Paalvoeten/, 'dak.paalvoet', 'Paalvoet verzinkt, verstelbaar'],
[/^Dakbeschot/, 'dak.beschotM2', 'Dakbeschot OSB 18 mm'],
[/^Spaanplaat$/, 'plaat.spaanplaatM2', 'Spaanplaat'],
[/^Cementplaat$/, 'plaat.cementplaatM2', 'Cementgebonden plaat'],
[/^OSB /, 'plaat.osbM2', 'OSB-plaat'],
[/^Vloerafwerking/, 'vloer.afwerkingM2', 'Vloerafwerking'],
[/vals plafond/, 'afwerking.valsPlafondM2', 'Vals plafond (gipsplaat, afgewerkt)'],
[/FIBO-wandpanelen/, 'afwerking.fiboM2', 'FIBO-wandpaneel']
];
// Inrichting: de code volgt het stuk zelf (FixtureModel: f.cost), de omschrijving is die van CostModel.
function fixtureCodes(parts) {
const map = {};
(parts.fixtures || []).filter(f => f.cost).forEach(f => {
const perM = { keuken: ['Keukenblok', 'keukenM'], kookeiland: ['Kookeiland', 'kookeilandM'], kast: [f.label, 'kastM'] }[f.cost];
map[perM ? `${perM[0]} ${f.w} mm` : f.label] = perM ? perM[1] : f.cost;
if (f.opts && f.opts.kookplaat) map.Inductiekookplaat = 'kookplaat';
});
return map;
}
function productOf(row, fixtures) {
let code = null, name = null;
if (row.cat === 'Inrichting' && fixtures[row.label]) {
code = `inrichting.${fixtures[row.label]}`;
name = row.label.replace(/ \d+ mm$/, '');
} else {
const rule = PRODUCT_RULES.find(([t]) => typeof t === 'function' ? t(row) : t.test(row.label));
if (!rule) return null;
code = typeof rule[1] === 'function' ? rule[1](row) : rule[1];
name = typeof rule[2] === 'function' ? rule[2](row) : rule[2];
}
const [g, k] = code.split('.'), def = (window.COST_DEFAULTS[g] && window.COST_DEFAULTS[g].items[k]) || null;
const sup = CostCsv.entry('leveranciers', code) || {};
return { sku: code, name, description: [def && def.label, 'Prijs uit de configurator: materiaal en montage, incl. opslagen en marge.'].filter(Boolean).join('\n'),
supplier: sup.supplier || '', article: sup.article || '' };
}
function build(st, parts, meta) {
const totals = HouseModel.computeTotals(parts);
const cost = CostModel.compute(parts, totals);
const S = cost.settings, O = S.opslag, A = S.arbeid, B = S.bedrijf;
const perHour = B.machines + B.slijtage + B.huisvesting;
const factor = (1 + O.algemeen / 100) * (1 + O.marge / 100);
// Zelfde opbouw als costOf in CostModel.compute.
const lineCost = (l) => {
const direct = l.sec / 3600, indirect = direct * A.toeslagUren / 100, qa = (direct + indirect) * A.qa / 100;
return l.mat * (1 + (O.verlies + O.klein) / 100) + (direct + indirect + qa) * A.uurloon + l.clock / 3600 * (1 + A.toeslagUren / 100) * perHour;
};
const chapters = new Map(CHAPTERS.map(([key, name]) => [key, { key, name, price: 0, rows: new Map(), info: [] }]));
cost.elements.forEach(el => el.lines.forEach(l => {
const ch = chapters.get(chapterOf(l)), price = lineCost(l) * factor;
ch.price += price;
const label = hidden(l) ? (HIDDEN_LABEL[ch.key] || 'Bevestiging en bewerking') : clean(l);
const unit = hidden(l) ? 'post' : l.unit, key = `${hidden(l) ? '~' : ''}${label}|${unit}`;
const row = ch.rows.get(key) || { label, unit, qty: 0, n: 0, cost: 0, cat: l.cat, hidden: hidden(l) };
row.qty = row.hidden ? 1 : row.qty + l.qty; row.n += 1; row.cost += price;
ch.rows.set(key, row);
}));
const fixtures = fixtureCodes(parts);
const n = parts.units.length, tr = chapters.get('transport');
tr.rows.set('t', { label: `Transport modules naar de bouwplaats`, unit: 'unit', qty: n, n: 1, cost: n * B.transportUnit * factor });
tr.rows.set('k', { label: 'Plaatsen met kraan', unit: 'post', qty: 1, n: 1, cost: B.kraan * factor });
tr.price = (n * B.transportUnit + B.kraan) * factor;
const out = [];
chapters.forEach(ch => {
if (!(ch.price > 0.005)) return;
// Bevestiging en bewerking als laatste regel van het hoofdstuk (daar komt ook het afrondingsverschil).
const rows = Array.from(ch.rows.values()).filter(r => r.qty > 0 && (r.cost > 0.005 || !r.hidden)).sort((a, b) => (a.hidden ? 1 : 0) - (b.hidden ? 1 : 0));
const lines = rows.map(r => {
// Ramen tellen per stuk (de prijs rekent per m²); een raam van een andere maat is een andere post.
const perPiece = r.cat === 'Kozijnen' && r.unit === 'm²';
const q0 = roundQty(r.qty, r.unit), qty = perPiece ? r.n : q0 > 0 ? q0 : Math.round(r.qty * 1000) / 1000 || 1;
const line = { label: r.label, qty, unit: perPiece ? 'st.' : r.unit, unitPrice: Math.round(r.cost / qty * 10000) / 10000, price: Math.round(r.cost * 100) / 100 };
const product = r.hidden ? null : productOf(r, fixtures);
if (product) line.product = product;
return line;
});
if (ch.key === 'elektra') {
const m = rows.filter(r => r.unit === 'm').reduce((t, r) => t + r.qty, 0);
if (m > 0) ch.info.push({ label: 'Totaal leidingwerk (buis en kabel)', qty: roundQty(m, 'm'), unit: 'm' });
}
out.push({ key: ch.key, name: ch.name, price: Math.round(ch.price * 100) / 100, lines, info: ch.info });
});
const sum = out.reduce((t, c) => t + c.price, 0);
if (Math.abs(sum - cost.summary.exBtw) > 1) console.warn('Bestek wijkt af van de configurator', sum, cost.summary.exBtw);
return Object.assign({ version: 1, computedAt: new Date().toISOString(), vat: O.btw, chapters: out,
totals: { exBtw: Math.round(cost.summary.exBtw * 100) / 100, btw: Math.round(cost.summary.btw * 100) / 100, inclBtw: Math.round(cost.summary.inclBtw * 100) / 100 },
units: n }, meta);
}
function render(b) {
$('.bs-body').innerHTML = b.chapters.map(ch => `
<section class="bs-ch">
<div class="bs-ch-head"><h2>${esc(ch.name)}</h2><b>${eur(ch.price)}</b></div>
<table><tbody>${ch.lines.map(l => `<tr><td>${esc(l.label)}${l.product ? ` <span class="chip" title="Product ${esc(l.product.name)}">${esc(l.product.sku)}</span>` : ''}</td>
<td class="num">${nl(l.qty, l.qty % 1 ? (l.unit === 'm³' ? 2 : 1) : 0)} ${esc(l.unit)}</td><td class="num muted">× ${eur(l.unitPrice)}</td><td class="num">${eur(l.price)}</td></tr>`).join('')}
${ch.info.map(l => `<tr class="bs-info"><td>${esc(l.label)}</td><td class="num">${nl(l.qty, 1)} ${esc(l.unit)}</td><td></td><td></td></tr>`).join('')}</tbody></table>
</section>`).join('');
$('.bs-total').innerHTML = `<div><span>Totaal excl. btw</span><b>${eur(b.totals.exBtw)}</b></div>
<div><span>Btw ${nl(b.vat)}%</span><b>${eur(b.totals.btw)}</b></div>
<div class="grand"><span>Totaal incl. btw</span><b>${eur(b.totals.inclBtw)}</b></div>`;
root.querySelector('[name=bestek]').value = JSON.stringify(b);
root.querySelector('button[type=submit]').disabled = false;
}
let st, parts;
try {
const design = JSON.parse(document.getElementById('design').textContent);
st = HouseModel.normalize(HouseModel.clone(design));
parts = HouseModel.computeParts(st, {
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials(),
elektraRough: false
});
} catch (err) {
console.error(err);
$('.bs-body').innerHTML = `<p class="empty">Dit ontwerp kan niet worden doorgerekend: ${esc(err.message)}</p>`;
return;
}
const meta = JSON.parse(document.getElementById('bestek-meta').textContent);
const run = () => {
try { render(build(st, parts, meta)); } catch (err) {
console.error(err);
$('.bs-body').innerHTML = `<p class="empty">Het bestek kon niet worden berekend: ${esc(err.message)}</p>`;
}
};
// Prijzen komen uit kosten/*.csv (async geladen bij het starten van de engine); daarna rekenen.
let done = false;
const once = () => { if (!done) { done = true; run(); } };
if (CostCsv.status('intern').loadedAt) once();
else { CostCsv.onChange(once); setTimeout(once, 6000); }
CostModel.onChange(() => { if (done) run(); });
})();
+198 -110
View File
@@ -1,4 +1,6 @@
/* Van Emous huisstijl — tokens uit het design system */ /* Van Emous huisstijl bovenop Bootstrap 5.3 — tokens uit het design system.
Layout, grid, formulieren, tabellen en het mobiele menu komen uit Bootstrap;
hier staan alleen de merkkleuren en de onderdelen die Bootstrap niet heeft. */
:root { :root {
--orange: #b95913; --orange: #b95913;
--orange-dark: #a24e11; --orange-dark: #a24e11;
@@ -22,77 +24,154 @@
--radius-md: 1rem; --radius-md: 1rem;
--radius-lg: 1.25rem; --radius-lg: 1.25rem;
--radius-pill: 50px; --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; } 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, 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; } h1 { font-size: 2rem; line-height: 1.1; letter-spacing: -0.01em; }
h2 { font-size: 1.3rem; } h2 { font-size: 1.3rem; }
h3 { font-size: 1.05rem; margin-top: 1.25rem; } 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: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 */ /* ---------- app-layout: zijbalk (desktop) / uitschuifmenu (mobiel) ---------- */
.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; } .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; } .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 { 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:hover { background: var(--orange-tint-soft); text-decoration: none; }
.side nav a.active { background: var(--orange); color: var(--white); } .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; } .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); } .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 { 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); } .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); } .label { font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); }
.muted { color: var(--ink-muted); } .muted { color: var(--ink-muted); }
.empty { color: var(--ink-muted); padding: 1rem 0; } .empty { color: var(--ink-muted); padding: 1rem 0; margin: 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; }
.hint { font-size: 0.875rem; color: var(--ink-muted); margin: 0.3rem 0 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 */ @media (max-width: 767.98px) {
.table-wrap { overflow-x: auto; } h1 { font-size: 1.6rem; }
table { width: 100%; border-collapse: collapse; } .head { flex-direction: column; align-items: stretch; }
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; } .head .actions > * { flex: 1 1 auto; }
td { padding: 0.7rem 0.75rem; border-bottom: 1px solid var(--line); vertical-align: top; } .head .actions .btn, .head .actions .form-select { width: 100%; }
tbody tr:last-child td { border-bottom: 0; } .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 { cursor: pointer; }
tr.link:hover td { background: var(--orange-tint-soft); }
.num { text-align: right; white-space: nowrap; } .num { text-align: right; white-space: nowrap; }
.thumb-sm { width: 48px; height: 48px; border-radius: 10px; object-fit: cover; background: var(--blue); display: block; } .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 { 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.nieuw, .chip.concept { background: var(--orange-tint); color: var(--orange-dark); }
.chip.offerte, .chip.verzonden { background: var(--blue-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); } .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 { 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:hover { border-color: var(--orange-line); text-decoration: none; }
.filters a.on { background: var(--orange); border-color: var(--orange); color: var(--white); } .filters a.on { background: var(--orange); border-color: var(--orange); color: var(--white); }
.search { display: flex; gap: 0.5rem; } .filters a.on .muted { color: inherit; }
.search input { width: 260px; } @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 { 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); } .flash.error { background: var(--orange-tint-soft); border-color: var(--orange-line); }
/* grafieken */ /* ---------- grafieken ---------- */
.bars { display: flex; flex-direction: column; gap: 0.6rem; } .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); } 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 .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; } .bar .fill { display: block; background: var(--orange); height: 100%; border-radius: 999px; }
.cols { display: flex; align-items: flex-end; gap: 6px; height: 170px; } .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; } .cols .c { width: 100%; background: var(--orange); border-radius: 6px 6px 0 0; min-height: 2px; }
/* contactpagina */ /* ---------- contact- en relatiepagina ---------- */
dl.props { display: grid; grid-template-columns: max-content 1fr; gap: 0.4rem 1rem; margin: 0; } 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); } dl.props dt { color: var(--ink-muted); font-weight: 400; }
dl.props dd { margin: 0; word-break: break-word; white-space: pre-line; } dl.props dd { margin: 0; overflow-wrap: anywhere; white-space: pre-line; }
.timeline { list-style: none; margin: 0; padding: 0; } .timeline { list-style: none; margin: 0; padding: 0; }
.timeline li { padding: 0.8rem 0; border-bottom: 1px solid var(--line); } .timeline li { padding: 0.8rem 0; border-bottom: 1px solid var(--line); }
.timeline li:last-child { border: 0; } .timeline li:last-child { border: 0; }
.timeline .meta { font-size: 0.8125rem; color: var(--ink-muted); margin-bottom: 0.15rem; } .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; } .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 { 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 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 .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; } .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 summary { cursor: pointer; font-family: var(--font-heading); font-size: 1.3rem; font-weight: 500; }
details[open] summary { margin-bottom: 1rem; } details[open] summary { margin-bottom: 1rem; }
.mailbody { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 15px; line-height: 1.7; }
/* inbox */ .members { display: grid; }
.mailbody { white-space: pre-wrap; word-break: break-word; font-size: 15px; line-height: 1.7; } .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); }
tr.unread td { font-weight: 600; } .member:last-child { border-bottom: 0; }
.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; }
}
/* taken */ /* taken */
.task { display: flex; gap: 0.75rem; align-items: flex-start; padding: 0.6rem 0; border-bottom: 1px solid var(--line); } .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 { 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); } .check:hover, .task.done .check { background: var(--orange); }
.overdue { color: var(--orange-dark); font-weight: 600; } .overdue { color: var(--orange-dark); font-weight: 600; }
.inline-fields { display: grid; grid-template-columns: 1fr 160px; gap: 0.5rem; }
/* portaal */ /* contact/relatie op mobiel: gegevens bovenaan, de 3D-weergave en de rest daaronder */
.portal-top { background: var(--white); border-bottom: 1px solid var(--line); padding: 1rem 16px; } @media (max-width: 991.98px) {
.portal-top .wrap, .portal-main { max-width: 1000px; margin: 0 auto; } .stack { display: flex; flex-direction: column; margin: 0; }
.portal-top .wrap { display: flex; justify-content: space-between; align-items: center; gap: 1rem; } .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-top img { width: 160px; display: block; }
.portal-main { padding: 2rem 16px; width: 100%; } .portal-main { padding-top: 2rem; padding-bottom: 2rem; }
body.portal { flex-direction: column; }
/* dialoog */ /* ---------- 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)); } .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::backdrop { background: rgba(255, 255, 255, 0.6); backdrop-filter: blur(2px); }
.dialog label { font-weight: 600; }
/* relaties */ /* ---------- beweging (huisstijl: lift 4–6px met shadow-hover en oranje rand, beeld schaalt 1.03–1.06) ---------- */
.members { display: grid; gap: 0.5rem; } .fx-wait .content, .fx-wait .portal-main, .fx-wait .auth { opacity: 0; }
.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; }
.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, .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); } .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); } .file img { transition: transform 0.8s cubic-bezier(0.22, 1, 0.36, 1); }
+510
View File
@@ -0,0 +1,510 @@
/* Viewer voor een woningontwerp (house-design-JSON uit de configurator), alleen bekijken.
*
* Draait op de engine van de configurator (static/configurator/, bijwerken met tools/configurator-sync.sh):
* dezelfde HouseModel.computeParts als de configurator en de light-versie, afgewerkte woning in een
* dag-omgeving. Weergaven 3D, Doorsnede en Plattegrond zoals de light-versie (js/light/light.js); de
* plattegrond is daaruit overgenomen, dus wijzigingen daar hier meenemen.
* Kerncijfers (maten, m², inhoud, gewicht, indicatieve prijs) rekent de engine; de prijs komt uit kosten/*.csv.
* Lagen: afgewerkt (zoals de light-versie) of per bouwfase / laag (ViewLayers, zoals Lagen in de configurator),
* zodat de ploeg bijv. regelwerk of elektra kan bekijken. PDF: werktekening = HousePdf.generate (alle tekenbladen),
* klant = LightPdf.generate zoals de light-configurator (voorblad met beeld, kerncijfers, keuzes, prijs en QR-code,
* aanzichten en plattegrond, werkwijze); bewaard bij het contact (POST naar de CRM); ontbreekt er een, dan maakt de
* viewer hem vanzelf aan.
*/
(() => {
const root = document.getElementById('viewer');
const $ = (sel) => root.querySelector(sel);
const nl = (n, d = 0) => n.toLocaleString('nl-NL', { minimumFractionDigits: d, maximumFractionDigits: d });
const meters = (mm) => `${nl(mm / 1000, 2)} m`;
const SECTION = { start: 1500, min: 300, max: 3000 };
const HINTS = {
'3d': 'Slepen = draaien · rechts of Shift+slepen = schuiven · klik en scroll = zoomen',
cut: 'Slepen = draaien · rechts of Shift+slepen = schuiven · klik en scroll = zoomen',
plan: 'Noord boven · maten in mm'
};
let st;
try {
st = HouseModel.normalize(HouseModel.clone(JSON.parse(document.getElementById('design').textContent)));
} catch (err) {
console.error(err);
root.classList.add('is-error');
$('.vw-msg').textContent = `Dit ontwerp kan niet worden getoond: ${err.message}`;
return;
}
// 3D: afgewerkte woning, dag-omgeving (zoals de light-versie).
const stage = $('.vw-3d');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xdfe6ec);
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 400);
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
renderer.shadowMap.enabled = true;
renderer.localClippingEnabled = true;
stage.appendChild(renderer.domElement);
const controls = new THREE.OrbitControls(camera, renderer.domElement);
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
const sun = new THREE.DirectionalLight(0xffffff, 0.8);
sun.position.set(5, 8, 5);
scene.add(ambient, sun);
const sky = Skybox.createEnvironment(scene);
const mats = {
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials(),
lightingMaterials: LightingModel.createMaterials(), sandwichMaterials: SandwichModel.createMaterials(), postMaterials: PostModel.createMaterials(),
chimneyMaterials: ChimneyModel.createMaterials(), elektraMaterials: ElektraModel.createMaterials()
};
const S = st.settings;
if (S.interiorPaint) mats.wallMaterials.spaanplaatMaterial.color.set(S.interiorColor);
mats.roofMaterials.ceilingMaterial.color.set(S.ceilingColor || '#f4f3ef');
const cfg = JSON.parse((document.getElementById('viewer-cfg') || {}).textContent || '{}');
const disposeGeo = (g) => g && g.traverse(x => { if (x.geometry) x.geometry.dispose(); });
// Lagen: 'af' = afgewerkte woning (finishedOnly, zoals de light-versie); anders de gekozen lagen (bouwfase of los).
let layers = ViewLayers.all(), finished = true, parts = null, group = null;
function build() {
const next = HouseModel.computeParts(st, Object.assign({ layers, finishedOnly: finished }, mats));
HouseModel.placeGroups(next);
if (group) { scene.remove(group); disposeGeo(group); caps = null; }
parts = next;
group = new THREE.Group();
HouseModel.rootGroups(parts).forEach(g => group.add(g));
scene.add(group);
}
let caps = null;
build();
const toM = 0.001, rs = parts.rects;
const bx0 = Math.min(...rs.map(r => r.x0)), bx1 = Math.max(...rs.map(r => r.x1));
const bz0 = Math.min(...rs.map(r => r.z0)), bz1 = Math.max(...rs.map(r => r.z1));
const center = { x: (bx0 + bx1) / 2 * toM, z: (bz0 + bz1) / 2 * toM, size: Math.max(bx1 - bx0, bz1 - bz0) * toM };
const floorTop = Math.max(...parts.units.map(u => u.layout.floorTop));
sky.fitTo(group, (floorTop - S.groundLevel - 2) * toM);
Skybox.apply('day', { env: sky, controls, sun, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: group });
controls.target.set(center.x, 1.2, center.z);
camera.position.set(center.x - center.size * 0.9, center.size * 0.7, center.z - center.size * 1.3);
controls.update();
// Weergave: 3D, doorsnede (dak en lampen weg, wanden afgesneden, snede wit afgedekt) of plattegrond.
let view = '3d', sectionMM = SECTION.start;
const poses = {};
const cutPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0);
const capMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
function clip(g, planes) {
if (g) g.traverse(x => (Array.isArray(x.material) ? x.material : x.material ? [x.material] : []).forEach(m => {
if ((m.clippingPlanes || []).length === planes.length) return;
m.clippingPlanes = planes;
m.needsUpdate = true;
}));
}
function placeCut() {
cutPlane.constant = (floorTop + sectionMM) * toM;
if (caps) { group.remove(caps); disposeGeo(caps); caps = null; }
if (view === 'cut') { caps = HouseModel.sectionCaps(parts, floorTop + sectionMM, capMaterial, ViewLayers.all()); group.add(caps); }
}
function applyView() {
const cut = view === 'cut', roof = !cut && layers.dak !== false;
parts.units.forEach(u => { if (u.roof.group) u.roof.group.visible = roof; });
[parts.roofEdgeGroup, parts.postsGroup].forEach(g => { if (g) g.visible = roof; });
if (parts.roomsGroup && parts.roomsGroup.userData.ceilings) parts.roomsGroup.userData.ceilings.visible = roof;
const planes = cut ? [cutPlane] : [];
HouseModel.allWallPieces(parts).forEach(x => clip(x.group, planes));
[parts.elektraGroup, parts.chimneysGroup, parts.roomsGroup, parts.fixturesGroup, parts.lightingGroup].forEach(g => clip(g, planes));
placeCut();
if (view === 'plan') drawPlan();
}
// Snijhoogte niet hoger dan de laagste goot (plat dak: de dakhoogte), zodat de snede door alle gevels gaat.
const cutInput = $('.vw-cutbar input');
const lows = st.settings.roof.house && st.settings.roof.house.enabled && st.settings.roof.house.form
? [st.settings.roof.house.form.low] : st.units.map(u => UnitModel.roofForm(u.unit).low);
cutInput.max = Math.max(SECTION.min, Math.floor(Math.min(...lows.filter(Number.isFinite), SECTION.max) / 50) * 50);
sectionMM = Math.min(sectionMM, Number(cutInput.max));
cutInput.value = sectionMM;
$('.vw-cutbar output').textContent = meters(sectionMM);
cutInput.addEventListener('input', () => {
sectionMM = Number(cutInput.value);
$('.vw-cutbar output').textContent = meters(sectionMM);
placeCut();
});
function setView(v) {
if (v === view) return;
if (view !== 'plan') poses[view] = { pos: camera.position.clone(), target: controls.target.clone() };
view = v;
if (v !== 'plan') {
const pose = poses[v];
if (pose) { camera.position.copy(pose.pos); controls.target.copy(pose.target); }
else {
controls.target.set(center.x, 0.6, center.z);
camera.position.set(center.x - center.size * 0.45, center.size * 1.05 + 2, center.z + center.size * 0.75);
}
controls.update();
}
root.classList.toggle('view-plan', v === 'plan');
root.classList.toggle('view-cut', v === 'cut');
root.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('on', b.dataset.view === v));
$('.vw-hint').textContent = HINTS[v];
applyView();
}
$('.vw-hint').textContent = HINTS['3d'];
$('.vw-viewbar').addEventListener('click', (e) => { const b = e.target.closest('[data-view]'); if (b) setView(b.dataset.view); });
// Plattegrond (noord boven), overgenomen uit js/light/light.js (drawPlan).
function drawPlan() {
const cv = $('.vw-plan'), W = cv.clientWidth, H = cv.clientHeight;
if (!W || !H) return;
const dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
const ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, W, H);
const p = parts;
const ext = rs.concat(p.units.map(u => u.roof.rect));
const ex0 = Math.min(...ext.map(r => r.x0)), ex1 = Math.max(...ext.map(r => r.x1));
const ez0 = Math.min(...ext.map(r => r.z0)), ez1 = Math.max(...ext.map(r => r.z1));
const top = 64, side = 56;
const scale = Math.min((W - 2 * side) / (ex1 - ex0), (H - top - side) / (ez1 - ez0));
const ox = W / 2 - (ex0 + ex1) / 2 * scale, oy = top + (H - top - side) / 2 - (ez0 + ez1) / 2 * scale;
const P = (x, z) => [ox + x * scale, oy + z * scale];
const path = (pts, closed) => { ctx.beginPath(); pts.forEach((q, i) => ctx[i ? 'lineTo' : 'moveTo'](...P(q.x, q.z))); if (closed) ctx.closePath(); };
const corners = (pl, lx0, lx1) => { const t = pl.thickness / 2; return [[lx0, -t], [lx1, -t], [lx1, t], [lx0, t]].map(([a, b]) => UnitModel.localToWorld(pl, a, b)); };
const rgba = (hex, a) => `rgba(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)}, ${a})`;
ctx.lineJoin = 'round';
// Dakrand
ctx.setLineDash([5, 4]); ctx.strokeStyle = '#9aa3ad'; ctx.lineWidth = 1;
if (p.roofEdge) p.roofEdge.segments.forEach(sg => { path([sg.outerA, sg.outerB]); ctx.stroke(); });
else p.units.forEach(u => { const [x, y] = P(u.roof.rect.x0, u.roof.rect.z0); ctx.strokeRect(x, y, u.roof.rect.W * scale, u.roof.rect.D * scale); });
ctx.setLineDash([]);
// Ruimtes
const R = p.rooms;
R.rooms.forEach(r => { ctx.fillStyle = rgba(r.color, 0.22); r.rects.forEach(c => { const [x, y] = P(c.x0, c.z0); ctx.fillRect(x, y, (c.x1 - c.x0) * scale, (c.z1 - c.z0) * scale); }); });
// Inrichting
(p.fixtures || []).forEach(it => FixtureModel.planShapes(it).forEach(sh => {
ctx.strokeStyle = '#8b939b';
ctx.lineWidth = sh.kind === 'thin' ? 0.6 : 1;
ctx.setLineDash(sh.kind === 'dash' ? [3, 3] : []);
if (sh.circle) { ctx.beginPath(); ctx.arc(...P(sh.circle.x, sh.circle.z), Math.max(1, sh.circle.r * scale), 0, Math.PI * 2); ctx.stroke(); return; }
if (sh.text) return;
path(sh.pts, sh.closed);
if (sh.fill) { ctx.fillStyle = 'rgba(139, 147, 155, 0.18)'; ctx.fill(); }
ctx.stroke();
}));
ctx.setLineDash([]);
// Wanden met openingen en kozijnsymbolen
const piece = (x, fill) => {
const pl = x.place, Wp = pl.width;
path(corners(pl, -Wp / 2, Wp / 2), true);
ctx.fillStyle = fill; ctx.fill();
x.config.openings.filter(op => op.type !== 'schoorsteen').forEach(op => {
path(corners(pl, -Wp / 2 + op.x, -Wp / 2 + op.x + op.w), true);
ctx.fillStyle = '#ffffff'; ctx.fill();
ctx.strokeStyle = '#33383d'; ctx.lineWidth = 0.8; ctx.stroke();
if (!op.fill) return;
KozijnModel.planSymbol(op, op.fill, { zIn: -pl.thickness / 2, zOut: pl.thickness / 2 }).forEach(sh => {
ctx.lineWidth = sh.kind === 'leaf' ? 1.3 : 0.8;
ctx.setLineDash(sh.kind === 'arc' || sh.kind === 'rail' ? [3, 3] : []);
path(sh.pts.map(q => UnitModel.localToWorld(pl, -Wp / 2 + op.x + q.x, q.z)), !!sh.closed);
ctx.stroke();
});
ctx.setLineDash([]);
});
};
p.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(x => piece(x, '#3d4247'))));
p.interior.forEach(x => piece(x, '#7a828a'));
ctx.fillStyle = '#a07c54';
p.posts.posts.forEach(pp => { const [x, y] = P(pp.x - pp.size / 2, pp.z - pp.size / 2); ctx.fillRect(x, y, pp.size * scale, pp.size * scale); });
p.posts.walls.forEach(w => { const [x, y] = P(w.x0, w.z0); ctx.fillStyle = '#3d4247'; ctx.fillRect(x, y, (w.x1 - w.x0) * scale, (w.z1 - w.z0) * scale); });
(p.chimneys || []).forEach(c => { path(c.footprint, true); ctx.fillStyle = '#8b8f93'; ctx.fill(); });
// Binnenspots
ctx.fillStyle = '#ffd36b'; ctx.strokeStyle = '#a07a1e'; ctx.lineWidth = 0.8;
(p.lighting.indoor || []).forEach(sp => { ctx.beginPath(); ctx.arc(...P(sp.x, sp.z), Math.max(2.5, 90 * scale), 0, Math.PI * 2); ctx.fill(); ctx.stroke(); });
// Ruimtenamen
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const fs = Math.max(9, Math.min(13, scale * 260));
R.rooms.forEach(r => {
const [x, y] = P(r.label.x, r.label.z);
ctx.fillStyle = '#212121'; ctx.font = `600 ${fs}px sans-serif`;
ctx.fillText(r.name, x, y - fs * 0.6);
ctx.fillStyle = '#5b6673'; ctx.font = `${fs * 0.9}px sans-serif`;
ctx.fillText(`${nl(r.areaM2, 1)} m²`, x, y + fs * 0.6);
});
// Buitenmaten (units) en noordpijl
ctx.strokeStyle = '#1d5f99'; ctx.fillStyle = '#1d5f99'; ctx.lineWidth = 1; ctx.font = '12px sans-serif';
const dim = (a, b, horiz, at) => {
const [x0, y0] = horiz ? P(a, 0) : P(0, a), [x1, y1] = horiz ? P(b, 0) : P(0, b);
const A = horiz ? [x0, at] : [at, y0], B = horiz ? [x1, at] : [at, y1];
ctx.beginPath(); ctx.moveTo(...A); ctx.lineTo(...B);
[A, B].forEach(([x, y]) => { ctx.moveTo(x - 4, y + 4); ctx.lineTo(x + 4, y - 4); ctx.moveTo(...(horiz ? [x, y - 6] : [x - 6, y])); ctx.lineTo(...(horiz ? [x, y + 6] : [x + 6, y])); });
ctx.stroke();
ctx.save();
ctx.translate((A[0] + B[0]) / 2, (A[1] + B[1]) / 2);
if (!horiz) ctx.rotate(-Math.PI / 2);
ctx.fillText(nl(Math.round(b - a)), 0, -9);
ctx.restore();
};
dim(bx0, bx1, true, P(0, ez0)[1] - 22);
dim(bz0, bz1, false, P(ex0, 0)[0] - 22);
const nx = W - 28, ny = top + 6;
ctx.beginPath(); ctx.moveTo(nx, ny); ctx.lineTo(nx - 7, ny + 18); ctx.lineTo(nx + 7, ny + 18); ctx.closePath(); ctx.fill();
ctx.fillText('N', nx, ny + 30);
}
// Kerncijfers; de prijs volgt zodra de kosten-CSV's geladen zijn.
function facts() {
const totals = HouseModel.computeTotals(parts);
const d = HouseModel.computeDimensions(parts, S.groundLevel);
let price = null;
try { price = CostModel.compute(parts, totals).summary.inclBtw; } catch (err) { console.warn(err); }
const rows = [
['Buitenmaten', `${nl(d.lengthX / 1000, 2)} × ${nl(d.lengthZ / 1000, 2)} m`],
['Bouwhoogte', meters(d.height)],
['Bruto / leefbaar', `${nl(d.brutoM2, 1)} / ${nl(d.leefbaarM2, 1)} m²`],
['Inhoud', `${nl(d.inhoudM3, 0)} m³`],
['Units', String(st.units.length)],
['Gewicht', `${nl(totals.totalKg / 1000, 1)} t`]
];
if (price) rows.push(['Indicatief incl. btw', `€ ${nl(Math.round(price / 100) * 100)}`]);
$('.vw-facts').innerHTML = rows.map(([k, v]) => `<div><span>${k}</span><b>${v}</b></div>`).join('');
}
facts();
CostModel.onChange(facts);
// Scrollwiel: in de CRM-pagina scrolt het de pagina, tenzij je in de 3D hebt geklikt of Ctrl/⌘ ingedrukt houdt.
const stageEl = $('.vw-stage');
let engaged = false, toastTimer = null;
const fsEl = () => document.fullscreenElement || document.webkitFullscreenElement;
function toast(msg) {
const t = $('.vw-toast');
t.textContent = msg;
t.classList.add('on');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('on'), 3000);
}
stageEl.addEventListener('pointerdown', () => { engaged = true; });
stageEl.addEventListener('pointerleave', () => { engaged = false; });
stageEl.addEventListener('wheel', (e) => {
if (engaged || e.ctrlKey || e.metaKey || view === 'plan' || fsEl() || window.parent === window) return;
e.stopPropagation();
toast('Klik eerst in de weergave (of houd Ctrl ingedrukt) om te zoomen.');
}, { capture: true });
// Schermvullend
const fullBtn = $('.vw-full');
if (root.requestFullscreen || root.webkitRequestFullscreen) {
fullBtn.hidden = false;
fullBtn.addEventListener('click', () => {
const r = fsEl() ? (document.exitFullscreen || document.webkitExitFullscreen).call(document)
: (root.requestFullscreen || root.webkitRequestFullscreen).call(root);
if (r && r.catch) r.catch(() => toast('Schermvullend lukt hier niet.'));
});
const onFull = () => { root.classList.toggle('is-full', !!fsEl()); fullBtn.title = fsEl() ? 'Schermvullend uit (Esc)' : 'Schermvullend'; };
document.addEventListener('fullscreenchange', onFull);
document.addEventListener('webkitfullscreenchange', onFull);
}
// Afbeelding van de huidige 3D-weergave
$('.vw-shot').addEventListener('click', () => {
if (view === 'plan') { const a = document.createElement('a'); a.href = $('.vw-plan').toDataURL('image/png'); a.download = 'plattegrond.png'; a.click(); return; }
renderer.render(scene, camera);
const a = document.createElement('a');
a.href = renderer.domElement.toDataURL('image/png');
a.download = `${(st.project && st.project.naam) || 'ontwerp'}.png`;
a.click();
});
// Lagenmenu: bouwfase (alles tot en met) of losse lagen; elke wijziging bouwt de woning opnieuw op.
const layerPop = $('.vw-pop-layers'), phaseSel = layerPop.querySelector('select');
phaseSel.innerHTML = `<option value="" hidden>Eigen keuze</option><option value="af">Afgewerkte woning</option>` +
ViewLayers.PHASES.slice(0, -1).map((p, i) => `<option value="${i}">${i + 1}. t/m ${p.toLowerCase()}</option>`).join('');
const LAYER_KEYS = ViewLayers.LIST.filter(l => l.key !== 'maten');
layerPop.querySelector('.vw-checks').innerHTML = LAYER_KEYS.map(l =>
`<label title="${l.hint || ''}"><input type="checkbox" data-layer="${l.key}" checked> ${l.label}</label>`).join('');
const syncChecks = () => layerPop.querySelectorAll('[data-layer]').forEach(c => { c.checked = layers[c.dataset.layer] !== false; });
function relayer(next, fin) {
layers = next; finished = fin;
root.classList.add('is-busy');
$('.vw-layers-btn').classList.toggle('on', !fin);
setTimeout(() => {
try { build(); Skybox.apply('day', { env: sky, controls, sun, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: group }); applyView(); }
catch (err) { console.error(err); toast('Deze lagen konden niet worden opgebouwd.'); }
root.classList.remove('is-busy');
}, 20);
}
phaseSel.addEventListener('change', () => {
const v = phaseSel.value;
// Bij een bouwfase het dak eraf, zodat je in de woning kijkt (aanzetten kan met de laag Dak).
relayer(v === 'af' ? ViewLayers.all() : Object.assign(ViewLayers.upTo(Number(v)), { dak: false, maten: true }), v === 'af');
syncChecks();
});
layerPop.addEventListener('change', (e) => {
const c = e.target.closest('[data-layer]');
if (!c) return;
const next = Object.assign({}, layers, { [c.dataset.layer]: c.checked });
const phase = ViewLayers.phaseOf(next);
phaseSel.value = LAYER_KEYS.every(l => next[l.key] !== false) ? 'af' : phase >= 0 && phase < ViewLayers.PHASES.length - 1 ? String(phase) : '';
relayer(next, phaseSel.value === 'af');
});
// Uitklappanelen (lagen, PDF): één open; klik ernaast sluit.
root.querySelectorAll('[data-pop]').forEach(btn => btn.addEventListener('click', (e) => {
e.stopPropagation();
const pop = $(btn.dataset.pop), open = pop.hidden;
root.querySelectorAll('.vw-pop').forEach(x => { x.hidden = true; });
pop.hidden = !open;
}));
root.querySelectorAll('.vw-pop').forEach(p => p.addEventListener('click', (e) => e.stopPropagation()));
document.addEventListener('click', () => root.querySelectorAll('.vw-pop').forEach(x => { x.hidden = true; }));
// PDF (werktekening / klant): de PDF-modules van de configurator pas laden als ze nodig zijn.
const PDF_SCRIPTS = ['js/vendor/jspdf.umd.min.js', 'js/core/logo-data.js', 'js/core/pdf-sheet.js', 'js/wall/wall-pdf.js',
'js/floor/floor-pdf.js', 'js/elektra/elektra-pdf.js', 'js/house/house-pdf.js', 'js/vendor/qrcode.js', 'js/light/light-pdf.js'];
let pdfLoading = null;
const loadScript = (src) => new Promise((ok, fail) => {
const sc = document.createElement('script');
sc.src = src; sc.onload = ok; sc.onerror = () => fail(new Error(`${src} niet geladen`));
document.head.appendChild(sc);
});
const loadPdf = () => pdfLoading || (pdfLoading = PDF_SCRIPTS.reduce((p, src) => p.then(() => loadScript(src)), Promise.resolve())
.then(() => Promise.all(HousePdf.materialThumbUrls(st).map(url => new Promise(ok => {
PdfSheet.preloadImage(url);
const img = new Image(); img.onload = img.onerror = ok; img.src = url;
})))).then(() => new Promise(ok => setTimeout(ok, 100))));
const costsReady = new Promise(ok => {
if (CostCsv.status('intern').loadedAt) ok();
else { CostCsv.onChange(ok); setTimeout(ok, 6000); }
});
const PDF_LABEL = { werk: 'Werktekening', klant: 'Klant-PDF' };
const pdfs = cfg.pdfs || {};
function renderPdfs() {
$('.vw-pop-pdf .vw-pdfs').innerHTML = ['werk', 'klant'].map(k => {
const f = pdfs[k];
return `<div class="vw-pdf"><div><b>${PDF_LABEL[k]}</b><span>${k === 'werk' ? 'Alle tekenbladen, elektra, zaaglijst en kosten (intern)' : 'Zoals de configurator: voorblad, aanzichten, plattegrond en werkwijze (ook in het klantportaal)'}</span>
${f ? `<span>Gemaakt ${f.date}</span>` : ''}</div>
<div>${f ? `<a href="${f.url}" target="_blank" rel="noopener">Openen</a>` : ''}
${cfg.pdfUrl ? `<button type="button" data-make="${k}">${f ? 'Opnieuw' : 'Maken'}</button>` : ''}</div></div>`;
}).join('');
}
// Klant-PDF zoals de light-configurator (js/light/light.js savePdf): LightPdf met beeld, kerncijfers en keuzes.
// De keuzes leest de CRM uit het ontwerp zelf (de light-keuzes komen niet mee in de house-design-JSON);
// zelfde onderwerpen en woorden als LightModel.describe.
const ROOF_NAMES = { plat: 'plat dak', lessenaar: 'lessenaardak', zadel: 'zadeldak', wolf: 'wolfsdak' };
const SIDE_NAMES = { voor: 'noord', achter: 'zuid', links: 'west', rechts: 'oost' };
const FACADES = { rabat: 'Hout', fels: 'Felsplaten', stuc: 'Stucwerk', steen: 'Steenstrips' };
const LIGHTING = { geen: 'Geen', spots: 'Spots in de dakrand', gevel: 'Gevellampen' };
const cap = (t) => t ? t.charAt(0).toUpperCase() + t.slice(1) : t;
const label = (list, key) => (list && list[key] && list[key].label) || '';
function choices() {
const R = S.roof, F = S.facade, rows = [];
const way = (f) => f.type === 'lessenaar' ? ` (hoog aan de ${SIDE_NAMES[f.dir] || f.dir}kant)`
: UnitModel.hasRidge(f.type) && UnitModel.RIDGES[f.ridge] ? ` (nok ${UnitModel.RIDGES[f.ridge].toLowerCase()})` : '';
const nok = (f) => f.type === 'plat' ? '' : `, ${f.type === 'lessenaar' ? 'hoge kant' : 'nok'} ${nl(f.high / 1000, 2)} m`;
const house = R.house && R.house.enabled && R.house.form;
const forms = house ? [R.house.form] : st.units.map(u => UnitModel.roofForm(u.unit));
const per = (fn) => { const v = forms.map(fn); return v.every(x => x === v[0]) ? v[0] : v.map((x, i) => `unit ${st.units[i].id}: ${x}`).join(', '); };
rows.push(['Dakvorm', cap(per(f => (ROOF_NAMES[f.type] || f.type) + way(f)) + (house ? ' over de hele woning' : ''))]);
rows.push(['Hoogte', cap(per(f => `goot ${nl(f.low / 1000, 2)} m${nok(f)}`))]);
const covers = [...new Set(st.units.map(u => label(RoofModel.COVERINGS, u.roof.covering) || label(RoofModel.COVERINGS, 'dakleer')))];
rows.push(['Dakbedekking', covers.join(', ')]);
const color = F.type === 'steen' ? `steen ${F.stone}` : F.type === 'rabat' ? label(WallModel.RABAT_COLORS, F.rabatColor)
: F.type === 'stuc' ? label(WallModel.STUC_COLORS, F.stucColor) : label(WallModel.FELS.COLORS, F.felsColor);
rows.push(['Gevel', cap([(FACADES[F.type] || F.type || '').toLowerCase(), F.type === 'rabat' ? S.cladding : null, color.toLowerCase()].filter(Boolean).join(', '))]);
const tr = S.trasraam || {};
rows.push(['Trasraam', tr.enabled ? `Steen ${tr.finish}, ${nl(tr.height / 1000, 2)} m hoog` : 'Geen']);
const fascia = st.units.some(u => u.roof.fascia !== false);
rows.push(['Dakrand', fascia ? `Boeidelen ${label(RoofModel.FASCIA_COLORS, R.fasciaColor).toLowerCase()}`.trim() : 'Daktrim']);
const L = S.lighting || {};
rows.push(['Buitenverlichting', (LIGHTING[L.mode] || 'Geen') + (L.mode === 'gevel' && LightingModel.DIRECTIONS[L.direction] ? ` (${LightingModel.DIRECTIONS[L.direction].toLowerCase()})` : '')]);
rows.push(['Binnenverlichting', L.indoor ? 'Inbouwspots in het plafond' : 'Geen']);
return rows;
}
// Beeld voor het voorblad: de afgewerkte woning in 3D (ook vanuit doorsnede, plattegrond of een laag), vaste resolutie.
function snapshot() {
const W = 1600, H = 1500, pr = renderer.getPixelRatio();
const keep = { view, layers, finished, pos: camera.position.clone(), target: controls.target.clone() };
const rebuild = () => { build(); Skybox.apply('day', { env: sky, controls, sun, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: group }); };
if (!finished) { layers = ViewLayers.all(); finished = true; rebuild(); }
if (view !== '3d') {
view = '3d'; applyView();
if (poses['3d']) { camera.position.copy(poses['3d'].pos); controls.target.copy(poses['3d'].target); }
}
renderer.setPixelRatio(1);
renderer.setSize(W, H, false);
camera.aspect = W / H;
camera.updateProjectionMatrix();
controls.update();
renderer.render(scene, camera);
const url = renderer.domElement.toDataURL('image/jpeg', 0.88);
renderer.setPixelRatio(pr);
if (!keep.finished) { layers = keep.layers; finished = false; rebuild(); }
view = keep.view;
applyView();
camera.position.copy(keep.pos);
controls.target.copy(keep.target);
controls.update();
resize();
return { url, width: W, height: H };
}
function customerPdf() {
const raw = JSON.parse(document.getElementById('design').textContent);
const key = raw.model || '', model = (HouseModel.MODELS || {})[key];
const name = (st.project && st.project.naam) || (model && model.label) || 'Uw woning';
const full = HouseModel.computeParts(st, { skip3D: true });
const totals = HouseModel.computeTotals(full), d = HouseModel.computeDimensions(full, S.groundLevel);
const info = {
price: CostModel.compute(full, totals).summary.inclBtw, kg: totals.totalKg, units: st.units.length,
lengthX: d.lengthX, lengthZ: d.lengthZ, height: d.height, brutoM2: d.brutoM2, leefbaarM2: d.leefbaarM2, inhoudM3: d.inhoudM3
};
let image = null;
try { image = snapshot(); } catch (err) { console.warn(err); }
const pdfSt = HouseModel.clone(st);
pdfSt.project = Object.assign({}, pdfSt.project, { naam: name, opmerking: (pdfSt.project && pdfSt.project.opmerking) || 'Indicatief ontwerp' });
return LightPdf.generate({ st: pdfSt, label: name, info, choices: choices(), url: LightPdf.modelUrl(key), image });
}
let making = Promise.resolve();
function makePdf(kind) {
making = making.then(() => {
toast(`${PDF_LABEL[kind]} wordt gemaakt…`);
return Promise.all([loadPdf(), costsReady]);
}).then(() => new Promise(ok => setTimeout(ok, 30))).then(() => {
const doc = kind === 'klant' ? customerPdf() : HousePdf.generate(st);
const body = new FormData();
body.append('kind', kind);
body.append('pdf', doc.output('blob'), `${kind}.pdf`);
return fetch(cfg.pdfUrl, { method: 'POST', body, credentials: 'same-origin' }).then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)));
}).then(res => {
pdfs[kind] = res;
renderPdfs();
toast(`${PDF_LABEL[kind]} opgeslagen bij het contact.`);
if (window.parent !== window) window.parent.postMessage({ type: 'tectum-pdf', kind, url: res.url, date: res.date }, location.origin);
}).catch(err => { console.error(err); toast(`${PDF_LABEL[kind]} maken lukte niet: ${err.message}`); });
return making;
}
$('.vw-pop-pdf').addEventListener('click', (e) => { const b = e.target.closest('[data-make]'); if (b) makePdf(b.dataset.make); });
renderPdfs();
// Direct aanwezig: ontbrekende PDF's maken zodra de weergave staat.
if (cfg.pdfUrl && cfg.auto) setTimeout(() => ['werk', 'klant'].filter(k => !pdfs[k]).forEach(makePdf), 1500);
function resize() {
const w = stage.clientWidth, h = stage.clientHeight;
if (!w || !h) return;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
}
new ResizeObserver(() => { resize(); if (view === 'plan') drawPlan(); }).observe(stage);
resize();
applyView();
root.classList.remove('is-busy');
(function loop() {
requestAnimationFrame(loop);
if (view !== 'plan' && !document.hidden) renderer.render(scene, camera);
})();
})();
+6 -6
View File
@@ -2,12 +2,12 @@
{% block title %}Mijn account{% endblock %} {% block title %}Mijn account{% endblock %}
{% block content %} {% block content %}
<div class="head"><div><div class="kicker">{{ ROLE.get(g.user.role) }}</div><h1>{{ g.user.name }}</h1><div class="muted">{{ g.user.email }}</div></div></div> <div class="head"><div><div class="kicker">{{ ROLE.get(g.user.role) }}</div><h1>{{ g.user.name }}</h1><div class="muted">{{ g.user.email }}</div></div></div>
<form method="post" class="card" style="max-width:560px"> <form method="post" class="card" style="max-width:560px"><div class="card-body">
<h2>Wachtwoord wijzigen</h2> <h2>Wachtwoord wijzigen</h2>
<div class="fields"> <div class="row g-3">
<div class="full"><label for="old">Huidig wachtwoord</label><input id="old" type="password" name="old_password" required></div> <div class="col-12"><label class="form-label" for="old">Huidig wachtwoord</label><input class="form-control" id="old" type="password" name="old_password" required></div>
<div class="full"><label for="new">Nieuw wachtwoord</label><input id="new" type="password" name="new_password" minlength="8" required autocomplete="new-password"></div> <div class="col-12"><label class="form-label" for="new">Nieuw wachtwoord</label><input class="form-control" id="new" type="password" name="new_password" minlength="8" required autocomplete="new-password"></div>
</div> </div>
<div class="actions"><button class="btn">Opslaan</button></div> <div class="actions"><button class="btn btn-primary">Opslaan</button></div>
</form> </div></form>
{% endblock %} {% endblock %}
+17 -13
View File
@@ -2,32 +2,36 @@
{% set titles = {'setup': 'Welkom', 'login': 'Inloggen', 'portal_login': 'Klantportaal', 'invite': 'Wachtwoord instellen'} %} {% set titles = {'setup': 'Welkom', 'login': 'Inloggen', 'portal_login': 'Klantportaal', 'invite': 'Wachtwoord instellen'} %}
{% block title %}{{ titles[mode] }}{% endblock %} {% block title %}{{ titles[mode] }}{% endblock %}
{% block body %} {% block body %}
<div class="auth-wrap">
<div class="auth"> <div class="auth">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous"> <img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous">
{% with msgs = get_flashed_messages(with_categories=true) %} {% with msgs = get_flashed_messages(with_categories=true) %}
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %} {% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
{% endwith %} {% endwith %}
<div class="card"> <div class="card"><div class="card-body">
<form method="post"> <form method="post">
<h1>{{ titles[mode] }}</h1> <h1>{{ titles[mode] }}</h1>
<div class="row g-3">
{% if mode == 'setup' %} {% if mode == 'setup' %}
<p class="muted">Maak het eerste beheerdersaccount aan.</p> <p class="muted mb-0">Maak het eerste beheerdersaccount aan.</p>
<label for="name">Naam</label><input id="name" name="name" required autofocus> <div class="col-12"><label class="form-label" for="name">Naam</label><input class="form-control" id="name" name="name" required autofocus></div>
<label for="email" style="margin-top:1rem">E-mailadres</label><input id="email" type="email" name="email" required> <div class="col-12"><label class="form-label" for="email">E-mailadres</label><input class="form-control" id="email" type="email" name="email" required></div>
{% elif mode == 'invite' %} {% elif mode == 'invite' %}
<p class="muted">Welkom {{ c.name }}. Kies een wachtwoord voor het klantportaal. Daarna logt u in met {{ c.email }}.</p> <p class="muted mb-0">Welkom {{ c.name }}. Kies een wachtwoord voor het klantportaal. Daarna logt u in met {{ c.email }}.</p>
{% else %} {% else %}
{% if mode == 'portal_login' %}<p class="muted">Bekijk uw offertes en deel bestanden met ons.</p>{% endif %} {% if mode == 'portal_login' %}<p class="muted mb-0">Bekijk uw offertes en deel bestanden met ons.</p>{% endif %}
<label for="email">E-mailadres</label><input id="email" type="email" name="email" required autofocus> <div class="col-12"><label class="form-label" for="email">E-mailadres</label><input class="form-control" id="email" type="email" name="email" required autofocus autocomplete="username"></div>
{% endif %} {% endif %}
<label for="pw" style="margin-top:1rem">Wachtwoord</label> <div class="col-12"><label class="form-label" for="pw">Wachtwoord</label>
<input id="pw" type="password" name="password" {{ 'minlength=8' if mode in ('setup', 'invite') }} required> <input class="form-control" id="pw" type="password" name="password" {{ 'minlength=8' if mode in ('setup', 'invite') }} required autocomplete="{{ 'new-password' if mode in ('setup', 'invite') else 'current-password' }}"></div>
{% if mode in ('setup', 'invite') %} {% if mode in ('setup', 'invite') %}
<label for="pw2" style="margin-top:1rem">Herhaal wachtwoord</label> <div class="col-12"><label class="form-label" for="pw2">Herhaal wachtwoord</label>
<input id="pw2" type="password" name="password2" minlength="8" required> <input class="form-control" id="pw2" type="password" name="password2" minlength="8" required autocomplete="new-password"></div>
{% endif %} {% endif %}
<div class="actions"><button class="btn">{{ 'Inloggen' if mode in ('login', 'portal_login') else 'Opslaan en doorgaan' }}</button></div> </div>
<div class="actions"><button class="btn btn-primary w-100">{{ 'Inloggen' if mode in ('login', 'portal_login') else 'Opslaan en doorgaan' }}</button></div>
</form> </form>
</div> </div></div>
</div>
</div> </div>
{% endblock %} {% endblock %}
+47 -26
View File
@@ -7,6 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Kantumruy+Pro:wght@500;600&family=Mukta:wght@400;600;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Kantumruy+Pro:wght@500;600&family=Mukta:wght@400;600;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<script> <script>
/* inhoud verbergen tot de GSAP-intro klaar staat; na 3 s altijd zichtbaar */ /* inhoud verbergen tot de GSAP-intro klaar staat; na 3 s altijd zichtbaar */
@@ -15,40 +16,60 @@
setTimeout(() => document.documentElement.classList.remove('fx-wait'), 3000); setTimeout(() => document.documentElement.classList.remove('fx-wait'), 3000);
} }
</script> </script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js" defer></script>
<script src="{{ url_for('static', filename='fx.js') }}" defer></script> <script src="{{ url_for('static', filename='fx.js') }}" defer></script>
</head> </head>
<body> <body>
{% block body %} {% block body %}
{% set ep = request.endpoint or '' %} {% set ep = request.endpoint or '' %}
<div class="app">
<aside class="side"> <aside class="side">
<a href="{{ url_for('dashboard') }}" class="brand"><img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous"></a> <div class="offcanvas-lg offcanvas-start side-panel" id="sidenav" tabindex="-1" aria-label="Menu">
{% set ctype = request.args.get('type') or (c.type if c is defined and c and c.type is defined else '') %} <div class="offcanvas-header">
{% set rkind = request.args.get('kind') or (r.kind if r is defined and r and r.kind is defined else '') %} <img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous">
<nav> <button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#sidenav" aria-label="Menu sluiten"></button>
<a href="{{ url_for('dashboard') }}" class="{{ 'active' if ep == 'dashboard' }}">Dashboard</a> </div>
<a href="{{ url_for('contacts', type='lead') }}" class="{{ 'active' if ep.startswith('contact') and ctype == 'lead' }}">Leads</a> <div class="offcanvas-body">
<a href="{{ url_for('relations', kind='klant') }}" class="{{ 'active' if ep.startswith('relation') and rkind == 'klant' }}">Klanten</a> <a href="{{ url_for('dashboard') }}" class="brand d-none d-lg-block"><img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous"></a>
<a href="{{ url_for('relations', kind='partner') }}" class="{{ 'active' if ep.startswith('relation') and rkind == 'partner' }}">Partners</a> {% set ctype = request.args.get('type') or (c.type if c is defined and c and c.type is defined else '') %}
<a href="{{ url_for('contacts') }}" class="{{ 'active' if ep.startswith('contact') and ctype != 'lead' }}">Contactpersonen</a> {% set rkind = request.args.get('kind') or (r.kind if r is defined and r and r.kind is defined else '') %}
<a href="{{ url_for('inbox') }}" class="{{ 'active' if ep.startswith('inbox') }}">Inbox {% if unread %}<span class="count">{{ unread }}</span>{% endif %}</a> <nav>
<a href="{{ url_for('products') }}" class="{{ 'active' if ep.startswith('product') }}">Producten</a> <a href="{{ url_for('dashboard') }}" class="{{ 'active' if ep == 'dashboard' }}">Dashboard</a>
<a href="{{ url_for('quotes') }}" class="{{ 'active' if ep.startswith('quote') }}">Offertes</a> <a href="{{ url_for('contacts', type='lead') }}" class="{{ 'active' if ep.startswith('contact') and ctype == 'lead' }}">Leads</a>
<a href="{{ url_for('import_start') }}" class="{{ 'active' if ep.startswith('import') }}">Importeren</a> <a href="{{ url_for('relations', kind='klant') }}" class="{{ 'active' if ep.startswith('relation') and rkind == 'klant' }}">Klanten</a>
{% if g.user.role == 'admin' %} <a href="{{ url_for('relations', kind='partner') }}" class="{{ 'active' if ep.startswith('relation') and rkind == 'partner' }}">Partners</a>
<a href="{{ url_for('users') }}" class="{{ 'active' if ep.startswith('user') }}">Gebruikers</a> <a href="{{ url_for('contacts') }}" class="{{ 'active' if ep.startswith('contact') and ctype != 'lead' }}">Contactpersonen</a>
<a href="{{ url_for('settings') }}" class="{{ 'active' if ep == 'settings' }}">Instellingen</a> <a href="{{ url_for('inbox') }}" class="{{ 'active' if ep.startswith('inbox') }}">Inbox {% if unread %}<span class="count">{{ unread }}</span>{% endif %}</a>
{% endif %} <a href="{{ url_for('products') }}" class="{{ 'active' if ep.startswith('product') }}">Producten</a>
</nav> <a href="{{ url_for('quotes') }}" class="{{ 'active' if ep.startswith('quote') }}">Offertes</a>
<a href="{{ url_for('account') }}" class="logout">{{ g.user.name }}</a> <a href="{{ url_for('import_start') }}" class="{{ 'active' if ep.startswith('import') }}">Importeren</a>
<a href="{{ url_for('logout') }}" class="logout">Uitloggen</a> {% if g.user.role == 'admin' %}
<a href="{{ url_for('users') }}" class="{{ 'active' if ep.startswith('user') }}">Gebruikers</a>
<a href="{{ url_for('settings') }}" class="{{ 'active' if ep == 'settings' }}">Instellingen</a>
{% endif %}
</nav>
<a href="{{ url_for('account') }}" class="logout">{{ g.user.name }}</a>
<a href="{{ url_for('logout') }}" class="logout">Uitloggen</a>
</div>
</div>
</aside> </aside>
<main> <div class="app-main">
{% with msgs = get_flashed_messages(with_categories=true) %} <header class="topbar d-lg-none">
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %} <button class="menu-btn" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidenav" aria-controls="sidenav">
{% endwith %} <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg>Menu
{% block content %}{% endblock %} </button>
</main> <a href="{{ url_for('dashboard') }}"><img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous"></a>
{% if unread %}<a href="{{ url_for('inbox') }}" class="count" aria-label="{{ unread }} ongelezen e-mail">{{ unread }}</a>{% endif %}
</header>
<main class="content">
{% with msgs = get_flashed_messages(with_categories=true) %}
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</div>
</div>
{% endblock %} {% endblock %}
</body> </body>
</html> </html>
+79
View File
@@ -0,0 +1,79 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{# De engine haalt kosten/*.csv en assets/ relatief op: base = de gekopieerde configurator. Alle CRM-links zijn absoluut. #}
<base href="{{ url_for('static', filename='configurator/') }}">
<title>Bestek · Offerte {{ qt.number }} · Van Emous CRM</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Kantumruy+Pro:wght@500;600&family=Mukta:wght@400;600;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<style>
.content { margin: 0 auto; max-width: 980px; }
.bs-ch { margin-bottom: 1.5rem; }
.bs-ch-head { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; border-bottom: 2px solid var(--ink); padding-bottom: 0.3rem; margin-bottom: 0.25rem; }
.bs-ch-head h2 { margin: 0; font-size: 1.1rem; }
.bs-ch table { width: 100%; border-collapse: collapse; }
.bs-ch td { padding: 0.35rem 0.25rem; border-bottom: 1px solid var(--line); vertical-align: top; }
.bs-ch td.num { text-align: right; white-space: nowrap; width: 9rem; }
.bs-info td { color: var(--ink-muted); font-style: italic; }
.bs-ch td.num.muted { width: 7rem; }
.bs-ch .chip { text-transform: none; letter-spacing: 0; font-weight: 400; font-size: 0.7rem; margin-left: 0.3rem; }
.bs-total { margin-left: auto; width: 320px; max-width: 100%; }
.bs-total div { display: flex; justify-content: space-between; padding: 0.2rem 0; }
.bs-total .grand { border-top: 2px solid var(--ink); margin-top: 0.3rem; padding-top: 0.5rem; font-family: var(--font-heading); font-size: 1.15rem; }
.bs-opts .form-check { margin-bottom: 0.4rem; }
@media (max-width: 640px) { .bs-ch td.num { width: auto; } }
</style>
</head>
<body>
<main class="content">
<div class="head">
<div>
<a class="label" href="{{ url_for('quote_edit', qid=qt.id) }}">← Offerte {{ qt.number }}</a>
<h1>Bestek uit ontwerp</h1>
<div class="muted">{{ design_file.title }}{% if c %} · {{ c.name }}{% endif %} · ontvangen {{ design_file.created_at|dt }}</div>
</div>
{% if designs|length > 1 %}
<form method="get" class="actions" action="{{ url_for('quote_bestek', qid=qt.id) }}">
<select class="form-select w-auto" name="file" onchange="this.form.submit()" aria-label="Ontwerp">
{% for d in designs %}<option value="{{ d.id }}" {{ 'selected' if d.id == design_file.id }}>{{ d.title }} · {{ d.created_at|dt('%d-%m-%Y') }}</option>{% endfor %}
</select>
</form>
{% endif %}
</div>
<div id="bestek">
<div class="card"><div class="card-body">
<p class="muted">Hoeveelheden en prijzen uit de configurator (zelfde berekening als Kosten &amp; 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).</p>
<div class="bs-body">{% if engine %}<p class="empty">Bestek berekenen…</p>{% else %}<p class="empty">De 3D-engine van de configurator ontbreekt. Draai tools/configurator-sync.sh in de CRM-map.</p>{% endif %}</div>
<div class="bs-total"></div>
</div></div>
<form method="post" action="{{ url_for('quote_bestek_save', qid=qt.id) }}" class="card bs-opts"><div class="card-body">
<input type="hidden" name="bestek">
<input type="hidden" name="file_id" value="{{ design_file.id }}">
<h2>In de offerte zetten</h2>
<div class="form-check"><input class="form-check-input" type="radio" id="mode-post" name="mode" value="post" checked><label class="form-check-label" for="mode-post"><strong>Per post</strong>: elke regel van het bestek als offerteregel met aantal en prijs per eenheid (bijv. 42 st. × prijs per spot)</label></div>
<div class="form-check"><input class="form-check-input" type="radio" id="mode-ch" name="mode" value="hoofdstuk"><label class="form-check-label" for="mode-ch"><strong>Per hoofdstuk</strong>: één regel per hoofdstuk (1 post) met de posten in de omschrijving</label></div>
<div class="form-check"><input class="form-check-input" type="checkbox" id="opt-replace" name="replace" value="1" {{ 'checked' if has_lines }}><label class="form-check-label" for="opt-replace">Bestaande regels van de offerte vervangen{% if has_lines %} ({{ has_lines }} regel{{ 's' if has_lines != 1 }}){% endif %}</label></div>
<div class="form-check"><input class="form-check-input" type="checkbox" id="opt-prices" name="update_prices" value="1"><label class="form-check-label" for="opt-prices">Prijzen van bestaande producten bijwerken naar deze berekening (wordt vastgelegd in de producthistorie)</label></div>
<p class="hint">Het bestek (alle posten met hoeveelheden) komt als bijlage bij de offerte, ook in de afdrukversie en het klantportaal.</p>
<div class="actions"><button class="btn btn-primary" type="submit" disabled>In offerte zetten</button>
<a class="btn btn-outline-secondary" href="{{ url_for('quote_edit', qid=qt.id) }}">Annuleren</a></div>
</div></form>
</div>
</main>
{% if engine %}
<script type="application/json" id="design">{{ design|tojson }}</script>
<script type="application/json" id="bestek-meta">{{ {'design': {'file_id': design_file.id, 'title': design_file.title}, 'configurator': design.get('configurator') or ''}|tojson }}</script>
{% for src in scripts %}<script src="{{ src }}"></script>
{% endfor %}<script src="{{ url_for('static', filename='bestek.js') }}"></script>
{% endif %}
</body>
</html>
+125 -84
View File
@@ -1,25 +1,5 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}{{ c.name }}{% if c.type == 'lead' %} {% block title %}{{ c.name }}{% endblock %}
<dialog id="convert" class="dialog">
<form method="post" action="{{ url_for('contact_convert', cid=c.id) }}">
<h2>Omzetten naar klant</h2>
<p class="muted">De contactpersoon komt in een klantrelatie, bijvoorbeeld een huishouden of familie.</p>
<label><input type="radio" name="mode" value="new" checked onchange="this.form.relation_id.disabled=true;this.form.new_relation.disabled=false"> Nieuwe klantrelatie</label>
<input name="new_relation" value="{{ c.company or ('Familie ' ~ c.name.split(' ')[-1]) }}" aria-label="Naam relatie" style="margin:.4rem 0 1rem">
{% if klant_relations %}
<label><input type="radio" name="mode" value="existing" onchange="this.form.relation_id.disabled=false;this.form.new_relation.disabled=true"> Bestaande klantrelatie</label>
<select name="relation_id" disabled aria-label="Bestaande relatie" style="margin-top:.4rem">
{% for r in klant_relations %}<option value="{{ r.id }}">{{ r.name }}{% if r.type %} · {{ r.type }}{% endif %}</option>{% endfor %}
</select>
{% endif %}
<div class="actions">
<button class="btn">Omzetten</button>
<button class="btn quiet" type="button" onclick="this.closest('dialog').close()">Annuleren</button>
</div>
</form>
</dialog>
{% endif %}
{% endblock %}
{% block content %} {% block content %}
<div class="head"> <div class="head">
<div> <div>
@@ -30,24 +10,77 @@
</div> </div>
<div class="actions"> <div class="actions">
<form method="post" action="{{ url_for('contact_status', cid=c.id) }}" class="inline"> <form method="post" action="{{ url_for('contact_status', cid=c.id) }}" class="inline">
<select name="status" onchange="this.form.submit()" aria-label="Status" style="width:auto"> <select class="form-select w-auto" name="status" onchange="this.form.submit()" aria-label="Status">
{% for k, l in STATUSES %}<option value="{{ k }}" {{ 'selected' if c.status == k }}>{{ l }}</option>{% endfor %} {% for k, l in STATUSES %}<option value="{{ k }}" {{ 'selected' if c.status == k }}>{{ l }}</option>{% endfor %}
</select> </select>
</form> </form>
{% if c.type == 'lead' %} {% if c.type == 'lead' %}
<button class="btn ghost" type="button" onclick="document.getElementById('convert').showModal()">Omzetten naar klant</button> <button class="btn btn-outline-primary" type="button" onclick="document.getElementById('convert').showModal()">Omzetten naar klant</button>
{% endif %} {% endif %}
<form method="post" action="{{ url_for('quote_new') }}" class="inline"> <form method="post" action="{{ url_for('quote_new') }}" class="inline">
<input type="hidden" name="contact_id" value="{{ c.id }}"> <input type="hidden" name="contact_id" value="{{ c.id }}">
<button class="btn ghost">Nieuwe offerte</button> <button class="btn btn-outline-primary">Nieuwe offerte</button>
</form> </form>
<a class="btn" href="{{ url_for('contact_edit', cid=c.id) }}">Bewerken</a> <a class="btn btn-primary" href="{{ url_for('contact_edit', cid=c.id) }}">Bewerken</a>
</div> </div>
</div> </div>
<div class="two"> <div class="row gx-4 stack">
<div> <div class="col-lg-7 col-xl-8">
<div class="card"> <div class="card"><div class="card-body">
<div class="card-top">
<h2>Gekozen model</h2>
{% if design %}
<div class="actions">
{% if designs|length > 1 %}
<form method="post" action="{{ url_for('contact_design', cid=c.id) }}" class="inline">
<select class="form-select form-select-sm w-auto" name="file_id" onchange="this.form.submit()" aria-label="Ander ontwerp kiezen">
{% for d in designs %}<option value="{{ d.id }}" {{ 'selected' if d.id == design.id }}>{{ d.title }} · {{ d.created_at|dt('%d-%m-%Y') }}</option>{% endfor %}
</select>
</form>
{% endif %}
<form method="post" action="{{ url_for('quote_new') }}" class="inline">
<input type="hidden" name="contact_id" value="{{ c.id }}"><input type="hidden" name="bestek" value="1">
<button class="btn btn-primary btn-sm">Offerte met bestek</button>
</form>
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('design_viewer', fid=design.id) }}" target="_blank">Openen</a>
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('upload_file', name=design.stored) }}" download="{{ design.original }}">JSON</a>
</div>
{% endif %}
</div>
{% if design %}
<div class="viewer-frame"><iframe src="{{ url_for('design_viewer', fid=design.id) }}" title="3D-weergave {{ design.title }}" loading="lazy" allowfullscreen></iframe></div>
<p class="muted" style="margin:.75rem 0 0"><strong style="color:var(--ink)">{{ design.title }}</strong>
· {{ 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 %}</p>
{% if design_meta.note %}<p style="white-space:pre-line;margin:.5rem 0 0">{{ design_meta.note }}</p>{% endif %}
<p class="pdf-links" style="margin:.5rem 0 0">Tekeningen:
{% for k, label in PDF_KINDS.items() %}{% set p = pdfs.get(k) %}
<a data-pdf="{{ k }}" href="{{ p.url if p else '#' }}" target="_blank" {{ 'hidden' if not p }}>{{ (label[:1]|upper) ~ label[1:] }}</a><span class="muted" data-pdf-wait="{{ k }}" {{ 'hidden' if p }}>{{ label }} wordt gemaakt…</span>{% if k == 'werk' %} <span class="muted">(intern)</span> ·{% endif %}
{% endfor %}
<span class="muted">· opnieuw maken via PDF in de weergave</span></p>
<script>
addEventListener('message', (e) => {
if (e.origin !== location.origin || !e.data || e.data.type !== 'tectum-pdf') return;
const a = document.querySelector(`[data-pdf="${e.data.kind}"]`), w = document.querySelector(`[data-pdf-wait="${e.data.kind}"]`);
if (a) { a.href = e.data.url; a.hidden = false; }
if (w) w.hidden = true;
});
</script>
{% elif designs %}
<p class="muted">Er is geen model gekozen.</p>
<form method="post" action="{{ url_for('contact_design', cid=c.id) }}" class="d-flex flex-wrap gap-2">
<select class="form-select w-auto" name="file_id" aria-label="Ontwerp">
{% for d in designs %}<option value="{{ d.id }}">{{ d.title }} · {{ d.created_at|dt('%d-%m-%Y') }}</option>{% endfor %}
</select>
<button class="btn btn-outline-primary">Kiezen</button>
</form>
{% else %}
<p class="empty">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.</p>
{% endif %}
</div></div>
<div class="card o-2"><div class="card-body">
<h2>Taken</h2> <h2>Taken</h2>
{% for t in tasks %} {% for t in tasks %}
<form method="post" action="{{ url_for('task_done', tid=t.id) }}" class="task {{ 'done' if t.done }}"> <form method="post" action="{{ url_for('task_done', tid=t.id) }}" class="task {{ 'done' if t.done }}">
@@ -56,46 +89,44 @@
<div class="muted">{{ t.user_name or '' }}{% if t.due_date %} · <span class="{{ 'overdue' if not t.done and t.due_date < today }}">{{ t.due_date|dt('%d-%m-%Y') }}</span>{% endif %}</div></div> <div class="muted">{{ t.user_name or '' }}{% if t.due_date %} · <span class="{{ 'overdue' if not t.done and t.due_date < today }}">{{ t.due_date|dt('%d-%m-%Y') }}</span>{% endif %}</div></div>
</form> </form>
{% endfor %} {% endfor %}
<form method="post" action="{{ url_for('contact_task', cid=c.id) }}" style="margin-top:1rem"> <form method="post" action="{{ url_for('contact_task', cid=c.id) }}" class="row g-2 mt-2">
<div class="inline-fields"> <div class="col-12 col-md"><input class="form-control" name="title" placeholder="Bijv. terugbellen, offerte nabellen" required aria-label="Taak"></div>
<input name="title" placeholder="Bijv. terugbellen, offerte nabellen" required aria-label="Taak"> <div class="col-6 col-md-auto"><input class="form-control" type="date" name="due_date" aria-label="Datum"></div>
<input type="date" name="due_date" aria-label="Datum"> <div class="col-6 col-md-auto">
</div> <select class="form-select" name="user_id" aria-label="Voor">
<div class="actions" style="margin-top:.5rem">
<select name="user_id" style="width:auto" aria-label="Voor">
{% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if u.id == g.user.id }}>{{ u.name }}</option>{% endfor %} {% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if u.id == g.user.id }}>{{ u.name }}</option>{% endfor %}
</select> </select>
<button class="btn ghost small">Taak toevoegen</button>
</div> </div>
<div class="col-12 col-md-auto"><button class="btn btn-outline-primary w-100">Taak toevoegen</button></div>
</form> </form>
</div> </div></div>
<div class="card"> <div class="card o-3"><div class="card-body">
<h2>Notitie toevoegen</h2> <h2>Notitie toevoegen</h2>
<form method="post" action="{{ url_for('contact_note', cid=c.id) }}"> <form method="post" action="{{ url_for('contact_note', cid=c.id) }}">
<textarea name="body" required placeholder="Gebeld, afspraak gemaakt, wensen…" aria-label="Notitie"></textarea> <textarea class="form-control" name="body" required placeholder="Gebeld, afspraak gemaakt, wensen…" aria-label="Notitie"></textarea>
<div class="actions"><button class="btn">Opslaan</button></div> <div class="actions"><button class="btn btn-primary">Opslaan</button></div>
</form> </form>
</div> </div></div>
{% if c.email %} {% if c.email %}
<div class="card"> <div class="card"><div class="card-body">
<details> <details>
<summary>E-mail sturen</summary> <summary>E-mail sturen</summary>
<form method="post" action="{{ url_for('mail_send') }}"> <form method="post" action="{{ url_for('mail_send') }}">
<input type="hidden" name="contact_id" value="{{ c.id }}"> <input type="hidden" name="contact_id" value="{{ c.id }}">
<div class="fields"> <div class="row g-3">
<div class="full"><label>Aan</label><input type="email" name="to" value="{{ c.email }}" required></div> <div class="col-12"><label class="form-label" for="m-to">Aan</label><input class="form-control" id="m-to" type="email" name="to" value="{{ c.email }}" required></div>
<div class="full"><label>Onderwerp</label><input name="subject" required></div> <div class="col-12"><label class="form-label" for="m-subject">Onderwerp</label><input class="form-control" id="m-subject" name="subject" required></div>
<div class="full"><label>Bericht</label><textarea name="body" rows="8" required></textarea></div> <div class="col-12"><label class="form-label" for="m-body">Bericht</label><textarea class="form-control" id="m-body" name="body" rows="8" required></textarea></div>
</div> </div>
<div class="actions"><button class="btn">Versturen</button></div> <div class="actions"><button class="btn btn-primary">Versturen</button></div>
</form> </form>
</details> </details>
</div> </div></div>
{% endif %} {% endif %}
<div class="card"> <div class="card"><div class="card-body">
<h2>Tijdlijn</h2> <h2>Tijdlijn</h2>
<ul class="timeline"> <ul class="timeline">
{% for a in activities %} {% for a in activities %}
@@ -107,28 +138,28 @@
<li class="empty">Nog geen activiteit.</li> <li class="empty">Nog geen activiteit.</li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div></div>
{% if emails %} {% if emails %}
<div class="card"> <div class="card"><div class="card-body">
<h2>E-mail</h2> <h2>E-mail</h2>
<div class="table-wrap"><table> <div class="table-responsive"><table class="table table-hover table-stack">
<tbody> <tbody>
{% for e in emails %} {% for e in emails %}
<tr class="link" onclick="location='{{ url_for('inbox_view', eid=e.id) }}'"> <tr class="link" onclick="location='{{ url_for('inbox_view', eid=e.id) }}'">
<td><span class="chip">{{ 'Ontvangen' if e.direction == 'in' else 'Verstuurd' }}</span></td> <td class="stack-title"><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td>
<td><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td> <td data-label="Richting"><span class="chip">{{ 'Ontvangen' if e.direction == 'in' else 'Verstuurd' }}</span></td>
<td class="muted num">{{ e.date|dt }}</td> <td data-label="Datum" class="muted num">{{ e.date|dt }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table></div> </table></div>
</div> </div></div>
{% endif %} {% endif %}
</div> </div>
<div> <div class="col-lg-5 col-xl-4">
<div class="card"> <div class="card o-1"><div class="card-body">
<h2>Gegevens</h2> <h2>Gegevens</h2>
<dl class="props"> <dl class="props">
<dt>Status</dt><dd><span class="chip {{ c.status }}">{{ STATUS.get(c.status, c.status) }}</span></dd> <dt>Status</dt><dd><span class="chip {{ c.status }}">{{ STATUS.get(c.status, c.status) }}</span></dd>
@@ -151,19 +182,23 @@
{% for k, v in extra.items() %}<dt>{{ k }}</dt><dd>{{ v if v is string else v|tojson }}</dd>{% endfor %} {% for k, v in extra.items() %}<dt>{{ k }}</dt><dd>{{ v if v is string else v|tojson }}</dd>{% endfor %}
</dl> </dl>
{% endif %} {% endif %}
</div> </div></div>
<div class="card"> <div class="card"><div class="card-body">
<h2>Bestanden</h2> <h2>Bestanden</h2>
{% if files %} {% if files %}
<div class="files"> <div class="files">
{% for f in files %} {% for f in files %}
<div class="file"> <div class="file">
<a href="{{ url_for('upload_file', name=f.stored) }}" target="_blank" class="thumb"> <a href="{{ url_for('design_viewer', fid=f.id) if f.title else url_for('upload_file', name=f.stored) }}" target="_blank" class="thumb">
{% if f.mime.startswith('image/') %}<img src="{{ url_for('upload_file', name=f.stored) }}" alt="{{ f.original }}" loading="lazy"> {% if f.mime.startswith('image/') %}<img src="{{ url_for('upload_file', name=f.stored) }}" alt="{{ f.original }}" loading="lazy">
{% 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 %}
</a> </a>
<div class="name" title="{{ f.original }}">{{ f.original }}</div> <div class="name" title="{{ f.original }}">{{ f.title or f.original }}</div>
{% if f.kind == 'werk' %}<div class="muted">Intern, niet in portaal</div>{% endif %}
{% if f.title %}{% if c.design_file_id == f.id %}<div class="muted">Gekozen model</div>{% else %}
<form method="post" action="{{ url_for('contact_design', cid=c.id) }}"><input type="hidden" name="file_id" value="{{ f.id }}"><button class="linkbtn">Kies als model</button></form>
{% endif %}{% endif %}
<form method="post" action="{{ url_for('file_delete', fid=f.id) }}" onsubmit="return confirm('Bestand verwijderen?')"> <form method="post" action="{{ url_for('file_delete', fid=f.id) }}" onsubmit="return confirm('Bestand verwijderen?')">
<button class="linkbtn">Verwijderen</button> <button class="linkbtn">Verwijderen</button>
</form> </form>
@@ -172,12 +207,12 @@
</div> </div>
{% endif %} {% endif %}
<form method="post" action="{{ url_for('contact_upload', cid=c.id) }}" enctype="multipart/form-data"> <form method="post" action="{{ url_for('contact_upload', cid=c.id) }}" enctype="multipart/form-data">
<input type="file" name="files" multiple required aria-label="Bestanden kiezen"> <input class="form-control" type="file" name="files" multiple required aria-label="Bestanden kiezen">
<div class="actions"><button class="btn ghost small">Uploaden</button></div> <div class="actions"><button class="btn btn-outline-primary btn-sm">Uploaden</button></div>
</form> </form>
</div> </div></div>
<div class="card"> <div class="card"><div class="card-body">
<h2>Offertes</h2> <h2>Offertes</h2>
{% for qt in quotes %} {% for qt in quotes %}
<p style="margin:0 0 .6rem"><a href="{{ url_for('quote_edit', qid=qt.id) }}">{{ qt.number }}</a> <span class="chip {{ qt.status }}">{{ QSTATUS.get(qt.status) }}</span><br> <p style="margin:0 0 .6rem"><a href="{{ url_for('quote_edit', qid=qt.id) }}">{{ qt.number }}</a> <span class="chip {{ qt.status }}">{{ QSTATUS.get(qt.status) }}</span><br>
@@ -185,27 +220,27 @@
{% else %} {% else %}
<p class="empty">Nog geen offertes.</p> <p class="empty">Nog geen offertes.</p>
{% endfor %} {% endfor %}
</div> </div></div>
<div class="card"> <div class="card"><div class="card-body">
<h2>Klantportaal</h2> <h2>Klantportaal</h2>
{% if c.portal_password_hash %} {% if c.portal_password_hash %}
<p class="muted" style="margin-top:0">Actief{% if c.portal_last_login %} · laatst ingelogd {{ c.portal_last_login|dt }}{% endif %}</p> <p class="muted">Actief{% if c.portal_last_login %} · laatst ingelogd {{ c.portal_last_login|dt }}{% endif %}</p>
{% elif portal_link %} {% elif portal_link %}
<p class="muted" style="margin-top:0">Uitnodiging klaar, nog niet geactiveerd:</p> <p class="muted">Uitnodiging klaar, nog niet geactiveerd:</p>
<p><code>{{ portal_link }}</code></p> <p><code>{{ portal_link }}</code></p>
{% else %} {% else %}
<p class="muted" style="margin-top:0">De klant kan via het portaal offertes bekijken en accepteren, en bestanden en berichten sturen.</p> <p class="muted">De klant kan via het portaal offertes bekijken en accepteren, en bestanden en berichten sturen.</p>
{% endif %} {% endif %}
<form method="post" action="{{ url_for('contact_portal', cid=c.id) }}" class="actions"> <form method="post" action="{{ url_for('contact_portal', cid=c.id) }}" class="actions">
{% if portal_link and c.email %}<button class="btn small" name="action" value="mail">Mail uitnodiging</button>{% endif %} {% if portal_link and c.email %}<button class="btn btn-primary btn-sm" name="action" value="mail">Mail uitnodiging</button>{% endif %}
{% if not c.portal_password_hash %}<button class="btn ghost small" name="action" value="invite">{{ 'Nieuwe link' if portal_link else 'Uitnodiging maken' }}</button>{% endif %} {% if not c.portal_password_hash %}<button class="btn btn-outline-primary btn-sm" name="action" value="invite">{{ 'Nieuwe link' if portal_link else 'Uitnodiging maken' }}</button>{% endif %}
{% if c.portal_password_hash or portal_link %}<button class="btn quiet small" name="action" value="revoke">Toegang intrekken</button>{% endif %} {% if c.portal_password_hash or portal_link %}<button class="btn btn-outline-secondary btn-sm" name="action" value="revoke">Toegang intrekken</button>{% endif %}
</form> </form>
</div> </div></div>
<form method="post" action="{{ url_for('contact_delete', cid=c.id) }}" onsubmit="return confirm('Contact met alle notities en bestanden verwijderen?')"> <form method="post" action="{{ url_for('contact_delete', cid=c.id) }}" class="mb-4" onsubmit="return confirm('Contact met alle notities en bestanden verwijderen?')">
<button class="btn quiet small">Contact verwijderen</button> <button class="btn btn-outline-secondary btn-sm">Contact verwijderen</button>
</form> </form>
</div> </div>
</div> </div>
@@ -214,17 +249,23 @@
<form method="post" action="{{ url_for('contact_convert', cid=c.id) }}"> <form method="post" action="{{ url_for('contact_convert', cid=c.id) }}">
<h2>Omzetten naar klant</h2> <h2>Omzetten naar klant</h2>
<p class="muted">De contactpersoon komt in een klantrelatie, bijvoorbeeld een huishouden of familie.</p> <p class="muted">De contactpersoon komt in een klantrelatie, bijvoorbeeld een huishouden of familie.</p>
<label><input type="radio" name="mode" value="new" checked onchange="this.form.relation_id.disabled=true;this.form.new_relation.disabled=false"> Nieuwe klantrelatie</label> <div class="form-check">
<input name="new_relation" value="{{ c.company or ('Familie ' ~ c.name.split(' ')[-1]) }}" aria-label="Naam relatie" style="margin:.4rem 0 1rem"> <input class="form-check-input" type="radio" id="mode-new" name="mode" value="new" checked onchange="this.form.relation_id && (this.form.relation_id.disabled=true);this.form.new_relation.disabled=false">
<label class="form-check-label" for="mode-new">Nieuwe klantrelatie</label>
</div>
<input class="form-control" name="new_relation" value="{{ c.company or ('Familie ' ~ c.name.split(' ')[-1]) }}" aria-label="Naam relatie" style="margin:.4rem 0 1rem">
{% if klant_relations %} {% if klant_relations %}
<label><input type="radio" name="mode" value="existing" onchange="this.form.relation_id.disabled=false;this.form.new_relation.disabled=true"> Bestaande klantrelatie</label> <div class="form-check">
<select name="relation_id" disabled aria-label="Bestaande relatie" style="margin-top:.4rem"> <input class="form-check-input" type="radio" id="mode-existing" name="mode" value="existing" onchange="this.form.relation_id.disabled=false;this.form.new_relation.disabled=true">
<label class="form-check-label" for="mode-existing">Bestaande klantrelatie</label>
</div>
<select class="form-select" name="relation_id" disabled aria-label="Bestaande relatie" style="margin-top:.4rem">
{% for r in klant_relations %}<option value="{{ r.id }}">{{ r.name }}{% if r.type %} · {{ r.type }}{% endif %}</option>{% endfor %} {% for r in klant_relations %}<option value="{{ r.id }}">{{ r.name }}{% if r.type %} · {{ r.type }}{% endif %}</option>{% endfor %}
</select> </select>
{% endif %} {% endif %}
<div class="actions"> <div class="actions">
<button class="btn">Omzetten</button> <button class="btn btn-primary">Omzetten</button>
<button class="btn quiet" type="button" onclick="this.closest('dialog').close()">Annuleren</button> <button class="btn btn-outline-secondary" type="button" onclick="this.closest('dialog').close()">Annuleren</button>
</div> </div>
</form> </form>
</dialog> </dialog>
+24 -24
View File
@@ -7,33 +7,33 @@
<h1>{{ ('Nieuwe ' ~ TYPE.get(c.type, 'lead')|lower) if new else 'Contact bewerken' }}</h1> <h1>{{ ('Nieuwe ' ~ TYPE.get(c.type, 'lead')|lower) if new else 'Contact bewerken' }}</h1>
</div> </div>
</div> </div>
<form method="post" class="card"> <form method="post" class="card"><div class="card-body">
<div class="fields"> <div class="row g-3">
<div><label for="name">Naam *</label><input id="name" name="name" value="{{ c.name }}" required></div> <div class="col-md-6"><label class="form-label" for="name">Naam *</label><input class="form-control" id="name" name="name" value="{{ c.name }}" required></div>
<div><label for="company">Bedrijf</label><input id="company" name="company" value="{{ c.company }}"></div> <div class="col-md-6"><label class="form-label" for="company">Bedrijf</label><input class="form-control" id="company" name="company" value="{{ c.company }}"></div>
<div><label for="email">E-mail</label><input id="email" type="email" name="email" value="{{ c.email }}"></div> <div class="col-md-6"><label class="form-label" for="email">E-mail</label><input class="form-control" id="email" type="email" name="email" value="{{ c.email }}"></div>
<div><label for="phone">Telefoon</label><input id="phone" name="phone" value="{{ c.phone }}"></div> <div class="col-md-6"><label class="form-label" for="phone">Telefoon</label><input class="form-control" id="phone" type="tel" name="phone" value="{{ c.phone }}"></div>
<div class="full"><label for="address">Adres</label><input id="address" name="address" value="{{ c.address }}"></div> <div class="col-12"><label class="form-label" for="address">Adres</label><input class="form-control" id="address" name="address" value="{{ c.address }}"></div>
<div><label for="postcode">Postcode</label><input id="postcode" name="postcode" value="{{ c.postcode }}"></div> <div class="col-sm-4"><label class="form-label" for="postcode">Postcode</label><input class="form-control" id="postcode" name="postcode" value="{{ c.postcode }}"></div>
<div><label for="city">Plaats</label><input id="city" name="city" value="{{ c.city }}"></div> <div class="col-sm-8"><label class="form-label" for="city">Plaats</label><input class="form-control" id="city" name="city" value="{{ c.city }}"></div>
<div><label for="status">Status</label> <div class="col-md-6 col-xl-4"><label class="form-label" for="status">Status</label>
<select id="status" name="status"> <select class="form-select" id="status" name="status">
{% for k, l in STATUSES %}<option value="{{ k }}" {{ 'selected' if c.status == k }}>{{ l }}</option>{% endfor %} {% for k, l in STATUSES %}<option value="{{ k }}" {{ 'selected' if c.status == k }}>{{ l }}</option>{% endfor %}
</select> </select>
</div> </div>
<div><label for="type">Soort</label> <div class="col-md-6 col-xl-4"><label class="form-label" for="type">Soort</label>
<select id="type" name="type"> <select class="form-select" id="type" name="type">
{% for k, l in TYPES %}<option value="{{ k }}" {{ 'selected' if c.type == k }}>{{ l }}</option>{% endfor %} {% for k, l in TYPES %}<option value="{{ k }}" {{ 'selected' if c.type == k }}>{{ l }}</option>{% endfor %}
</select> </select>
</div> </div>
<div><label for="assigned">Toegewezen aan</label> <div class="col-md-6 col-xl-4"><label class="form-label" for="assigned">Toegewezen aan</label>
<select id="assigned" name="assigned_user_id"> <select class="form-select" id="assigned" name="assigned_user_id">
<option value="">— Niemand —</option> <option value="">— Niemand —</option>
{% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if c.assigned_user_id|string == u.id|string }}>{{ u.name }}</option>{% endfor %} {% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if c.assigned_user_id|string == u.id|string }}>{{ u.name }}</option>{% endfor %}
</select> </select>
</div> </div>
<div><label for="relation">Relatie</label> <div class="col-md-6"><label class="form-label" for="relation">Relatie</label>
<select id="relation" name="relation_id"> <select class="form-select" id="relation" name="relation_id">
<option value="">— Geen —</option> <option value="">— Geen —</option>
{% for kind, label in RELATION_KINDS %} {% for kind, label in RELATION_KINDS %}
<optgroup label="{{ label }}s"> <optgroup label="{{ label }}s">
@@ -43,14 +43,14 @@
</select> </select>
<p class="hint">Huishouden, familie of bedrijf van de klant, of de partner waar deze persoon werkt.</p> <p class="hint">Huishouden, familie of bedrijf van de klant, of de partner waar deze persoon werkt.</p>
</div> </div>
<div><label for="role">Rol binnen relatie</label><input id="role" name="relation_role" value="{{ c.relation_role }}" placeholder="Bijv. bewoner, mantelzorger, inkoper"></div> <div class="col-md-6"><label class="form-label" for="role">Rol binnen relatie</label><input class="form-control" id="role" name="relation_role" value="{{ c.relation_role }}" placeholder="Bijv. bewoner, mantelzorger, inkoper"></div>
<div><label for="value">Verwachte waarde (€)</label><input id="value" type="number" step="0.01" name="value" value="{{ c.value or '' }}"></div> <div class="col-md-6"><label class="form-label" for="value">Verwachte waarde (€)</label><input class="form-control" id="value" type="number" step="0.01" name="value" value="{{ c.value or '' }}" inputmode="decimal"></div>
<div><label for="source">Bron</label><input id="source" name="source" value="{{ c.source }}" placeholder="Bijv. website, beurs, telefoon"></div> <div class="col-md-6"><label class="form-label" for="source">Bron</label><input class="form-control" id="source" name="source" value="{{ c.source }}" placeholder="Bijv. website, beurs, telefoon"></div>
<div class="full"><label for="notes">Vaste notities</label><textarea id="notes" name="notes">{{ c.notes }}</textarea></div> <div class="col-12"><label class="form-label" for="notes">Vaste notities</label><textarea class="form-control" id="notes" name="notes">{{ c.notes }}</textarea></div>
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn">Opslaan</button> <button class="btn btn-primary">Opslaan</button>
<a class="btn quiet" href="{{ url_for('contacts') if new else url_for('contact_view', cid=c.id) }}">Annuleren</a> <a class="btn btn-outline-secondary" href="{{ url_for('contacts') if new else url_for('contact_view', cid=c.id) }}">Annuleren</a>
</div> </div>
</form> </div></form>
{% endblock %} {% endblock %}
+16 -15
View File
@@ -6,10 +6,10 @@
<div class="actions"> <div class="actions">
<form class="search" method="get"> <form class="search" method="get">
{% for k in ('type', 'status', 'owner') %}{% if a[k] %}<input type="hidden" name="{{ k }}" value="{{ a[k] }}">{% endif %}{% endfor %} {% for k in ('type', 'status', 'owner') %}{% if a[k] %}<input type="hidden" name="{{ k }}" value="{{ a[k] }}">{% endif %}{% endfor %}
<input type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, e-mail, plaats…" aria-label="Zoeken"> <input class="form-control" type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, e-mail, plaats…" aria-label="Zoeken">
</form> </form>
<a class="btn ghost" href="{{ url_for('contacts_export') }}">Exporteer CSV</a> <a class="btn btn-outline-primary" href="{{ url_for('contacts_export') }}">Exporteer CSV</a>
<a class="btn" href="{{ url_for('contact_new', type=a.type or 'lead') }}">{{ 'Nieuwe klant' if a.type == 'klant' else 'Nieuwe lead' }}</a> <a class="btn btn-primary" href="{{ url_for('contact_new', type=a.type or 'lead') }}">{{ 'Nieuwe klant' if a.type == 'klant' else 'Nieuwe lead' }}</a>
</div> </div>
</div> </div>
@@ -30,25 +30,26 @@
<a href="{{ url_for('contacts', owner='none', **base) }}" class="{{ 'on' if a.owner == 'none' }}">Niet toegewezen</a> <a href="{{ url_for('contacts', owner='none', **base) }}" class="{{ 'on' if a.owner == 'none' }}">Niet toegewezen</a>
</div> </div>
<div class="card"> <div class="card"><div class="card-body">
{% if rows %} {% if rows %}
<div class="table-wrap"><table> <div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th>Naam</th><th>Relatie</th><th>Telefoon</th><th>Plaats</th><th>Bron</th><th>Toegewezen</th><th>Status</th><th>Aangemaakt</th></tr></thead> <thead><tr><th>Naam</th><th>Relatie</th><th>Telefoon</th><th>Plaats</th><th>Model</th><th>Bron</th><th>Toegewezen</th><th>Status</th><th>Aangemaakt</th></tr></thead>
<tbody> <tbody>
{% for c in rows %} {% for c in rows %}
<tr class="link" onclick="location='{{ url_for('contact_view', cid=c.id) }}'"> <tr class="link" onclick="location='{{ url_for('contact_view', cid=c.id) }}'">
<td><a href="{{ url_for('contact_view', cid=c.id) }}">{{ c.name }}</a><div class="muted">{{ c.email }}</div></td> <td class="stack-title"><a href="{{ url_for('contact_view', cid=c.id) }}">{{ c.name }}</a><div class="muted">{{ c.email }}</div></td>
<td>{% if c.relation_id %}<a href="{{ url_for('relation_view', rid=c.relation_id) }}" onclick="event.stopPropagation()">{{ c.relation_name }}</a>{% if c.relation_role %}<div class="muted">{{ c.relation_role }}</div>{% endif %}{% else %}{{ c.company }}{% endif %}</td> <td data-label="Relatie">{% if c.relation_id %}<span><a href="{{ url_for('relation_view', rid=c.relation_id) }}" onclick="event.stopPropagation()">{{ c.relation_name }}</a>{% if c.relation_role %}<span class="muted d-block">{{ c.relation_role }}</span>{% endif %}</span>{% else %}{{ c.company }}{% endif %}</td>
<td>{{ c.phone }}</td> <td data-label="Telefoon">{{ c.phone }}</td>
<td>{{ c.city }}</td> <td data-label="Plaats">{{ c.city }}</td>
<td>{{ c.source }}</td> <td data-label="Model">{{ c.design_title or '' }}</td>
<td>{{ c.user_name or '—' }}</td> <td data-label="Bron">{{ c.source }}</td>
<td>{% if not a.type %}<span class="chip">{{ TYPE.get(c.type) }}</span> {% endif %}<span class="chip {{ c.status }}">{{ STATUS.get(c.status, c.status) }}</span></td> <td data-label="Toegewezen">{{ c.user_name or '—' }}</td>
<td class="muted">{{ c.created_at|dt('%d-%m-%Y') }}</td> <td data-label="Status"><span>{% if not a.type %}<span class="chip">{{ TYPE.get(c.type) }}</span> {% endif %}<span class="chip {{ c.status }}">{{ STATUS.get(c.status, c.status) }}</span></span></td>
<td data-label="Aangemaakt" class="muted">{{ c.created_at|dt('%d-%m-%Y') }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table></div> </table></div>
{% else %}<p class="empty">Geen contacten gevonden.</p>{% endif %} {% else %}<p class="empty">Geen contacten gevonden.</p>{% endif %}
</div> </div></div>
{% endblock %} {% endblock %}
+92 -84
View File
@@ -3,101 +3,109 @@
{% block content %} {% block content %}
<div class="head"> <div class="head">
<div><div class="kicker">Overzicht</div><h1>Dashboard</h1></div> <div><div class="kicker">Overzicht</div><h1>Dashboard</h1></div>
<a class="btn" href="{{ url_for('contact_new') }}">Nieuw contact</a> <div class="actions"><a class="btn btn-primary" href="{{ url_for('contact_new') }}">Nieuw contact</a></div>
</div> </div>
<div class="kpis"> <div class="row g-3 mb-4">
<div class="kpi"><div class="label">Nieuwe leads · 30 dagen</div><div class="value">{{ stats.new30 }}</div><div class="muted">{{ stats.customers }} klantrelaties · {{ stats.partners }} partners</div></div> <div class="col-sm-6 col-xl-3"><div class="kpi"><div class="label">Nieuwe leads · 30 dagen</div><div class="value">{{ stats.new30 }}</div><div class="muted">{{ stats.customers }} klantrelaties · {{ stats.partners }} partners</div></div></div>
<div class="kpi"><div class="label">Open leads</div><div class="value">{{ stats.open_leads }}</div> <div class="col-sm-6 col-xl-3"><div class="kpi"><div class="label">Open leads</div><div class="value">{{ stats.open_leads }}</div>
<div class="muted">{{ stats.pipeline_value|euro }} verwacht{% if stats.unassigned %} · <a href="{{ url_for('contacts', type='lead', owner='none') }}">{{ stats.unassigned }} niet toegewezen</a>{% endif %}</div></div> <div class="muted">{{ stats.pipeline_value|euro }} verwacht{% if stats.unassigned %} · <a href="{{ url_for('contacts', type='lead', owner='none') }}">{{ stats.unassigned }} niet toegewezen</a>{% endif %}</div></div></div>
<div class="kpi"><div class="label">Open offertes</div><div class="value">{{ stats.open_quotes }}</div><div class="muted">{{ stats.open_value|euro }} excl. btw</div></div> <div class="col-sm-6 col-xl-3"><div class="kpi"><div class="label">Open offertes</div><div class="value">{{ stats.open_quotes }}</div><div class="muted">{{ stats.open_value|euro }} excl. btw</div></div></div>
<div class="kpi"><div class="label">Gewonnen · 30 dagen</div><div class="value">{{ stats.won_value|euro }}</div><div class="muted">excl. btw</div></div> <div class="col-sm-6 col-xl-3"><div class="kpi"><div class="label">Gewonnen · 30 dagen</div><div class="value">{{ stats.won_value|euro }}</div><div class="muted">excl. btw</div></div></div>
</div> </div>
<div class="grid"> <div class="row g-4 mb-4 card-grid">
<div class="card"> <div class="col-md-6 col-xxl-4">
<h2>Leads per status</h2> <div class="card"><div class="card-body">
<div class="bars"> <h2>Leads per status</h2>
{% for key, label, n in pipeline %} <div class="bars">
<a class="bar" href="{{ url_for('contacts', type='lead', status=key) }}"> {% for key, label, n in pipeline %}
<span>{{ label }}</span> <a class="bar" href="{{ url_for('contacts', type='lead', status=key) }}">
<span class="track"><span class="fill" style="width: {{ (n / pipe_max * 100)|round(1) }}%"></span></span> <span>{{ label }}</span>
<span class="num">{{ n }}</span> <span class="track"><span class="fill" style="width: {{ (n / pipe_max * 100)|round(1) }}%"></span></span>
</a> <span class="num">{{ n }}</span>
{% endfor %} </a>
</div> {% endfor %}
</div>
<div class="card">
<h2>Leads per week</h2>
<div class="cols">
{% for wk, n in weekly %}
<div title="Week {{ wk }}: {{ n }} lead(s)">
<span>{{ n or '' }}</span>
<span class="c" style="height: {{ (n / week_max * 75)|round(1) }}%"></span>
<span>{{ wk }}</span>
</div> </div>
{% endfor %} </div></div>
</div>
</div> </div>
<div class="card"> <div class="col-md-6 col-xxl-4">
<h2>Bronnen</h2> <div class="card"><div class="card-body">
{% if sources %} <h2>Leads per week</h2>
<div class="bars"> <div class="cols">
{% for s in sources %} {% for wk, n in weekly %}
<div class="bar"> <div title="Week {{ wk }}: {{ n }} lead(s)">
<span title="{{ s.s }}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ s.s }}</span> <span>{{ n or '' }}</span>
<span class="track"><span class="fill" style="width: {{ (s.n / source_max * 100)|round(1) }}%"></span></span> <span class="c" style="height: {{ (n / week_max * 75)|round(1) }}%"></span>
<span class="num">{{ s.n }}</span> <span>{{ wk }}</span>
</div>
{% endfor %}
</div> </div>
{% endfor %} </div></div>
</div> </div>
{% else %}<p class="empty">Nog geen leads.</p>{% endif %} <div class="col-md-12 col-xxl-4">
<div class="card"><div class="card-body">
<h2>Bronnen</h2>
{% if sources %}
<div class="bars">
{% for s in sources %}
<div class="bar">
<span title="{{ s.s }}">{{ s.s }}</span>
<span class="track"><span class="fill" style="width: {{ (s.n / source_max * 100)|round(1) }}%"></span></span>
<span class="num">{{ s.n }}</span>
</div>
{% endfor %}
</div>
{% else %}<p class="empty">Nog geen leads.</p>{% endif %}
</div></div>
</div> </div>
</div> </div>
<div class="two"> <div class="row gx-4">
<div class="card"> <div class="col-xl-8">
<h2>Laatste leads</h2> <div class="card"><div class="card-body">
{% if recent %} <h2>Laatste leads</h2>
<div class="table-wrap"><table> {% if recent %}
<thead><tr><th>Naam</th><th>Bron</th><th>Toegewezen</th><th>Status</th><th>Binnen</th></tr></thead> <div class="table-responsive"><table class="table table-hover table-stack">
<tbody> <thead><tr><th>Naam</th><th>Bron</th><th>Toegewezen</th><th>Status</th><th>Binnen</th></tr></thead>
{% for c in recent %} <tbody>
<tr class="link" onclick="location='{{ url_for('contact_view', cid=c.id) }}'"> {% for c in recent %}
<td><a href="{{ url_for('contact_view', cid=c.id) }}">{{ c.name }}</a><div class="muted">{{ c.email }}</div></td> <tr class="link" onclick="location='{{ url_for('contact_view', cid=c.id) }}'">
<td>{{ c.source }}</td> <td class="stack-title"><a href="{{ url_for('contact_view', cid=c.id) }}">{{ c.name }}</a><div class="muted">{{ c.email }}</div></td>
<td>{{ c.user_name or '—' }}</td> <td data-label="Bron">{{ c.source }}</td>
<td><span class="chip {{ c.status }}">{{ STATUS.get(c.status, c.status) }}</span></td> <td data-label="Toegewezen">{{ c.user_name or '—' }}</td>
<td class="muted">{{ c.created_at|dt }}</td> <td data-label="Status"><span class="chip {{ c.status }}">{{ STATUS.get(c.status, c.status) }}</span></td>
</tr> <td data-label="Binnen" class="muted">{{ c.created_at|dt }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Nog geen contacten. Voeg er een toe of koppel een formulier via Instellingen.</p>{% endif %}
</div></div>
</div>
<div class="col-xl-4">
<div class="card"><div class="card-body">
<h2>Mijn taken</h2>
{% for t in tasks %}
<form method="post" action="{{ url_for('task_done', tid=t.id) }}" class="task">
<button class="check" aria-label="Afronden"></button>
<div><div>{{ t.title }}</div>
<div class="muted">{% if t.contact_id %}<a href="{{ url_for('contact_view', cid=t.contact_id) }}">{{ t.contact_name }}</a>{% endif %}
{% if t.due_date %}· <span class="{{ 'overdue' if t.due_date < today }}">{{ t.due_date|dt('%d-%m-%Y') }}</span>{% endif %}</div></div>
</form>
{% else %}
<p class="empty">Geen openstaande taken.</p>
{% endfor %} {% endfor %}
</tbody> </div></div>
</table></div> <div class="card"><div class="card-body">
{% else %}<p class="empty">Nog geen contacten. Voeg er een toe of koppel een formulier via Instellingen.</p>{% endif %} <h2>Ongelezen e-mail</h2>
</div> {% for e in mails %}
<div> <p><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a><br><span class="muted">{{ e.from_name or e.from_addr }} · {{ e.date|dt }}</span></p>
<div class="card"> {% else %}
<h2>Mijn taken</h2> <p class="empty">Geen ongelezen berichten.</p>
{% for t in tasks %} {% endfor %}
<form method="post" action="{{ url_for('task_done', tid=t.id) }}" class="task"> <a href="{{ url_for('inbox') }}">Naar inbox</a>
<button class="check" aria-label="Afronden"></button> </div></div>
<div><div>{{ t.title }}</div>
<div class="muted">{% if t.contact_id %}<a href="{{ url_for('contact_view', cid=t.contact_id) }}">{{ t.contact_name }}</a>{% endif %}
{% if t.due_date %}· <span class="{{ 'overdue' if t.due_date < today }}">{{ t.due_date|dt('%d-%m-%Y') }}</span>{% endif %}</div></div>
</form>
{% else %}
<p class="empty">Geen openstaande taken.</p>
{% endfor %}
</div>
<div class="card">
<h2>Ongelezen e-mail</h2>
{% for e in mails %}
<p><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a><br><span class="muted">{{ e.from_name or e.from_addr }} · {{ e.date|dt }}</span></p>
{% else %}
<p class="empty">Geen ongelezen berichten.</p>
{% endfor %}
<a href="{{ url_for('inbox') }}">Naar inbox</a>
</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+11 -11
View File
@@ -5,35 +5,35 @@
<div> <div>
<a class="label" href="{{ url_for('inbox') }}">← Inbox</a> <a class="label" href="{{ url_for('inbox') }}">← Inbox</a>
<h1>{{ e.subject }}</h1> <h1>{{ e.subject }}</h1>
<div class="muted"> <div class="muted" style="overflow-wrap:anywhere">
{% if e.direction == 'in' %}Van {{ e.from_name }} &lt;{{ e.from_addr }}&gt;{% else %}Aan {{ e.to_addr }}{% endif %} {% if e.direction == 'in' %}Van {{ e.from_name }} &lt;{{ e.from_addr }}&gt;{% else %}Aan {{ e.to_addr }}{% endif %}
· {{ e.date|dt }} · {{ e.date|dt }}
</div> </div>
</div> </div>
<div class="actions"> <div class="actions">
{% if contact %} {% if contact %}
<a class="btn ghost" href="{{ url_for('contact_view', cid=contact.id) }}">Naar {{ contact.name }}</a> <a class="btn btn-outline-primary" href="{{ url_for('contact_view', cid=contact.id) }}">Naar {{ contact.name }}</a>
{% elif e.direction == 'in' %} {% elif e.direction == 'in' %}
<form method="post" action="{{ url_for('inbox_contact', eid=e.id) }}" class="inline"><button class="btn ghost">Maak contact aan</button></form> <form method="post" action="{{ url_for('inbox_contact', eid=e.id) }}" class="inline"><button class="btn btn-outline-primary">Maak contact aan</button></form>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="card"><div class="mailbody">{{ e.body }}</div></div> <div class="card"><div class="card-body"><div class="mailbody">{{ e.body }}</div></div></div>
{% if e.direction == 'in' %} {% if e.direction == 'in' %}
<div class="card"> <div class="card"><div class="card-body">
<h2>Beantwoorden</h2> <h2>Beantwoorden</h2>
<form method="post" action="{{ url_for('mail_send') }}"> <form method="post" action="{{ url_for('mail_send') }}">
<input type="hidden" name="reply_to" value="{{ e.id }}"> <input type="hidden" name="reply_to" value="{{ e.id }}">
{% if contact %}<input type="hidden" name="contact_id" value="{{ contact.id }}">{% endif %} {% if contact %}<input type="hidden" name="contact_id" value="{{ contact.id }}">{% endif %}
<div class="fields"> <div class="row g-3">
<div><label>Aan</label><input type="email" name="to" value="{{ e.from_addr }}" required></div> <div class="col-md-6"><label class="form-label" for="to">Aan</label><input class="form-control" id="to" type="email" name="to" value="{{ e.from_addr }}" required></div>
<div><label>Onderwerp</label><input name="subject" value="{{ e.subject if e.subject.lower().startswith('re:') else 'Re: ' ~ e.subject }}" required></div> <div class="col-md-6"><label class="form-label" for="subject">Onderwerp</label><input class="form-control" id="subject" name="subject" value="{{ e.subject if e.subject.lower().startswith('re:') else 'Re: ' ~ e.subject }}" required></div>
<div class="full"><label>Bericht</label><textarea name="body" rows="8" required></textarea></div> <div class="col-12"><label class="form-label" for="body">Bericht</label><textarea class="form-control" id="body" name="body" rows="8" required></textarea></div>
</div> </div>
<div class="actions"><button class="btn">Versturen</button></div> <div class="actions"><button class="btn btn-primary">Versturen</button></div>
</form> </form>
</div> </div></div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+24 -20
View File
@@ -2,27 +2,31 @@
{% block title %}Importeren{% endblock %} {% block title %}Importeren{% endblock %}
{% block content %} {% block content %}
<div class="head"><div><div class="kicker">Gegevens</div><h1>Importeren</h1></div></div> <div class="head"><div><div class="kicker">Gegevens</div><h1>Importeren</h1></div></div>
<div class="two"> <div class="row g-4 card-grid">
<form method="post" enctype="multipart/form-data" class="card"> <div class="col-xl-7">
<h2>Bestand kiezen</h2> <form method="post" enctype="multipart/form-data" class="card"><div class="card-body">
<div class="fields"> <h2>Bestand kiezen</h2>
<div class="full"><label for="kind">Wat importeer je?</label> <div class="row g-3">
<select id="kind" name="kind"> <div class="col-12"><label class="form-label" for="kind">Wat importeer je?</label>
<option value="contacts">Leads / klanten</option> <select class="form-select" id="kind" name="kind">
<option value="relations" {{ 'selected' if request.args.get('kind') == 'relations' }}>Relaties (klanten of partners)</option> <option value="contacts">Leads / klanten</option>
<option value="products" {{ 'selected' if request.args.get('kind') == 'products' }}>Producten</option> <option value="relations" {{ 'selected' if request.args.get('kind') == 'relations' }}>Relaties (klanten of partners)</option>
</select> <option value="products" {{ 'selected' if request.args.get('kind') == 'products' }}>Producten</option>
</select>
</div>
<div class="col-12"><label class="form-label" for="file">CSV- of JSON-bestand</label><input class="form-control" id="file" type="file" name="file" accept=".csv,.json,.txt" required></div>
</div> </div>
<div class="full"><label for="file">CSV- of JSON-bestand</label><input id="file" type="file" name="file" accept=".csv,.json,.txt" required></div> <div class="actions"><button class="btn btn-primary">Volgende: kolommen controleren</button></div>
</div> </div></form>
<div class="actions"><button class="btn">Volgende: kolommen controleren</button></div> </div>
</form> <div class="col-xl-5">
<div class="card"> <div class="card"><div class="card-body">
<h2>Formaten</h2> <h2>Formaten</h2>
<p><strong>CSV</strong>: scheidingsteken <code>;</code>, <code>,</code> of tab, de eerste regel met kolomnamen. Regels die beginnen met <code>#</code> worden overgeslagen. Bedragen als <code>6,00</code>, <code>1.234,50</code> of <code>€ 6</code>.</p> <p><strong>CSV</strong>: scheidingsteken <code>;</code>, <code>,</code> of tab, de eerste regel met kolomnamen. Regels die beginnen met <code>#</code> worden overgeslagen. Bedragen als <code>6,00</code>, <code>1.234,50</code> of <code>€ 6</code>.</p>
<p><strong>JSON</strong>: een lijst met objecten, of een object met een lijst erin, zoals <code>{"leads": [{"naam": "…", "email": "…"}]}</code>.</p> <p><strong>JSON</strong>: een lijst met objecten, of een object met een lijst erin, zoals <code>{"leads": [{"naam": "…", "email": "…"}]}</code>.</p>
<p><strong>Configurator</strong>: <code>kosten/leveranciers.csv</code> en <code>intern.csv</code> worden als producten herkend (code, omschrijving, eenheid, prijs, leverancier). Een woningontwerp (<code>house-design</code>-JSON) upload je bij een contact onder Bestanden.</p> <p><strong>Configurator</strong>: <code>kosten/leveranciers.csv</code> en <code>intern.csv</code> worden als producten herkend (code, omschrijving, eenheid, prijs, leverancier). Een woningontwerp (<code>house-design</code>-JSON) upload je bij een contact onder Bestanden.</p>
<p class="muted" style="margin-bottom:0">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.</p> <p class="muted">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.</p>
</div></div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+18 -17
View File
@@ -7,17 +7,18 @@
<div><a class="label" href="{{ url_for('import_start') }}">← Ander bestand</a> <div><a class="label" href="{{ url_for('import_start') }}">← Ander bestand</a>
<h1>{{ filename }}</h1> <h1>{{ filename }}</h1>
<div class="muted">{{ total }} rij(en) · {{ {'contacts': 'leads / contactpersonen', 'relations': 'relaties', 'products': 'producten'}[kind] }}</div></div> <div class="muted">{{ total }} rij(en) · {{ {'contacts': 'leads / contactpersonen', 'relations': 'relaties', 'products': 'producten'}[kind] }}</div></div>
<div class="actions"><button class="btn">{{ total }} rij(en) importeren</button></div> <div class="actions"><button class="btn btn-primary">{{ total }} rij(en) importeren</button></div>
</div> </div>
<div class="card"> <div class="card"><div class="card-body">
<h2>Kolommen koppelen</h2> <h2>Kolommen koppelen</h2>
<div class="table-wrap"><table> <p class="hint d-md-none" style="margin:-.4rem 0 .75rem">Schuif de tabel opzij voor de andere kolommen.</p>
<div class="table-responsive"><table class="table">
<thead><tr> <thead><tr>
{% for col in cols %} {% for col in cols %}
<th style="min-width:170px"> <th style="min-width:170px">
<div style="margin-bottom:.4rem">{{ col }}</div> <div style="margin-bottom:.4rem">{{ col }}</div>
<select name="map_{{ loop.index0 }}" aria-label="Doelveld voor {{ col }}"> <select class="form-select form-select-sm" name="map_{{ loop.index0 }}" aria-label="Doelveld voor {{ col }}">
{% for k, l in targets %}<option value="{{ k }}" {{ 'selected' if mapping[loop.index0] == k }}>{{ l }}</option>{% endfor %} {% for k, l in targets %}<option value="{{ k }}" {{ 'selected' if mapping[loop.index0] == k }}>{{ l }}</option>{% endfor %}
</select> </select>
</th> </th>
@@ -30,30 +31,30 @@
</tbody> </tbody>
</table></div> </table></div>
{% if total > rows|length %}<p class="hint">Eerste {{ rows|length }} van {{ total }} rijen.</p>{% endif %} {% if total > rows|length %}<p class="hint">Eerste {{ rows|length }} van {{ total }} rijen.</p>{% endif %}
</div> </div></div>
<div class="card"> <div class="card"><div class="card-body">
<h2>Opties</h2> <h2>Opties</h2>
<div class="fields"> <div class="row g-3">
{% if kind == 'contacts' %} {% if kind == 'contacts' %}
<div><label>Importeren als</label><select name="type">{% for k, l in TYPES %}<option value="{{ k }}">{{ l }}</option>{% endfor %}</select></div> <div class="col-md-6"><label class="form-label" for="o-type">Importeren als</label><select class="form-select" id="o-type" name="type">{% for k, l in TYPES %}<option value="{{ k }}">{{ l }}</option>{% endfor %}</select></div>
<div><label>Status (als de kolom ontbreekt)</label><select name="status">{% for k, l in STATUSES %}<option value="{{ k }}">{{ l }}</option>{% endfor %}</select></div> <div class="col-md-6"><label class="form-label" for="o-status">Status (als de kolom ontbreekt)</label><select class="form-select" id="o-status" name="status">{% for k, l in STATUSES %}<option value="{{ k }}">{{ l }}</option>{% endfor %}</select></div>
<div><label>Toewijzen aan</label><select name="assigned_user_id"><option value="">— Niemand —</option>{% for u in USERS %}<option value="{{ u.id }}">{{ u.name }}</option>{% endfor %}</select></div> <div class="col-md-6"><label class="form-label" for="o-user">Toewijzen aan</label><select class="form-select" id="o-user" name="assigned_user_id"><option value="">— Niemand —</option>{% for u in USERS %}<option value="{{ u.id }}">{{ u.name }}</option>{% endfor %}</select></div>
<div><label>Bron (als de kolom ontbreekt)</label><input name="source" value="Import"></div> <div class="col-md-6"><label class="form-label" for="o-source">Bron (als de kolom ontbreekt)</label><input class="form-control" id="o-source" name="source" value="Import"></div>
{% endif %} {% endif %}
{% if kind == 'relations' %} {% if kind == 'relations' %}
<div><label>Soort</label><select name="relation_kind" id="rkind">{% for k, l in RELATION_KINDS %}<option value="{{ k }}" {{ 'selected' if request.args.get('rkind') == k }}>{{ l }}</option>{% endfor %}</select></div> <div class="col-md-6"><label class="form-label" for="rkind">Soort</label><select class="form-select" name="relation_kind" id="rkind">{% for k, l in RELATION_KINDS %}<option value="{{ k }}" {{ 'selected' if request.args.get('rkind') == k }}>{{ l }}</option>{% endfor %}</select></div>
<div><label>Type (als de kolom ontbreekt)</label> <div class="col-md-6"><label class="form-label" for="o-rtype">Type (als de kolom ontbreekt)</label>
<select name="relation_type"><option value="">—</option> <select class="form-select" id="o-rtype" name="relation_type"><option value="">—</option>
{% for k, l in RELATION_KINDS %}<optgroup label="{{ l }}">{% for t in relation_types[k] %}<option>{{ t }}</option>{% endfor %}</optgroup>{% endfor %} {% for k, l in RELATION_KINDS %}<optgroup label="{{ l }}">{% for t in relation_types[k] %}<option>{{ t }}</option>{% endfor %}</optgroup>{% endfor %}
</select></div> </select></div>
{% endif %} {% endif %}
{% if kind == 'products' %} {% if kind == 'products' %}
<div class="full"><label><input type="checkbox" name="deactivate_missing" value="1">Producten met een SKU die niet in dit bestand staan op inactief zetten (volledige prijslijst)</label> <div class="col-12"><div class="form-check"><input class="form-check-input" type="checkbox" id="o-deact" name="deactivate_missing" value="1"><label class="form-check-label" for="o-deact">Producten met een SKU die niet in dit bestand staan op inactief zetten (volledige prijslijst)</label></div>
<p class="hint">Alleen als er een kolom aan SKU gekoppeld is. Inactieve producten blijven bestaan, met hun mutaties en offertes.</p></div> <p class="hint">Alleen als er een kolom aan SKU gekoppeld is. Inactieve producten blijven bestaan, met hun mutaties en offertes.</p></div>
{% endif %} {% endif %}
<div class="full"><label><input type="checkbox" name="update" value="1" checked>Bestaande {{ 'contacten (zelfde e-mail) aanvullen' if kind == 'contacts' else 'relaties (zelfde naam of KvK) aanvullen' if kind == 'relations' else 'producten bijwerken (zelfde SKU, of zelfde naam als er geen SKU is); alleen gekoppelde kolommen worden gewijzigd' }}; uit = overslaan</label></div> <div class="col-12"><div class="form-check"><input class="form-check-input" type="checkbox" id="o-update" name="update" value="1" checked><label class="form-check-label" for="o-update">Bestaande {{ 'contacten (zelfde e-mail) aanvullen' if kind == 'contacts' else 'relaties (zelfde naam of KvK) aanvullen' if kind == 'relations' else 'producten bijwerken (zelfde SKU, of zelfde naam als er geen SKU is); alleen gekoppelde kolommen worden gewijzigd' }}; uit = overslaan</label></div></div>
</div> </div>
</div> </div></div>
</form> </form>
{% endblock %} {% endblock %}
+9 -10
View File
@@ -2,10 +2,9 @@
{% block title %}Inbox{% endblock %} {% block title %}Inbox{% endblock %}
{% block content %} {% block content %}
<div class="head"> <div class="head">
<div><div class="kicker">E-mail</div><h1>Inbox</h1></div> <div><div class="kicker">E-mail</div><h1>Inbox</h1>{% if last_sync %}<div class="muted">Laatst opgehaald: {{ last_sync|dt }}</div>{% endif %}</div>
<div class="actions"> <div class="actions">
{% if last_sync %}<span class="muted">Laatst opgehaald: {{ last_sync|dt }}</span>{% endif %} <form method="post" action="{{ url_for('inbox_sync') }}" class="inline"><button class="btn btn-primary">Nieuwe e-mail ophalen</button></form>
<form method="post" action="{{ url_for('inbox_sync') }}" class="inline"><button class="btn">Nieuwe e-mail ophalen</button></form>
</div> </div>
</div> </div>
@@ -19,21 +18,21 @@
<a href="{{ url_for('inbox', f='unlinked') }}" class="{{ 'on' if f == 'unlinked' }}">Niet gekoppeld aan contact</a> <a href="{{ url_for('inbox', f='unlinked') }}" class="{{ 'on' if f == 'unlinked' }}">Niet gekoppeld aan contact</a>
</div> </div>
<div class="card"> <div class="card"><div class="card-body">
{% if rows %} {% if rows %}
<div class="table-wrap"><table> <div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th>Van</th><th>Onderwerp</th><th>Contact</th><th class="num">Datum</th></tr></thead> <thead><tr><th>Van</th><th>Onderwerp</th><th>Contact</th><th class="num">Datum</th></tr></thead>
<tbody> <tbody>
{% for e in rows %} {% for e in rows %}
<tr class="link {{ 'unread' if not e.is_read }}" onclick="location='{{ url_for('inbox_view', eid=e.id) }}'"> <tr class="link {{ 'unread' if not e.is_read }}" onclick="location='{{ url_for('inbox_view', eid=e.id) }}'">
<td>{% if not e.is_read %}<span class="dot" aria-label="Ongelezen"></span>{% endif %}{{ e.from_name or e.from_addr }}</td> <td class="stack-title">{% if not e.is_read %}<span class="dot" aria-label="Ongelezen"></span>{% endif %}{{ e.from_name or e.from_addr }}<a class="d-block d-md-none" href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td>
<td><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td> <td class="d-none d-md-table-cell"><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td>
<td>{{ e.contact_name or '' }}</td> <td data-label="Contact">{{ e.contact_name or '' }}</td>
<td class="muted num">{{ e.date|dt }}</td> <td data-label="Datum" class="muted num">{{ e.date|dt }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table></div> </table></div>
{% else %}<p class="empty">Geen berichten.</p>{% endif %} {% else %}<p class="empty">Geen berichten.</p>{% endif %}
</div> </div></div>
{% endblock %} {% endblock %}
+44 -41
View File
@@ -1,70 +1,73 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}Klantportaal{% endblock %} {% block title %}Klantportaal{% endblock %}
{% block body %} {% block body %}
<script>document.body.classList.add('portal')</script>
<header class="portal-top"> <header class="portal-top">
<div class="wrap"> <div class="container d-flex justify-content-between align-items-center gap-3">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="{{ s.company_name or 'Van Emous' }}"> <img src="{{ url_for('static', filename='logo.svg') }}" alt="{{ s.company_name or 'Van Emous' }}">
<a href="{{ url_for('portal_logout') }}">Uitloggen</a> <a href="{{ url_for('portal_logout') }}">Uitloggen</a>
</div> </div>
</header> </header>
<div class="portal-main"> <div class="portal-main container">
{% with msgs = get_flashed_messages(with_categories=true) %} {% with msgs = get_flashed_messages(with_categories=true) %}
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %} {% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
{% endwith %} {% endwith %}
<div class="head"><div><div class="kicker">Klantportaal</div><h1>Welkom, {{ c.name }}</h1></div></div> <div class="head"><div><div class="kicker">Klantportaal</div><h1>Welkom, {{ c.name }}</h1></div></div>
<div class="card"> <div class="card"><div class="card-body">
<h2>Uw offertes</h2> <h2>Uw offertes</h2>
{% if quotes %} {% if quotes %}
<div class="table-wrap"><table> <div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th>Nummer</th><th>Onderwerp</th><th>Datum</th><th>Status</th><th class="num">Totaal incl. btw</th></tr></thead> <thead><tr><th>Nummer</th><th>Onderwerp</th><th>Datum</th><th>Status</th><th class="num">Totaal incl. btw</th></tr></thead>
<tbody> <tbody>
{% for qt in quotes %} {% for qt in quotes %}
<tr class="link" onclick="location='{{ url_for('portal_quote', qid=qt.id) }}'"> <tr class="link" onclick="location='{{ url_for('portal_quote', qid=qt.id) }}'">
<td><a href="{{ url_for('portal_quote', qid=qt.id) }}">{{ qt.number }}</a></td> <td class="stack-title"><a href="{{ url_for('portal_quote', qid=qt.id) }}">{{ qt.number }}</a></td>
<td>{{ qt.title }}</td> <td data-label="Onderwerp">{{ qt.title }}</td>
<td>{{ qt.date|dt('%d-%m-%Y') }}</td> <td data-label="Datum">{{ qt.date|dt('%d-%m-%Y') }}</td>
<td><span class="chip {{ qt.status }}">{{ 'Wacht op uw reactie' if qt.status == 'verzonden' else QSTATUS.get(qt.status) }}</span></td> <td data-label="Status"><span class="chip {{ qt.status }}">{{ 'Wacht op uw reactie' if qt.status == 'verzonden' else QSTATUS.get(qt.status) }}</span></td>
<td class="num">{{ qt.total|euro }}</td> <td data-label="Totaal incl. btw" class="num">{{ qt.total|euro }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table></div> </table></div>
{% else %}<p class="empty">Er staan nog geen offertes voor u klaar.</p>{% endif %} {% else %}<p class="empty">Er staan nog geen offertes voor u klaar.</p>{% endif %}
</div> </div></div>
<div class="grid"> <div class="row g-4 mb-4 card-grid">
<div class="card"> <div class="col-md-6">
<h2>Bestanden</h2> <div class="card"><div class="card-body">
{% if files %} <h2>Bestanden</h2>
<div class="files"> {% if files %}
{% for f in files %} <div class="files">
<div class="file"> {% for f in files %}
<a href="{{ url_for('portal_file', name=f.stored) }}" target="_blank" class="thumb"> <div class="file">
{% if f.mime.startswith('image/') %}<img src="{{ url_for('portal_file', name=f.stored) }}" alt="{{ f.original }}" loading="lazy"> <a href="{{ url_for('portal_file', name=f.stored) }}" target="_blank" class="thumb">
{% else %}{{ f.original.rsplit('.', 1)[-1][:4] if '.' in f.original else 'bestand' }}{% endif %} {% if f.mime.startswith('image/') %}<img src="{{ url_for('portal_file', name=f.stored) }}" alt="{{ f.original }}" loading="lazy">
</a> {% else %}{{ f.original.rsplit('.', 1)[-1][:4] if '.' in f.original else 'bestand' }}{% endif %}
<div class="name" title="{{ f.original }}">{{ f.original }}</div> </a>
</div> <div class="name" title="{{ f.original }}">{{ f.original }}</div>
{% endfor %} </div>
</div> {% endfor %}
{% endif %} </div>
<form method="post" action="{{ url_for('portal_upload') }}" enctype="multipart/form-data"> {% endif %}
<label for="files">Foto's of documenten delen (bijv. van uw tuin of tekeningen)</label> <form method="post" action="{{ url_for('portal_upload') }}" enctype="multipart/form-data">
<input id="files" type="file" name="files" multiple required> <label class="form-label" for="files">Foto's of documenten delen (bijv. van uw tuin of tekeningen)</label>
<div class="actions"><button class="btn ghost small">Uploaden</button></div> <input class="form-control" id="files" type="file" name="files" multiple required>
</form> <div class="actions"><button class="btn btn-outline-primary btn-sm">Uploaden</button></div>
</form>
</div></div>
</div> </div>
<div class="card"> <div class="col-md-6">
<h2>Bericht sturen</h2> <div class="card"><div class="card-body">
<form method="post" action="{{ url_for('portal_message') }}"> <h2>Bericht sturen</h2>
<textarea name="body" required aria-label="Bericht" placeholder="Uw vraag of opmerking"></textarea> <form method="post" action="{{ url_for('portal_message') }}">
<div class="actions"><button class="btn">Versturen</button></div> <textarea class="form-control" name="body" required aria-label="Bericht" placeholder="Uw vraag of opmerking"></textarea>
</form> <div class="actions"><button class="btn btn-primary">Versturen</button></div>
{% for m in messages %} </form>
<p class="muted" style="margin:.75rem 0 0">{{ m.created_at|dt }}<br><span style="color:var(--ink);white-space:pre-line">{{ m.body[8:] }}</span></p> {% for m in messages %}
{% endfor %} <p class="muted" style="margin:.75rem 0 0">{{ m.created_at|dt }}<br><span style="color:var(--ink);white-space:pre-line">{{ m.body[8:] }}</span></p>
{% endfor %}
</div></div>
</div> </div>
</div> </div>
{% if s.company_phone or s.company_email %} {% if s.company_phone or s.company_email %}
+31 -31
View File
@@ -4,61 +4,61 @@
<div class="head"> <div class="head">
<div><a class="label" href="{{ url_for('products') }}">← Producten</a><h1>{{ 'Product bewerken' if p.id else 'Nieuw product' }}</h1></div> <div><a class="label" href="{{ url_for('products') }}">← Producten</a><h1>{{ 'Product bewerken' if p.id else 'Nieuw product' }}</h1></div>
</div> </div>
<form method="post" enctype="multipart/form-data" class="card"> <form method="post" enctype="multipart/form-data" class="card"><div class="card-body">
<div class="fields"> <div class="row g-3">
<div class="full"><label for="name">Naam *</label><input id="name" name="name" value="{{ p.name }}" required></div> <div class="col-12"><label class="form-label" for="name">Naam *</label><input class="form-control" id="name" name="name" value="{{ p.name }}" required></div>
<div><label for="sku">SKU / artikelnummer</label><input id="sku" name="sku" value="{{ p.sku }}"> <div class="col-md-6"><label class="form-label" for="sku">SKU / artikelnummer</label><input class="form-control" id="sku" name="sku" value="{{ p.sku }}">
<p class="hint">Vaste sleutel: imports en de API werken het product met deze SKU bij.</p></div> <p class="hint">Vaste sleutel: imports en de API werken het product met deze SKU bij.</p></div>
<div><label for="supplier">Leverancier</label><input id="supplier" name="supplier" value="{{ p.supplier }}" list="suppliers" autocomplete="off"> <div class="col-md-6"><label class="form-label" for="supplier">Leverancier</label><input class="form-control" id="supplier" name="supplier" value="{{ p.supplier }}" list="suppliers" autocomplete="off">
<datalist id="suppliers">{% for r in suppliers %}<option value="{{ r.name }}">{% endfor %}</datalist> <datalist id="suppliers">{% for r in suppliers %}<option value="{{ r.name }}">{% endfor %}</datalist>
<p class="hint">{% if p.supplier_id %}<a href="{{ url_for('relation_view', rid=p.supplier_id) }}">Naar partner</a> · {% endif %}Een nieuwe naam wordt een partner van het type Leverancier.</p></div> <p class="hint">{% if p.supplier_id %}<a href="{{ url_for('relation_view', rid=p.supplier_id) }}">Naar partner</a> · {% endif %}Een nieuwe naam wordt een partner van het type Leverancier.</p></div>
<div><label for="unit">Eenheid</label><input id="unit" name="unit" value="{{ p.unit }}" placeholder="stuk, m², uur"></div> <div class="col-sm-4"><label class="form-label" for="unit">Eenheid</label><input class="form-control" id="unit" name="unit" value="{{ p.unit }}" placeholder="stuk, m², uur"></div>
<div><label for="price">Prijs excl. btw (€)</label><input id="price" type="number" step="0.01" name="price" value="{{ p.price }}" required></div> <div class="col-sm-4"><label class="form-label" for="price">Prijs excl. btw (€)</label><input class="form-control" id="price" type="number" step="0.01" name="price" value="{{ p.price }}" required inputmode="decimal"></div>
<div><label for="vat">Btw %</label> <div class="col-sm-4"><label class="form-label" for="vat">Btw %</label>
<select id="vat" name="vat"> <select class="form-select" id="vat" name="vat">
{% for v in [21, 9, 0] %}<option value="{{ v }}" {{ 'selected' if p.vat == v }}>{{ v }}%</option>{% endfor %} {% for v in [21, 9, 0] %}<option value="{{ v }}" {{ 'selected' if p.vat == v }}>{{ v }}%</option>{% endfor %}
</select> </select>
</div> </div>
<div class="full"><label for="description">Omschrijving</label><textarea id="description" name="description">{{ p.description }}</textarea> <div class="col-12"><label class="form-label" for="description">Omschrijving</label><textarea class="form-control" id="description" name="description">{{ p.description }}</textarea>
<p class="hint">Wordt standaard onder de productnaam op de offerte gezet.</p></div> <p class="hint">Wordt standaard onder de productnaam op de offerte gezet.</p></div>
<div class="full"> <div class="col-12">
<label for="image">Afbeelding</label> <label class="form-label" for="image">Afbeelding</label>
{% if p.image %}<img src="{{ url_for('upload_file', name=p.image) }}" alt="" style="max-width:220px;border-radius:var(--radius-md);display:block;margin-bottom:.75rem">{% endif %} {% if p.image %}<img src="{{ url_for('upload_file', name=p.image) }}" alt="" class="d-block mb-3 img-fluid" style="max-width:220px;border-radius:var(--radius-md)">{% endif %}
<input id="image" type="file" name="image" accept="image/*"> <input class="form-control" id="image" type="file" name="image" accept="image/*">
</div> </div>
<div class="full"><label><input type="checkbox" name="active" value="1" {{ 'checked' if p.active }}>Actief (beschikbaar in offertes)</label></div> <div class="col-12"><div class="form-check"><input class="form-check-input" type="checkbox" id="active" name="active" value="1" {{ 'checked' if p.active }}><label class="form-check-label" for="active">Actief (beschikbaar in offertes)</label></div></div>
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn">Opslaan</button> <button class="btn btn-primary">Opslaan</button>
<a class="btn quiet" href="{{ url_for('products') }}">Annuleren</a> <a class="btn btn-outline-secondary" href="{{ url_for('products') }}">Annuleren</a>
</div> </div>
</form> </div></form>
{% if p.id %} {% if p.id %}
<div class="card"> <div class="card"><div class="card-body">
<h2>Mutaties</h2> <h2>Mutaties</h2>
<p class="hint" style="margin-top:-.4rem">Bestaande offertes houden hun eigen prijs; wijzigingen gelden voor nieuwe offerteregels.{% if used %} Gebruikt in {{ used }} offerte(s).{% endif %}</p> <p class="hint" style="margin:-.4rem 0 1rem">Bestaande offertes houden hun eigen prijs; wijzigingen gelden voor nieuwe offerteregels.{% if used %} Gebruikt in {{ used }} offerte(s).{% endif %}</p>
{% if history %} {% if history %}
<div class="table-wrap"><table> <div class="table-responsive"><table class="table table-stack">
<thead><tr><th>Datum</th><th>Veld</th><th>Oud</th><th>Nieuw</th><th>Bron</th></tr></thead> <thead><tr><th>Datum</th><th>Veld</th><th>Oud</th><th>Nieuw</th><th>Bron</th></tr></thead>
<tbody> <tbody>
{% for h in history %} {% for h in history %}
<tr> <tr>
<td class="muted">{{ h.created_at|dt }}</td> <td class="stack-title muted">{{ h.created_at|dt }}</td>
<td>{{ LABELS.get(h.field, h.field) }}</td> <td data-label="Veld">{{ LABELS.get(h.field, h.field) }}</td>
{% if h.field == 'price' %}<td class="num">{{ h.old_value|float|euro }}</td><td class="num">{{ h.new_value|float|euro }} {% if h.field == 'price' %}<td data-label="Oud" class="num">{{ h.old_value|float|euro }}</td><td data-label="Nieuw" class="num"><span>{{ h.new_value|float|euro }}
{% set diff = h.new_value|float - h.old_value|float %}{% if h.old_value|float %}<span class="muted">({{ '+' if diff > 0 }}{{ (diff / h.old_value|float * 100)|round(1)|nr }}%)</span>{% endif %}</td> {% set diff = h.new_value|float - h.old_value|float %}{% if h.old_value|float %}<span class="muted">({{ '+' if diff > 0 }}{{ (diff / h.old_value|float * 100)|round(1)|nr }}%)</span>{% endif %}</span></td>
{% elif h.field == 'created' %}<td></td><td class="num">{{ h.new_value|float|euro }}</td> {% elif h.field == 'created' %}<td data-label="Oud"></td><td data-label="Nieuw" class="num">{{ h.new_value|float|euro }}</td>
{% elif h.field == 'active' %}<td>{{ 'ja' if h.old_value == '1' else 'nee' }}</td><td>{{ 'ja' if h.new_value == '1' else 'nee' }}</td> {% elif h.field == 'active' %}<td data-label="Oud">{{ 'ja' if h.old_value == '1' else 'nee' }}</td><td data-label="Nieuw">{{ 'ja' if h.new_value == '1' else 'nee' }}</td>
{% else %}<td>{{ h.old_value|truncate(60) }}</td><td>{{ h.new_value|truncate(60) }}</td>{% endif %} {% else %}<td data-label="Oud">{{ h.old_value|truncate(60) }}</td><td data-label="Nieuw">{{ h.new_value|truncate(60) }}</td>{% endif %}
<td class="muted">{{ h.source }}{% if h.user_name %} · {{ h.user_name }}{% endif %}</td> <td data-label="Bron" class="muted">{{ h.source }}{% if h.user_name %} · {{ h.user_name }}{% endif %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table></div> </table></div>
{% else %}<p class="empty">Nog geen mutaties.</p>{% endif %} {% else %}<p class="empty">Nog geen mutaties.</p>{% endif %}
</div> </div></div>
<form method="post" action="{{ url_for('product_delete', pid=p.id) }}" onsubmit="return confirm('{{ 'Dit product staat in offertes en wordt op inactief gezet.' if used else 'Product verwijderen?' }}')"> <form method="post" action="{{ url_for('product_delete', pid=p.id) }}" onsubmit="return confirm('{{ 'Dit product staat in offertes en wordt op inactief gezet.' if used else 'Product verwijderen?' }}')">
<button class="btn quiet small">{{ 'Archiveren' if used else 'Product verwijderen' }}</button> <button class="btn btn-outline-secondary btn-sm">{{ 'Archiveren' if used else 'Product verwijderen' }}</button>
</form> </form>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+17 -17
View File
@@ -6,11 +6,11 @@
<div class="actions"> <div class="actions">
<form class="search" method="get"> <form class="search" method="get">
<input type="hidden" name="show" value="{{ show }}"> <input type="hidden" name="show" value="{{ show }}">
<input type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, SKU, leverancier…" aria-label="Zoeken"> <input class="form-control" type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, SKU, leverancier…" aria-label="Zoeken">
</form> </form>
<a class="btn ghost" href="{{ url_for('products_export') }}">Exporteer CSV</a> <a class="btn btn-outline-primary" href="{{ url_for('products_export') }}">Exporteer CSV</a>
<a class="btn ghost" href="{{ url_for('import_start', kind='products') }}">Importeren</a> <a class="btn btn-outline-primary" href="{{ url_for('import_start', kind='products') }}">Importeren</a>
<a class="btn" href="{{ url_for('product_edit') }}">Nieuw product</a> <a class="btn btn-primary" href="{{ url_for('product_edit') }}">Nieuw product</a>
</div> </div>
</div> </div>
<div class="filters"> <div class="filters">
@@ -19,26 +19,26 @@
{% endfor %} {% endfor %}
<span class="muted">{{ rows|length }} product(en)</span> <span class="muted">{{ rows|length }} product(en)</span>
</div> </div>
<div class="card"> <div class="card"><div class="card-body">
{% if rows %} {% if rows %}
<div class="table-wrap"><table> <div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th></th><th>Naam</th><th>SKU</th><th>Leverancier</th><th>Eenheid</th><th class="num">Prijs excl.</th><th class="num">Btw</th><th>Bijgewerkt</th><th>Status</th></tr></thead> <thead><tr><th class="d-none d-md-table-cell"></th><th>Naam</th><th>SKU</th><th>Leverancier</th><th>Eenheid</th><th class="num">Prijs excl.</th><th class="num">Btw</th><th>Bijgewerkt</th><th>Status</th></tr></thead>
<tbody> <tbody>
{% for p in rows %} {% for p in rows %}
<tr class="link" onclick="location='{{ url_for('product_edit', pid=p.id) }}'"> <tr class="link" onclick="location='{{ url_for('product_edit', pid=p.id) }}'">
<td style="width:64px">{% if p.image %}<img class="thumb-sm" src="{{ url_for('upload_file', name=p.image) }}" alt="">{% else %}<span class="thumb-sm"></span>{% endif %}</td> <td class="d-none d-md-table-cell" style="width:64px">{% if p.image %}<img class="thumb-sm" src="{{ url_for('upload_file', name=p.image) }}" alt="">{% else %}<span class="thumb-sm"></span>{% endif %}</td>
<td><a href="{{ url_for('product_edit', pid=p.id) }}">{{ p.name }}</a><div class="muted">{{ p.description|truncate(90) }}</div></td> <td class="stack-title"><a href="{{ url_for('product_edit', pid=p.id) }}">{{ p.name }}</a><div class="muted">{{ p.description|truncate(90) }}</div></td>
<td><code>{{ p.sku }}</code></td> <td data-label="SKU"><code>{{ p.sku }}</code></td>
<td>{% if p.supplier_id %}<a href="{{ url_for('relation_view', rid=p.supplier_id) }}" onclick="event.stopPropagation()">{{ p.supplier }}</a>{% else %}{{ p.supplier }}{% endif %}</td> <td data-label="Leverancier">{% if p.supplier_id %}<a href="{{ url_for('relation_view', rid=p.supplier_id) }}" onclick="event.stopPropagation()">{{ p.supplier }}</a>{% else %}{{ p.supplier }}{% endif %}</td>
<td>{{ p.unit }}</td> <td data-label="Eenheid">{{ p.unit }}</td>
<td class="num">{{ p.price|euro }}</td> <td data-label="Prijs excl." class="num">{{ p.price|euro }}</td>
<td class="num">{{ p.vat|nr }}%</td> <td data-label="Btw" class="num">{{ p.vat|nr }}%</td>
<td class="muted">{{ (p.updated_at or p.created_at)|dt('%d-%m-%Y') }}</td> <td data-label="Bijgewerkt" class="muted">{{ (p.updated_at or p.created_at)|dt('%d-%m-%Y') }}</td>
<td>{% if not p.active %}<span class="chip verloren">Inactief</span>{% endif %}</td> <td data-label="Status">{% if not p.active %}<span class="chip verloren">Inactief</span>{% endif %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table></div> </table></div>
{% else %}<p class="empty">{{ '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.' }}</p>{% endif %} {% else %}<p class="empty">{{ '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.' }}</p>{% endif %}
</div> </div></div>
{% endblock %} {% endblock %}
+54 -31
View File
@@ -2,8 +2,8 @@
{% block title %}Offerte {{ qt.number }}{% endblock %} {% block title %}Offerte {{ qt.number }}{% endblock %}
{% macro row(l) %} {% macro row(l) %}
<tr> <tr>
<td> <td data-label="Product">
<select name="l_product" class="prod" aria-label="Product"> <select name="l_product" class="prod form-select form-select-sm" aria-label="Product">
<option value="">Vrije regel</option> <option value="">Vrije regel</option>
{% for p in products %} {% for p in products %}
<option value="{{ p.id }}" {{ 'selected' if l.product_id == p.id }} <option value="{{ p.id }}" {{ 'selected' if l.product_id == p.id }}
@@ -12,14 +12,14 @@
{% endfor %} {% endfor %}
</select> </select>
</td> </td>
<td><textarea name="l_desc" rows="1" aria-label="Omschrijving">{{ l.description }}</textarea></td> <td data-label="Omschrijving"><textarea class="form-control form-control-sm" name="l_desc" rows="1" aria-label="Omschrijving">{{ l.description }}</textarea></td>
<td><input type="number" step="any" name="l_qty" value="{{ l.qty|default(1) }}" aria-label="Aantal"></td> <td data-label="Aantal"><input class="form-control form-control-sm" type="number" step="any" inputmode="decimal" name="l_qty" value="{{ l.qty|default(1) }}" aria-label="Aantal"></td>
<td><input name="l_unit" value="{{ l.unit }}" aria-label="Eenheid"></td> <td data-label="Eenheid"><input class="form-control form-control-sm" name="l_unit" value="{{ l.unit }}" aria-label="Eenheid"></td>
<td><input type="number" step="0.01" name="l_price" value="{{ l.price }}" aria-label="Prijs"> <td data-label="Prijs excl."><input class="form-control form-control-sm" type="number" step="0.01" inputmode="decimal" name="l_price" value="{{ l.price }}" aria-label="Prijs">
{% if l.product_id and prices.get(l.product_id) is not none and prices[l.product_id] != l.price %}<p class="hint" style="margin:.2rem 0 0">Actueel: {{ prices[l.product_id]|euro }}</p>{% endif %}</td> {% if l.product_id and prices.get(l.product_id) is not none and prices[l.product_id] != l.price %}<p class="hint" style="margin:.2rem 0 0">Actueel: {{ prices[l.product_id]|euro }}</p>{% endif %}</td>
<td><select name="l_vat" aria-label="Btw">{% for v in [21, 9, 0] %}<option value="{{ v }}" {{ 'selected' if l.vat|default(21) == v }}>{{ v }}%</option>{% endfor %}</select></td> <td data-label="Btw"><select class="form-select form-select-sm" name="l_vat" aria-label="Btw">{% for v in [21, 9, 0] %}<option value="{{ v }}" {{ 'selected' if l.vat|default(21) == v }}>{{ v }}%</option>{% endfor %}</select></td>
<td class="num amt"></td> <td class="num amt" data-label="Totaal"></td>
<td><button type="button" class="linkbtn del" aria-label="Regel verwijderen">✕</button></td> <td class="del-cell"><button type="button" class="linkbtn del" aria-label="Regel verwijderen">✕</button></td>
</tr> </tr>
{% endmacro %} {% endmacro %}
{% block content %} {% block content %}
@@ -27,52 +27,75 @@
<div class="head"> <div class="head">
<div><a class="label" href="{{ url_for('quotes') }}">← Offertes</a><h1>Offerte {{ qt.number }}</h1></div> <div><a class="label" href="{{ url_for('quotes') }}">← Offertes</a><h1>Offerte {{ qt.number }}</h1></div>
<div class="actions"> <div class="actions">
<button class="btn ghost" name="then" value="print">Opslaan en afdrukken</button> <button class="btn btn-outline-primary" name="then" value="print">Opslaan en afdrukken</button>
<button class="btn">Opslaan</button> <button class="btn btn-primary">Opslaan</button>
</div> </div>
</div> </div>
<div class="card"> <div class="card"><div class="card-body">
<div class="fields"> <div class="row g-3">
<div><label for="contact">Klant</label> <div class="col-md-6"><label class="form-label" for="contact">Klant</label>
<select id="contact" name="contact_id"> <select class="form-select" id="contact" name="contact_id">
<option value="">— Kies een contact —</option> <option value="">— Kies een contact —</option>
{% for c in contacts %}<option value="{{ c.id }}" {{ 'selected' if c.id == qt.contact_id }}>{{ c.name }}{% if c.company %} ({{ c.company }}){% endif %}</option>{% endfor %} {% for c in contacts %}<option value="{{ c.id }}" {{ 'selected' if c.id == qt.contact_id }}>{{ c.name }}{% if c.company %} ({{ c.company }}){% endif %}</option>{% endfor %}
</select> </select>
</div> </div>
<div><label for="status">Status</label> <div class="col-md-6"><label class="form-label" for="status">Status</label>
<select id="status" name="status">{% for k, l in QUOTE_STATUSES %}<option value="{{ k }}" {{ 'selected' if qt.status == k }}>{{ l }}</option>{% endfor %}</select> <select class="form-select" id="status" name="status">{% for k, l in QUOTE_STATUSES %}<option value="{{ k }}" {{ 'selected' if qt.status == k }}>{{ l }}</option>{% endfor %}</select>
<p class="hint">Op "Verzonden" zetten = zichtbaar in het klantportaal.</p> <p class="hint">Op "Verzonden" zetten = zichtbaar in het klantportaal.</p>
</div> </div>
<div><label for="date">Datum</label><input id="date" type="date" name="date" value="{{ qt.date }}"></div> <div class="col-6"><label class="form-label" for="date">Datum</label><input class="form-control" id="date" type="date" name="date" value="{{ qt.date }}"></div>
<div><label for="valid">Geldig tot</label><input id="valid" type="date" name="valid_until" value="{{ qt.valid_until or '' }}"></div> <div class="col-6"><label class="form-label" for="valid">Geldig tot</label><input class="form-control" id="valid" type="date" name="valid_until" value="{{ qt.valid_until or '' }}"></div>
<div class="full"><label for="title">Onderwerp</label><input id="title" name="title" value="{{ qt.title }}" placeholder="Bijv. Mantelzorgwoning model Amstel"></div> <div class="col-12"><label class="form-label" for="title">Onderwerp</label><input class="form-control" id="title" name="title" value="{{ qt.title }}" placeholder="Bijv. Mantelzorgwoning model Amstel"></div>
<div class="full"><label for="intro">Inleiding</label><textarea id="intro" name="intro">{{ qt.intro }}</textarea></div> <div class="col-12"><label class="form-label" for="intro">Inleiding</label><textarea class="form-control" id="intro" name="intro">{{ qt.intro }}</textarea></div>
</div> </div>
</div> </div></div>
<div class="card"> <div class="card"><div class="card-body">
<h2>Regels</h2> <h2>Regels</h2>
<div class="table-wrap"><table class="lines"> <div class="table-responsive"><table class="table lines">
<thead><tr><th style="min-width:170px">Product</th><th style="min-width:240px">Omschrijving</th><th style="width:90px">Aantal</th><th style="width:100px">Eenheid</th><th style="width:130px">Prijs excl.</th><th style="width:90px">Btw</th><th class="num" style="width:120px">Totaal</th><th></th></tr></thead> <thead><tr><th style="min-width:170px">Product</th><th style="min-width:240px">Omschrijving</th><th style="width:90px">Aantal</th><th style="width:100px">Eenheid</th><th style="width:130px">Prijs excl.</th><th style="width:90px">Btw</th><th class="num" style="width:120px">Totaal</th><th></th></tr></thead>
<tbody id="rows">{% for l in lines %}{{ row(l) }}{% endfor %}</tbody> <tbody id="rows">{% for l in lines %}{{ row(l) }}{% endfor %}</tbody>
</table></div> </table></div>
<div class="actions"><button type="button" class="btn ghost small" id="add">Regel toevoegen</button></div> <div class="actions"><button type="button" class="btn btn-outline-primary btn-sm" id="add">Regel toevoegen</button></div>
<table class="totals"> <table class="totals">
<tr><td>Subtotaal</td><td class="num" id="t-sub"></td></tr> <tr><td>Subtotaal</td><td class="num" id="t-sub"></td></tr>
<tbody id="t-vat"></tbody> <tbody id="t-vat"></tbody>
<tr class="grand"><td>Totaal</td><td class="num" id="t-total"></td></tr> <tr class="grand"><td>Totaal</td><td class="num" id="t-total"></td></tr>
</table> </table>
</div> </div></div>
<div class="card"> <div class="card"><div class="card-body">
<label for="notes">Opmerkingen en voorwaarden</label> <label class="form-label" for="notes">Opmerkingen en voorwaarden</label>
<textarea id="notes" name="notes">{{ qt.notes }}</textarea> <textarea class="form-control" id="notes" name="notes">{{ qt.notes }}</textarea>
</div> </div></div>
</form> </form>
<div class="card"><div class="card-body">
<div class="card-top">
<h2>Bestek uit ontwerp</h2>
<div class="actions">
{% if bestek %}
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('quote_print', qid=qt.id) }}#bestek">Bekijken</a>
{% if designs %}<a class="btn btn-outline-primary btn-sm" href="{{ url_for('quote_bestek', qid=qt.id) }}">Opnieuw berekenen</a>{% endif %}
<form method="post" action="{{ url_for('quote_bestek_delete', qid=qt.id) }}" class="inline" onsubmit="return confirm('Bestek loskoppelen? De offerteregels blijven staan.')"><button class="btn btn-outline-secondary btn-sm">Loskoppelen</button></form>
{% elif designs %}
<a class="btn btn-primary btn-sm" href="{{ url_for('quote_bestek', qid=qt.id) }}">Bestek maken</a>
{% endif %}
</div>
</div>
{% if bestek %}
<p class="muted" style="margin:0">{{ 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.</p>
{% elif designs %}
<p class="muted" style="margin:0">Hoeveelheden en prijzen uit het gekozen model van de klant (gevel, dak, kozijnen, stopcontacten, leidingen, …) als offerteregels en bijlage.</p>
{% else %}
<p class="empty" style="padding:0">Kies (en sla op) een klant met een gekozen model om een bestek uit het ontwerp te maken.</p>
{% endif %}
</div></div>
<form method="post" action="{{ url_for('quote_delete', qid=qt.id) }}" onsubmit="return confirm('Offerte verwijderen?')"> <form method="post" action="{{ url_for('quote_delete', qid=qt.id) }}" onsubmit="return confirm('Offerte verwijderen?')">
<button class="btn quiet small">Offerte verwijderen</button> <button class="btn btn-outline-secondary btn-sm">Offerte verwijderen</button>
</form> </form>
<template id="tpl">{{ row({}) }}</template> <template id="tpl">{{ row({}) }}</template>
+23
View File
@@ -40,6 +40,13 @@
.notes { margin-top: 32px; } .notes { margin-top: 32px; }
.sign { margin-top: 40px; display: flex; gap: 48px; } .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; } .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; } 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 { @media print {
body { background: #fff; } body { background: #fff; }
@@ -124,6 +131,22 @@
{% if s.quote_footer %}<div style="white-space:pre-line;margin-bottom:6px">{{ s.quote_footer }}</div>{% endif %} {% if s.quote_footer %}<div style="white-space:pre-line;margin-bottom:6px">{{ s.quote_footer }}</div>{% 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(' · ') }} {{ [('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(' · ') }}
</footer> </footer>
{% if bestek %}
<section class="bestek" id="bestek">
<h1>Bestek{% if bestek.design.title %}: {{ bestek.design.title }}{% endif %}</h1>
<p class="intro">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.</p>
{% for ch in bestek.chapters %}
<h2><span>{{ loop.index }}. {{ ch.name }}</span></h2>
<table><tbody>
{% for l in ch.lines %}<tr><td>{{ l.label }}</td><td class="num">{{ l.qty|nr }} {{ l.unit }}</td></tr>{% endfor %}
{% for l in ch.info %}<tr class="info"><td>{{ l.label }}</td><td class="num">{{ l.qty|nr }} {{ l.unit }}</td></tr>{% endfor %}
</tbody></table>
{% endfor %}
</section>
{% endif %}
</div> </div>
</body> </body>
</html> </html>
+12 -10
View File
@@ -3,29 +3,31 @@
{% block content %} {% block content %}
<div class="head"> <div class="head">
<div><div class="kicker">Verkoop</div><h1>Offertes</h1></div> <div><div class="kicker">Verkoop</div><h1>Offertes</h1></div>
<form method="post" action="{{ url_for('quote_new') }}" class="inline"><button class="btn">Nieuwe offerte</button></form> <div class="actions">
<form method="post" action="{{ url_for('quote_new') }}" class="inline"><button class="btn btn-primary">Nieuwe offerte</button></form>
</div>
</div> </div>
<div class="filters"> <div class="filters">
<a href="{{ url_for('quotes') }}" class="{{ 'on' if not status }}">Alle</a> <a href="{{ url_for('quotes') }}" class="{{ 'on' if not status }}">Alle</a>
{% for k, l in QUOTE_STATUSES %}<a href="{{ url_for('quotes', status=k) }}" class="{{ 'on' if status == k }}">{{ l }}</a>{% endfor %} {% for k, l in QUOTE_STATUSES %}<a href="{{ url_for('quotes', status=k) }}" class="{{ 'on' if status == k }}">{{ l }}</a>{% endfor %}
</div> </div>
<div class="card"> <div class="card"><div class="card-body">
{% if rows %} {% if rows %}
<div class="table-wrap"><table> <div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th>Nummer</th><th>Klant</th><th>Onderwerp</th><th>Datum</th><th>Status</th><th class="num">Totaal incl.</th></tr></thead> <thead><tr><th>Nummer</th><th>Klant</th><th>Onderwerp</th><th>Datum</th><th>Status</th><th class="num">Totaal incl.</th></tr></thead>
<tbody> <tbody>
{% for qt in rows %} {% for qt in rows %}
<tr class="link" onclick="location='{{ url_for('quote_edit', qid=qt.id) }}'"> <tr class="link" onclick="location='{{ url_for('quote_edit', qid=qt.id) }}'">
<td><a href="{{ url_for('quote_edit', qid=qt.id) }}">{{ qt.number }}</a></td> <td class="stack-title"><a href="{{ url_for('quote_edit', qid=qt.id) }}">{{ qt.number }}</a></td>
<td>{{ qt.contact_name or '—' }}</td> <td data-label="Klant">{{ qt.contact_name or '—' }}</td>
<td>{{ qt.title }}</td> <td data-label="Onderwerp">{{ qt.title }}</td>
<td>{{ qt.date|dt('%d-%m-%Y') }}</td> <td data-label="Datum">{{ qt.date|dt('%d-%m-%Y') }}</td>
<td><span class="chip {{ qt.status }}">{{ QSTATUS.get(qt.status, qt.status) }}</span></td> <td data-label="Status"><span class="chip {{ qt.status }}">{{ QSTATUS.get(qt.status, qt.status) }}</span></td>
<td class="num">{{ qt.total|euro }}</td> <td data-label="Totaal incl." class="num">{{ qt.total|euro }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table></div> </table></div>
{% else %}<p class="empty">Nog geen offertes.</p>{% endif %} {% else %}<p class="empty">Nog geen offertes.</p>{% endif %}
</div> </div></div>
{% endblock %} {% endblock %}
+54 -32
View File
@@ -11,16 +11,16 @@
{% if r.kind == 'klant' and members %} {% if r.kind == 'klant' and members %}
<form method="post" action="{{ url_for('quote_new') }}" class="inline"> <form method="post" action="{{ url_for('quote_new') }}" class="inline">
<input type="hidden" name="contact_id" value="{{ members[0].id }}"> <input type="hidden" name="contact_id" value="{{ members[0].id }}">
<button class="btn ghost">Nieuwe offerte</button> <button class="btn btn-outline-primary">Nieuwe offerte</button>
</form> </form>
{% endif %} {% endif %}
<a class="btn" href="{{ url_for('relation_edit', rid=r.id) }}">Bewerken</a> <a class="btn btn-primary" href="{{ url_for('relation_edit', rid=r.id) }}">Bewerken</a>
</div> </div>
</div> </div>
<div class="two"> <div class="row gx-4 stack">
<div> <div class="col-lg-7 col-xl-8">
<div class="card"> <div class="card o-2"><div class="card-body">
<h2>Contactpersonen</h2> <h2>Contactpersonen</h2>
<div class="members"> <div class="members">
{% for m in members %} {% for m in members %}
@@ -28,9 +28,9 @@
<div> <div>
<a href="{{ url_for('contact_view', cid=m.id) }}">{{ m.name }}</a> <a href="{{ url_for('contact_view', cid=m.id) }}">{{ m.name }}</a>
{% if m.relation_role %}<span class="muted">· {{ m.relation_role }}</span>{% endif %} {% if m.relation_role %}<span class="muted">· {{ m.relation_role }}</span>{% endif %}
<div class="muted">{{ [m.email, m.phone]|select|join(' · ') }}</div> <div class="muted" style="overflow-wrap:anywhere">{{ [m.email, m.phone]|select|join(' · ') }}</div>
</div> </div>
<div class="actions" style="margin:0"> <div class="d-flex align-items-center gap-3">
{% if m.type != 'partner' %}<span class="chip {{ m.status }}">{{ STATUS.get(m.status) }}</span>{% endif %} {% if m.type != 'partner' %}<span class="chip {{ m.status }}">{{ STATUS.get(m.status) }}</span>{% endif %}
<form method="post" action="{{ url_for('relation_member_remove', rid=r.id, cid=m.id) }}" class="inline" onsubmit="return confirm('{{ m.name }} loskoppelen van {{ r.name }}?')"> <form method="post" action="{{ url_for('relation_member_remove', rid=r.id, cid=m.id) }}" class="inline" onsubmit="return confirm('{{ m.name }} loskoppelen van {{ r.name }}?')">
<button class="linkbtn">Loskoppelen</button> <button class="linkbtn">Loskoppelen</button>
@@ -41,41 +41,63 @@
<p class="empty">Nog geen contactpersonen.</p> <p class="empty">Nog geen contactpersonen.</p>
{% endfor %} {% endfor %}
</div> </div>
<form method="post" action="{{ url_for('relation_member', rid=r.id) }}" class="actions"> <form method="post" action="{{ url_for('relation_member', rid=r.id) }}" class="row g-2 align-items-center mt-3">
{% if candidates %} {% if candidates %}
<select name="contact_id" style="width:auto;flex:1;min-width:180px" aria-label="Bestaand contact" required> <div class="col-md">
<option value="">Bestaand contact toevoegen…</option> <select class="form-select" name="contact_id" aria-label="Bestaand contact" required>
{% for c in candidates %}<option value="{{ c.id }}">{{ c.name }}{% if c.email %} ({{ c.email }}){% endif %}</option>{% endfor %} <option value="">Bestaand contact toevoegen…</option>
</select> {% for c in candidates %}<option value="{{ c.id }}">{{ c.name }}{% if c.email %} ({{ c.email }}){% endif %}</option>{% endfor %}
<input name="relation_role" placeholder="Rol" style="width:160px" aria-label="Rol"> </select>
<button class="btn ghost small">Toevoegen</button> </div>
<div class="col-8 col-md-3"><input class="form-control" name="relation_role" placeholder="Rol" aria-label="Rol"></div>
<div class="col-4 col-md-auto"><button class="btn btn-outline-primary w-100">Toevoegen</button></div>
{% endif %} {% endif %}
<a class="btn small" href="{{ url_for('contact_new', relation_id=r.id, company=r.name if r.kind == 'partner' else None) }}">Nieuwe contactpersoon</a> <div class="col-12"><a class="btn btn-primary btn-sm" href="{{ url_for('contact_new', relation_id=r.id, company=r.name if r.kind == 'partner' else None) }}">Nieuwe contactpersoon</a></div>
</form> </form>
</div> </div></div>
{% if designs %}
<div class="card"><div class="card-body">
<h2>Ontwerpen en tekeningen</h2>
<div class="table-responsive"><table class="table table-stack">
<thead><tr><th>Ontwerp</th><th>Contact</th><th>Tekeningen</th><th>Ontvangen</th></tr></thead>
<tbody>
{% for d in designs %}{% set p = pdfs.get(d.id, {}) %}
<tr>
<td class="stack-title"><a href="{{ url_for('design_viewer', fid=d.id) }}" target="_blank">{{ d.title }}</a>{% if d.chosen %} <span class="chip">Gekozen model</span>{% endif %}</td>
<td data-label="Contact"><a href="{{ url_for('contact_view', cid=d.contact_id) }}">{{ d.contact_name }}</a></td>
<td data-label="Tekeningen"><span>{% for k, label in PDF_KINDS.items() %}{% if p.get(k) %}<a href="{{ p[k].url }}" target="_blank">{{ (label[:1]|upper) ~ label[1:] }}</a>{% if not loop.last %} · {% endif %}{% endif %}{% endfor %}
{% if not p %}<span class="muted">Wordt gemaakt bij openen van het contact</span>{% endif %}</span></td>
<td data-label="Ontvangen" class="muted">{{ d.created_at|dt('%d-%m-%Y') }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
</div></div>
{% endif %}
{% if r.kind == 'partner' %} {% if r.kind == 'partner' %}
<div class="card"> <div class="card"><div class="card-body">
<h2>Producten van deze leverancier</h2> <h2>Producten van deze leverancier</h2>
{% if products %} {% if products %}
<div class="table-wrap"><table> <div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th>SKU</th><th>Naam</th><th class="num">Prijs excl.</th><th>Bijgewerkt</th></tr></thead> <thead><tr><th>SKU</th><th>Naam</th><th class="num">Prijs excl.</th><th>Bijgewerkt</th></tr></thead>
<tbody> <tbody>
{% for p in products %} {% for p in products %}
<tr class="link" onclick="location='{{ url_for('product_edit', pid=p.id) }}'"> <tr class="link" onclick="location='{{ url_for('product_edit', pid=p.id) }}'">
<td><code>{{ p.sku }}</code></td> <td data-label="SKU"><code>{{ p.sku }}</code></td>
<td><a href="{{ url_for('product_edit', pid=p.id) }}">{{ p.name }}</a>{% if not p.active %} <span class="chip verloren">Inactief</span>{% endif %}</td> <td class="stack-title"><a href="{{ url_for('product_edit', pid=p.id) }}">{{ p.name }}</a>{% if not p.active %} <span class="chip verloren">Inactief</span>{% endif %}</td>
<td class="num">{{ p.price|euro }}</td> <td data-label="Prijs excl." class="num">{{ p.price|euro }}</td>
<td class="muted">{{ (p.updated_at or p.created_at)|dt('%d-%m-%Y') }}</td> <td data-label="Bijgewerkt" class="muted">{{ (p.updated_at or p.created_at)|dt('%d-%m-%Y') }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table></div> </table></div>
{% else %}<p class="empty">Nog geen producten gekoppeld. Vul deze naam in als leverancier bij een product of in een import.</p>{% endif %} {% else %}<p class="empty">Nog geen producten gekoppeld. Vul deze naam in als leverancier bij een product of in een import.</p>{% endif %}
</div> </div></div>
{% endif %} {% endif %}
<div class="card"> <div class="card"><div class="card-body">
<h2>Recente activiteit</h2> <h2>Recente activiteit</h2>
<ul class="timeline"> <ul class="timeline">
{% for a in activities %} {% for a in activities %}
@@ -87,11 +109,11 @@
<li class="empty">Nog geen activiteit.</li> <li class="empty">Nog geen activiteit.</li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div></div>
</div> </div>
<div> <div class="col-lg-5 col-xl-4">
<div class="card"> <div class="card o-1"><div class="card-body">
<h2>Gegevens</h2> <h2>Gegevens</h2>
<dl class="props"> <dl class="props">
{% if r.type %}<dt>Type</dt><dd>{{ r.type }}</dd>{% endif %} {% if r.type %}<dt>Type</dt><dd>{{ r.type }}</dd>{% endif %}
@@ -103,10 +125,10 @@
<dt>Aangemaakt</dt><dd>{{ r.created_at|dt('%d-%m-%Y') }}</dd> <dt>Aangemaakt</dt><dd>{{ r.created_at|dt('%d-%m-%Y') }}</dd>
</dl> </dl>
{% if r.notes %}<h3>Notities</h3><p style="white-space:pre-line;margin:0">{{ r.notes }}</p>{% endif %} {% if r.notes %}<h3>Notities</h3><p style="white-space:pre-line;margin:0">{{ r.notes }}</p>{% endif %}
</div> </div></div>
{% if tasks %} {% if tasks %}
<div class="card"> <div class="card"><div class="card-body">
<h2>Open taken</h2> <h2>Open taken</h2>
{% for t in tasks %} {% for t in tasks %}
<form method="post" action="{{ url_for('task_done', tid=t.id) }}" class="task"> <form method="post" action="{{ url_for('task_done', tid=t.id) }}" class="task">
@@ -114,11 +136,11 @@
<div><div>{{ t.title }}</div><div class="muted">{{ t.contact_name }}{% if t.due_date %} · <span class="{{ 'overdue' if t.due_date < today }}">{{ t.due_date|dt('%d-%m-%Y') }}</span>{% endif %}</div></div> <div><div>{{ t.title }}</div><div class="muted">{{ t.contact_name }}{% if t.due_date %} · <span class="{{ 'overdue' if t.due_date < today }}">{{ t.due_date|dt('%d-%m-%Y') }}</span>{% endif %}</div></div>
</form> </form>
{% endfor %} {% endfor %}
</div> </div></div>
{% endif %} {% endif %}
{% if r.kind == 'klant' %} {% if r.kind == 'klant' %}
<div class="card"> <div class="card"><div class="card-body">
<h2>Offertes</h2> <h2>Offertes</h2>
{% for qt in quotes %} {% for qt in quotes %}
<p style="margin:0 0 .6rem"><a href="{{ url_for('quote_edit', qid=qt.id) }}">{{ qt.number }}</a> <span class="chip {{ qt.status }}">{{ QSTATUS.get(qt.status) }}</span><br> <p style="margin:0 0 .6rem"><a href="{{ url_for('quote_edit', qid=qt.id) }}">{{ qt.number }}</a> <span class="chip {{ qt.status }}">{{ QSTATUS.get(qt.status) }}</span><br>
@@ -126,7 +148,7 @@
{% else %} {% else %}
<p class="empty">Nog geen offertes.</p> <p class="empty">Nog geen offertes.</p>
{% endfor %} {% endfor %}
</div> </div></div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
+19 -19
View File
@@ -7,33 +7,33 @@
<h1>{{ 'Relatie bewerken' if r.id else ('Nieuwe ' ~ RKIND.get(r.kind, 'relatie')|lower) }}</h1> <h1>{{ 'Relatie bewerken' if r.id else ('Nieuwe ' ~ RKIND.get(r.kind, 'relatie')|lower) }}</h1>
</div> </div>
</div> </div>
<form method="post" class="card"> <form method="post" class="card"><div class="card-body">
<div class="fields"> <div class="row g-3">
<div class="full"><label for="name">Naam *</label><input id="name" name="name" value="{{ r.name }}" required placeholder="{{ 'Bijv. Familie Jansen' if r.kind == 'klant' else 'Bijv. Houthandel De Vries' }}"></div> <div class="col-12"><label class="form-label" for="name">Naam *</label><input class="form-control" id="name" name="name" value="{{ r.name }}" required placeholder="{{ 'Bijv. Familie Jansen' if r.kind == 'klant' else 'Bijv. Houthandel De Vries' }}"></div>
<div><label for="kind">Soort</label> <div class="col-md-6"><label class="form-label" for="kind">Soort</label>
<select id="kind" name="kind">{% for k, l in RELATION_KINDS %}<option value="{{ k }}" {{ 'selected' if r.kind == k }}>{{ l }}</option>{% endfor %}</select> <select class="form-select" id="kind" name="kind">{% for k, l in RELATION_KINDS %}<option value="{{ k }}" {{ 'selected' if r.kind == k }}>{{ l }}</option>{% endfor %}</select>
</div> </div>
<div><label for="type">Type</label> <div class="col-md-6"><label class="form-label" for="type">Type</label>
<input id="type" name="type" value="{{ r.type }}" list="types" autocomplete="off"> <input class="form-control" id="type" name="type" value="{{ r.type }}" list="types" autocomplete="off">
<datalist id="types">{% for t in types %}<option value="{{ t }}">{% endfor %}</datalist> <datalist id="types">{% for t in types %}<option value="{{ t }}">{% endfor %}</datalist>
</div> </div>
<div><label for="email">E-mail</label><input id="email" type="email" name="email" value="{{ r.email }}"></div> <div class="col-md-6"><label class="form-label" for="email">E-mail</label><input class="form-control" id="email" type="email" name="email" value="{{ r.email }}"></div>
<div><label for="phone">Telefoon</label><input id="phone" name="phone" value="{{ r.phone }}"></div> <div class="col-md-6"><label class="form-label" for="phone">Telefoon</label><input class="form-control" id="phone" type="tel" name="phone" value="{{ r.phone }}"></div>
<div class="full"><label for="address">Adres</label><input id="address" name="address" value="{{ r.address }}"></div> <div class="col-12"><label class="form-label" for="address">Adres</label><input class="form-control" id="address" name="address" value="{{ r.address }}"></div>
<div><label for="postcode">Postcode</label><input id="postcode" name="postcode" value="{{ r.postcode }}"></div> <div class="col-sm-4"><label class="form-label" for="postcode">Postcode</label><input class="form-control" id="postcode" name="postcode" value="{{ r.postcode }}"></div>
<div><label for="city">Plaats</label><input id="city" name="city" value="{{ r.city }}"></div> <div class="col-sm-8"><label class="form-label" for="city">Plaats</label><input class="form-control" id="city" name="city" value="{{ r.city }}"></div>
<div><label for="website">Website</label><input id="website" name="website" value="{{ r.website }}"></div> <div class="col-md-6"><label class="form-label" for="website">Website</label><input class="form-control" id="website" name="website" value="{{ r.website }}"></div>
<div><label for="kvk">KvK-nummer</label><input id="kvk" name="kvk" value="{{ r.kvk }}"></div> <div class="col-md-6"><label class="form-label" for="kvk">KvK-nummer</label><input class="form-control" id="kvk" name="kvk" value="{{ r.kvk }}"></div>
<div class="full"><label for="notes">Notities</label><textarea id="notes" name="notes">{{ r.notes }}</textarea></div> <div class="col-12"><label class="form-label" for="notes">Notities</label><textarea class="form-control" id="notes" name="notes">{{ r.notes }}</textarea></div>
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn">Opslaan</button> <button class="btn btn-primary">Opslaan</button>
<a class="btn quiet" href="{{ url_for('relation_view', rid=r.id) if r.id else url_for('relations', kind=r.kind) }}">Annuleren</a> <a class="btn btn-outline-secondary" href="{{ url_for('relation_view', rid=r.id) if r.id else url_for('relations', kind=r.kind) }}">Annuleren</a>
</div> </div>
</form> </div></form>
{% if r.id %} {% if r.id %}
<form method="post" action="{{ url_for('relation_delete', rid=r.id) }}" onsubmit="return confirm('Relatie verwijderen? Contactpersonen en producten blijven bestaan.')"> <form method="post" action="{{ url_for('relation_delete', rid=r.id) }}" onsubmit="return confirm('Relatie verwijderen? Contactpersonen en producten blijven bestaan.')">
<button class="btn quiet small">Relatie verwijderen</button> <button class="btn btn-outline-secondary btn-sm">Relatie verwijderen</button>
</form> </form>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+12 -12
View File
@@ -11,10 +11,10 @@
<form class="search" method="get"> <form class="search" method="get">
<input type="hidden" name="kind" value="{{ kind }}"> <input type="hidden" name="kind" value="{{ kind }}">
{% if type %}<input type="hidden" name="type" value="{{ type }}">{% endif %} {% if type %}<input type="hidden" name="type" value="{{ type }}">{% endif %}
<input type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, plaats, persoon…" aria-label="Zoeken"> <input class="form-control" type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, plaats, persoon…" aria-label="Zoeken">
</form> </form>
<a class="btn ghost" href="{{ url_for('import_start', kind='relations') }}">Importeren</a> <a class="btn btn-outline-primary" href="{{ url_for('import_start', kind='relations') }}">Importeren</a>
<a class="btn" href="{{ url_for('relation_edit', kind=kind, type=type or None) }}">{{ 'Nieuwe klant' if kind == 'klant' else 'Nieuwe partner' }}</a> <a class="btn btn-primary" href="{{ url_for('relation_edit', kind=kind, type=type or None) }}">{{ 'Nieuwe klant' if kind == 'klant' else 'Nieuwe partner' }}</a>
</div> </div>
</div> </div>
@@ -25,23 +25,23 @@
{% endfor %} {% endfor %}
</div> </div>
<div class="card"> <div class="card"><div class="card-body">
{% if rows %} {% if rows %}
<div class="table-wrap"><table> <div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th>Naam</th><th>Type</th><th>Contactpersonen</th><th>Plaats</th><th>Contact</th>{% if kind == 'partner' %}<th class="num">Producten</th>{% endif %}</tr></thead> <thead><tr><th>Naam</th><th>Type</th><th>Contactpersonen</th><th>Plaats</th><th>Contact</th>{% if kind == 'partner' %}<th class="num">Producten</th>{% endif %}</tr></thead>
<tbody> <tbody>
{% for r in rows %} {% for r in rows %}
<tr class="link" onclick="location='{{ url_for('relation_view', rid=r.id) }}'"> <tr class="link" onclick="location='{{ url_for('relation_view', rid=r.id) }}'">
<td><a href="{{ url_for('relation_view', rid=r.id) }}">{{ r.name }}</a></td> <td class="stack-title"><a href="{{ url_for('relation_view', rid=r.id) }}">{{ r.name }}</a></td>
<td>{% if r.type %}<span class="chip">{{ r.type }}</span>{% endif %}</td> <td data-label="Type">{% if r.type %}<span class="chip">{{ r.type }}</span>{% endif %}</td>
<td>{{ r.member_names or '—' }}{% if r.members > 4 %} <span class="muted">+{{ r.members - 4 }}</span>{% endif %}</td> <td data-label="Personen"><span>{{ r.member_names or '—' }}{% if r.members > 4 %} <span class="muted">+{{ r.members - 4 }}</span>{% endif %}</span></td>
<td>{{ r.city }}</td> <td data-label="Plaats">{{ r.city }}</td>
<td class="muted">{{ [r.phone, r.email]|select|join(' · ') }}</td> <td data-label="Contact" class="muted">{{ [r.phone, r.email]|select|join(' · ') }}</td>
{% if kind == 'partner' %}<td class="num">{{ r.products or '' }}</td>{% endif %} {% if kind == 'partner' %}<td data-label="Producten" class="num">{{ r.products or '' }}</td>{% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table></div> </table></div>
{% else %}<p class="empty">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.' }}</p>{% endif %} {% else %}<p class="empty">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.' }}</p>{% endif %}
</div> </div></div>
{% endblock %} {% endblock %}
+32 -32
View File
@@ -1,16 +1,16 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}Instellingen{% endblock %} {% block title %}Instellingen{% endblock %}
{% macro field(key, label, type='text', placeholder='', full=false) %} {% macro field(key, label, type='text', placeholder='', full=false) %}
<div class="{{ 'full' if full }}"><label for="{{ key }}">{{ label }}</label><input id="{{ key }}" type="{{ type }}" name="{{ key }}" value="{{ s.get(key, '') }}" placeholder="{{ placeholder }}"></div> <div class="{{ 'col-12' if full else 'col-md-6 col-xl-4' }}"><label class="form-label" for="{{ key }}">{{ label }}</label><input class="form-control" id="{{ key }}" type="{{ type }}" name="{{ key }}" value="{{ s.get(key, '') }}" placeholder="{{ placeholder }}"></div>
{% endmacro %} {% endmacro %}
{% block content %} {% block content %}
<div class="head"><div><div class="kicker">Beheer</div><h1>Instellingen</h1></div></div> <div class="head"><div><div class="kicker">Beheer</div><h1>Instellingen</h1></div></div>
<form method="post"> <form method="post">
<div class="card"> <div class="card"><div class="card-body">
<h2>Bedrijfsgegevens</h2> <h2>Bedrijfsgegevens</h2>
<p class="hint" style="margin-top:-.4rem;margin-bottom:1rem">Deze gegevens staan op de offertes.</p> <p class="hint" style="margin:-.4rem 0 1rem">Deze gegevens staan op de offertes.</p>
<div class="fields"> <div class="row g-3">
{{ field('company_name', 'Bedrijfsnaam', full=true) }} {{ field('company_name', 'Bedrijfsnaam', full=true) }}
{{ field('company_address', 'Adres') }} {{ field('company_address', 'Adres') }}
{{ field('company_postcode_city', 'Postcode en plaats') }} {{ field('company_postcode_city', 'Postcode en plaats') }}
@@ -21,66 +21,66 @@
{{ field('company_btw', 'Btw-nummer') }} {{ field('company_btw', 'Btw-nummer') }}
{{ field('company_iban', 'IBAN') }} {{ field('company_iban', 'IBAN') }}
</div> </div>
</div> </div></div>
<div class="card"> <div class="card"><div class="card-body">
<h2>Offertes</h2> <h2>Offertes</h2>
<div class="fields"> <div class="row g-3">
{{ field('quote_prefix', 'Voorvoegsel nummer', placeholder='OFF') }} {{ field('quote_prefix', 'Voorvoegsel nummer', placeholder='OFF') }}
{{ field('quote_valid_days', 'Standaard geldig (dagen)', 'number') }} {{ field('quote_valid_days', 'Standaard geldig (dagen)', 'number') }}
<div class="full"><label for="quote_intro">Standaard inleiding</label><textarea id="quote_intro" name="quote_intro">{{ s.quote_intro }}</textarea></div> <div class="col-12"><label class="form-label" for="quote_intro">Standaard inleiding</label><textarea class="form-control" id="quote_intro" name="quote_intro">{{ s.quote_intro }}</textarea></div>
<div class="full"><label for="quote_footer">Voettekst (bijv. betalingsvoorwaarden)</label><textarea id="quote_footer" name="quote_footer">{{ s.quote_footer }}</textarea></div> <div class="col-12"><label class="form-label" for="quote_footer">Voettekst (bijv. betalingsvoorwaarden)</label><textarea class="form-control" id="quote_footer" name="quote_footer">{{ s.quote_footer }}</textarea></div>
</div> </div>
</div> </div></div>
<div class="card" id="mail"> <div class="card" id="mail"><div class="card-body">
<h2>E-mail koppelen</h2> <h2>E-mail koppelen</h2>
<p class="hint" style="margin-top:-.4rem;margin-bottom:1rem">Gebruik voor Gmail of Microsoft 365 een app-wachtwoord. Wachtwoorden staan in de database: houd de map <code>data/</code> privé.</p> <p class="hint" style="margin:-.4rem 0 1rem">Gebruik voor Gmail of Microsoft 365 een app-wachtwoord. Wachtwoorden staan in de database: houd de map <code>data/</code> privé.</p>
<h3>Ontvangen (IMAP)</h3> <h3>Ontvangen (IMAP)</h3>
<div class="fields"> <div class="row g-3">
{{ field('imap_host', 'Server', placeholder='imap.gmail.com') }} {{ field('imap_host', 'Server', placeholder='imap.gmail.com') }}
{{ field('imap_port', 'Poort', 'number', '993') }} {{ field('imap_port', 'Poort', 'number', '993') }}
{{ field('imap_user', 'Gebruikersnaam', placeholder='info@vanemous.nl') }} {{ field('imap_user', 'Gebruikersnaam', placeholder='info@vanemous.nl') }}
<div><label for="imap_pass">Wachtwoord</label><input id="imap_pass" type="password" name="imap_pass" placeholder="{{ '•••••••• (ingesteld)' if s.imap_pass else '' }}" autocomplete="new-password"></div> <div class="col-md-6 col-xl-4"><label class="form-label" for="imap_pass">Wachtwoord</label><input class="form-control" id="imap_pass" type="password" name="imap_pass" placeholder="{{ '•••••••• (ingesteld)' if s.imap_pass else '' }}" autocomplete="new-password"></div>
{{ field('imap_folder', 'Map', placeholder='INBOX') }} {{ field('imap_folder', 'Map', placeholder='INBOX') }}
{{ field('imap_days', 'Aantal dagen terug ophalen', 'number', '30') }} {{ field('imap_days', 'Aantal dagen terug ophalen', 'number', '30') }}
</div> </div>
<h3>Versturen (SMTP)</h3> <h3>Versturen (SMTP)</h3>
<div class="fields"> <div class="row g-3">
{{ field('smtp_host', 'Server', placeholder='smtp.gmail.com') }} {{ field('smtp_host', 'Server', placeholder='smtp.gmail.com') }}
{{ field('smtp_port', 'Poort', 'number', '587') }} {{ field('smtp_port', 'Poort', 'number', '587') }}
{{ field('smtp_user', 'Gebruikersnaam', placeholder='leeg = zelfde als IMAP') }} {{ field('smtp_user', 'Gebruikersnaam', placeholder='leeg = zelfde als IMAP') }}
<div><label for="smtp_pass">Wachtwoord</label><input id="smtp_pass" type="password" name="smtp_pass" placeholder="{{ '•••••••• (ingesteld)' if s.smtp_pass else 'leeg = zelfde als IMAP' }}" autocomplete="new-password"></div> <div class="col-md-6 col-xl-4"><label class="form-label" for="smtp_pass">Wachtwoord</label><input class="form-control" id="smtp_pass" type="password" name="smtp_pass" placeholder="{{ '•••••••• (ingesteld)' if s.smtp_pass else 'leeg = zelfde als IMAP' }}" autocomplete="new-password"></div>
{{ field('smtp_from', 'Afzender', placeholder='Van Emous <info@vanemous.nl>', full=true) }} {{ field('smtp_from', 'Afzender', placeholder='Van Emous <info@vanemous.nl>', full=true) }}
</div> </div>
</div> </div></div>
<div class="card"> <div class="card"><div class="card-body">
<h2>Formulieren en API</h2> <h2>Formulieren en API</h2>
<div class="fields"> <div class="row g-3">
{{ field('allowed_origins', 'Toegestane websites (komma-gescheiden)', placeholder='https://www.vanemous.nl', full=true) }} {{ field('allowed_origins', 'Toegestane websites (komma-gescheiden)', placeholder='https://www.vanemous.nl', full=true) }}
<div><label for="api_assign_user">Nieuwe leads toewijzen aan</label> <div class="col-md-6 col-xl-4"><label class="form-label" for="api_assign_user">Nieuwe leads toewijzen aan</label>
<select id="api_assign_user" name="api_assign_user"> <select class="form-select" id="api_assign_user" name="api_assign_user">
<option value="">— Niemand —</option> <option value="">— Niemand —</option>
{% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if s.api_assign_user == u.id|string }}>{{ u.name }}</option>{% endfor %} {% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if s.api_assign_user == u.id|string }}>{{ u.name }}</option>{% endfor %}
</select> </select>
</div> </div>
</div> </div>
</div> </div></div>
<div class="card"> <div class="card"><div class="card-body">
<h2>Relatietypes</h2> <h2>Relatietypes</h2>
<p class="hint" style="margin-top:-.4rem;margin-bottom:1rem">Komma-gescheiden. Bestaande relaties houden hun type als je er een weghaalt.</p> <p class="hint" style="margin:-.4rem 0 1rem">Komma-gescheiden. Bestaande relaties houden hun type als je er een weghaalt.</p>
<div class="fields"> <div class="row g-3">
<div class="full"><label for="klant_types">Klanten (bundels)</label><input id="klant_types" name="klant_types" value="{{ s.klant_types or 'Huishouden / familie, Particulier, Zakelijk, Zorginstelling' }}"></div> <div class="col-12"><label class="form-label" for="klant_types">Klanten (bundels)</label><input class="form-control" id="klant_types" name="klant_types" value="{{ s.klant_types or 'Huishouden / familie, Particulier, Zakelijk, Zorginstelling' }}"></div>
<div class="full"><label for="partner_types">Partners</label><input id="partner_types" name="partner_types" value="{{ s.partner_types or 'Leverancier, Aannemer, Installateur, Architect / adviseur, Gemeente, Overig' }}"></div> <div class="col-12"><label class="form-label" for="partner_types">Partners</label><input class="form-control" id="partner_types" name="partner_types" value="{{ s.partner_types or 'Leverancier, Aannemer, Installateur, Architect / adviseur, Gemeente, Overig' }}"></div>
</div> </div>
</div> </div></div>
<div class="actions" style="margin-bottom:1.5rem"><button class="btn">Instellingen opslaan</button></div> <div class="actions" style="margin-bottom:1.5rem"><button class="btn btn-primary">Instellingen opslaan</button></div>
</form> </form>
<div class="card"> <div class="card"><div class="card-body">
<h2>API-koppeling</h2> <h2>API-koppeling</h2>
<dl class="props"> <dl class="props">
<dt>Endpoint</dt><dd><code>POST {{ api_url }}</code></dd> <dt>Endpoint</dt><dd><code>POST {{ api_url }}</code></dd>
@@ -123,7 +123,7 @@ const { ok, id } = await res.json();{% endfilter %}</pre>
<form method="post" onsubmit="return confirm('Nieuwe sleutel maken? Bestaande formulieren werken dan niet meer.')"> <form method="post" onsubmit="return confirm('Nieuwe sleutel maken? Bestaande formulieren werken dan niet meer.')">
<input type="hidden" name="action" value="apikey"> <input type="hidden" name="action" value="apikey">
<button class="btn quiet small">Nieuwe API-sleutel maken</button> <button class="btn btn-outline-secondary btn-sm">Nieuwe API-sleutel maken</button>
</form> </form>
</div> </div></div>
{% endblock %} {% endblock %}
+12 -12
View File
@@ -4,21 +4,21 @@
<div class="head"> <div class="head">
<div><a class="label" href="{{ url_for('users') }}">← Gebruikers</a><h1>{{ 'Gebruiker bewerken' if u.id else 'Nieuwe gebruiker' }}</h1></div> <div><a class="label" href="{{ url_for('users') }}">← Gebruikers</a><h1>{{ 'Gebruiker bewerken' if u.id else 'Nieuwe gebruiker' }}</h1></div>
</div> </div>
<form method="post" class="card"> <form method="post" class="card"><div class="card-body">
<div class="fields"> <div class="row g-3">
<div><label for="name">Naam *</label><input id="name" name="name" value="{{ u.name }}" required></div> <div class="col-md-6"><label class="form-label" for="name">Naam *</label><input class="form-control" id="name" name="name" value="{{ u.name }}" required></div>
<div><label for="email">E-mailadres (inlognaam) *</label><input id="email" type="email" name="email" value="{{ u.email }}" required></div> <div class="col-md-6"><label class="form-label" for="email">E-mailadres (inlognaam) *</label><input class="form-control" id="email" type="email" name="email" value="{{ u.email }}" required></div>
<div><label for="role">Rol</label> <div class="col-md-6"><label class="form-label" for="role">Rol</label>
<select id="role" name="role">{% for k, l in ROLES %}<option value="{{ k }}" {{ 'selected' if u.role == k }}>{{ l }}</option>{% endfor %}</select> <select class="form-select" id="role" name="role">{% for k, l in ROLES %}<option value="{{ k }}" {{ 'selected' if u.role == k }}>{{ l }}</option>{% endfor %}</select>
</div> </div>
<div><label for="password">{{ 'Nieuw wachtwoord' if u.id else 'Wachtwoord *' }}</label> <div class="col-md-6"><label class="form-label" for="password">{{ 'Nieuw wachtwoord' if u.id else 'Wachtwoord *' }}</label>
<input id="password" type="password" name="password" minlength="8" {{ 'required' if not u.id }} autocomplete="new-password"> <input class="form-control" id="password" type="password" name="password" minlength="8" {{ 'required' if not u.id }} autocomplete="new-password">
{% if u.id %}<p class="hint">Leeg laten om het huidige wachtwoord te houden.</p>{% endif %}</div> {% if u.id %}<p class="hint">Leeg laten om het huidige wachtwoord te houden.</p>{% endif %}</div>
<div class="full"><label><input type="checkbox" name="active" value="1" {{ 'checked' if u.active }}>Actief (mag inloggen)</label></div> <div class="col-12"><div class="form-check"><input class="form-check-input" type="checkbox" id="active" name="active" value="1" {{ 'checked' if u.active }}><label class="form-check-label" for="active">Actief (mag inloggen)</label></div></div>
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn">Opslaan</button> <button class="btn btn-primary">Opslaan</button>
<a class="btn quiet" href="{{ url_for('users') }}">Annuleren</a> <a class="btn btn-outline-secondary" href="{{ url_for('users') }}">Annuleren</a>
</div> </div>
</form> </div></form>
{% endblock %} {% endblock %}
+9 -9
View File
@@ -3,23 +3,23 @@
{% block content %} {% block content %}
<div class="head"> <div class="head">
<div><div class="kicker">Team</div><h1>Gebruikers</h1></div> <div><div class="kicker">Team</div><h1>Gebruikers</h1></div>
<a class="btn" href="{{ url_for('user_edit') }}">Nieuwe gebruiker</a> <div class="actions"><a class="btn btn-primary" href="{{ url_for('user_edit') }}">Nieuwe gebruiker</a></div>
</div> </div>
<div class="card"> <div class="card"><div class="card-body">
<div class="table-wrap"><table> <div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th>Naam</th><th>E-mail</th><th>Rol</th><th class="num">Contacten</th><th>Status</th></tr></thead> <thead><tr><th>Naam</th><th>E-mail</th><th>Rol</th><th class="num">Contacten</th><th>Status</th></tr></thead>
<tbody> <tbody>
{% for u in rows %} {% for u in rows %}
<tr class="link" onclick="location='{{ url_for('user_edit', uid=u.id) }}'"> <tr class="link" onclick="location='{{ url_for('user_edit', uid=u.id) }}'">
<td><a href="{{ url_for('user_edit', uid=u.id) }}">{{ u.name }}</a></td> <td class="stack-title"><a href="{{ url_for('user_edit', uid=u.id) }}">{{ u.name }}</a></td>
<td>{{ u.email }}</td> <td data-label="E-mail">{{ u.email }}</td>
<td>{{ ROLE.get(u.role, u.role) }}</td> <td data-label="Rol">{{ ROLE.get(u.role, u.role) }}</td>
<td class="num">{{ u.n }}</td> <td data-label="Contacten" class="num">{{ u.n }}</td>
<td>{% if not u.active %}<span class="chip verloren">Inactief</span>{% endif %}</td> <td data-label="Status">{% if not u.active %}<span class="chip verloren">Inactief</span>{% endif %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table></div> </table></div>
</div> </div></div>
<p class="muted">Beheerders kunnen ook gebruikers en instellingen beheren. Klanten krijgen toegang via het klantportaal (op de pagina van het contact).</p> <p class="muted">Beheerders kunnen ook gebruikers en instellingen beheren. Klanten krijgen toegang via het klantportaal (op de pagina van het contact).</p>
{% endblock %} {% endblock %}
+124
View File
@@ -0,0 +1,124 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{# De engine verwijst relatief naar assets/ en kosten/: base = de gekopieerde configurator. #}
<base href="{{ url_for('static', filename='configurator/') }}">
<title>{{ name }} · Van Emous CRM</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Mukta:wght@400;600&display=swap" rel="stylesheet">
<style>
:root {
--orange: #b95913; --orange-dark: #a24e11; --orange-tint: rgba(185, 89, 19, 0.1);
--ink: #212121; --ink-muted: #5c6670; --white: #ffffff; --ultra-light: #f9faf5; --line: rgba(0, 0, 0, 0.08);
--radius-sm: 0.5rem; --radius-pill: 50px;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body { font: 15px/1.5 "Mukta", system-ui, sans-serif; color: var(--ink); background: var(--white); font-variant-numeric: tabular-nums; }
.vw { height: 100%; display: flex; flex-direction: column; background: var(--white); }
.vw-stage { position: relative; flex: 1; min-height: 0; background: #dfe6ec; overflow: hidden; }
.vw-3d, .vw-plan { position: absolute; inset: 0; width: 100%; height: 100%; }
.vw-3d canvas { display: block; touch-action: none; }
.vw-plan { display: none; background: #fff; }
.view-plan .vw-plan { display: block; }
.view-plan .vw-3d { visibility: hidden; }
.vw-bar { position: absolute; top: 12px; left: 12px; right: 12px; display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-start; justify-content: space-between; pointer-events: none; }
.vw-bar > * { pointer-events: auto; }
.vw-viewbar, .vw-tools { display: flex; background: rgba(255, 255, 255, 0.94); border: 1px solid var(--line); border-radius: var(--radius-pill); padding: 3px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); }
.vw button { font: 600 14px/1 "Mukta", system-ui, sans-serif; color: var(--ink); background: none; border: 0; border-radius: var(--radius-pill); padding: 8px 14px; cursor: pointer; }
.vw button:hover { background: var(--orange-tint); }
.vw button.on { background: var(--orange); color: var(--white); }
.vw button:focus-visible { outline: 3px solid var(--orange); outline-offset: 1px; }
.vw-tools button { padding: 8px 10px; display: inline-flex; align-items: center; gap: 6px; }
.vw-tools-wrap { position: relative; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
.vw-pop { position: absolute; top: calc(100% + 6px); right: 0; z-index: 2; width: min(320px, calc(100vw - 24px)); max-height: calc(100vh - 90px); overflow: auto;
background: var(--white); border: 1px solid var(--line); border-radius: 14px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14); padding: 12px 14px; font-size: 14px; }
.vw-pop[hidden] { display: none; }
.vw-pop h3 { margin: 0 0 8px; font-size: 12px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); }
.vw-pop select { width: 100%; font: inherit; padding: 6px 8px; border: 1px solid #8f969c; border-radius: var(--radius-sm); margin-bottom: 10px; }
.vw-checks { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 10px; }
.vw-checks label { display: flex; gap: 6px; align-items: center; cursor: pointer; }
.vw-checks input { accent-color: var(--orange); }
.vw-pdf { display: flex; justify-content: space-between; gap: 10px; padding: 8px 0; border-top: 1px solid var(--line); }
.vw-pdf:first-child { border-top: 0; padding-top: 0; }
.vw-pdf span { display: block; color: var(--ink-muted); font-size: 12px; line-height: 1.35; }
.vw-pdf > div:last-child { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; white-space: nowrap; }
.vw-pdf a { color: var(--orange); font-weight: 600; text-decoration: none; }
.vw .vw-pdf button { padding: 4px 10px; border: 1px solid rgba(185, 89, 19, 0.35); color: var(--orange); font-size: 13px; }
.vw-tools svg { width: 16px; height: 16px; display: block; }
.vw-cutbar { position: absolute; left: 12px; top: 58px; display: none; align-items: center; gap: 10px; background: rgba(255, 255, 255, 0.94); border: 1px solid var(--line); border-radius: var(--radius-pill); padding: 6px 14px; font-size: 13px; }
.view-cut .vw-cutbar { display: flex; }
.vw-cutbar input { accent-color: var(--orange); width: 140px; }
.vw-cutbar output { min-width: 3.5em; font-weight: 600; }
.vw-hint { position: absolute; left: 12px; bottom: 10px; font-size: 12px; color: var(--ink-muted); background: rgba(255, 255, 255, 0.85); padding: 2px 10px; border-radius: var(--radius-pill); }
.vw-toast { position: absolute; left: 50%; bottom: 44px; transform: translateX(-50%); background: var(--ink); color: var(--white); font-size: 13px; padding: 6px 14px; border-radius: var(--radius-pill); opacity: 0; transition: opacity 0.2s; pointer-events: none; white-space: nowrap; }
.vw-toast.on { opacity: 0.92; }
.vw-busy, .vw-msg { position: absolute; inset: 0; display: none; place-items: center; text-align: center; padding: 2rem; color: var(--ink-muted); }
.is-busy .vw-busy, .is-error .vw-msg { display: grid; }
.vw-facts { display: flex; flex-wrap: wrap; gap: 6px 22px; padding: 10px 16px; border-top: 1px solid var(--line); background: var(--ultra-light); font-size: 13px; }
.vw-facts span { display: block; color: var(--ink-muted); font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; }
.vw-facts b { font-weight: 600; }
@media (max-width: 560px) { .vw-hint { display: none; } .vw button { padding: 7px 10px; } .vw-tools span { display: none; } .vw-viewbar button { padding: 7px 9px; font-size: 13px; } }
</style>
</head>
<body>
<div id="viewer" class="vw is-busy">
<div class="vw-stage">
<div class="vw-3d"></div>
<canvas class="vw-plan" aria-label="Plattegrond, noord boven"></canvas>
<div class="vw-bar">
<div class="vw-viewbar" role="group" aria-label="Weergave">
<button type="button" data-view="3d" class="on">3D</button>
<button type="button" data-view="cut" title="Wanden afgesneden op een hoogte boven de vloer">Doorsnede</button>
<button type="button" data-view="plan">Plattegrond</button>
</div>
<div class="vw-tools-wrap">
<div class="vw-tools">
<button type="button" class="vw-layers-btn" data-pop=".vw-pop-layers" title="Bouwfase en lagen (regelwerk, elektra, …)" aria-label="Lagen">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 1 0.8 4.6 8 8.2l7.2-3.6L8 1Zm-5.1 3.6L8 2.3l5.1 2.3L8 6.9 2.9 4.6Z"/><path d="m1.6 7.3-.8.4L8 11.3l7.2-3.6-.8-.4L8 10.5 1.6 7.3Z"/><path d="m1.6 10.4-.8.4L8 14.4l7.2-3.6-.8-.4L8 13.6l-6.4-3.2Z"/></svg><span>Lagen</span>
</button>
<button type="button" data-pop=".vw-pop-pdf" title="PDF: werktekening en klant-PDF" aria-label="PDF">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M4 1h5.5L13 4.5V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1Zm.2 1.2v11.6h7.6V5.2H9V2.2H4.2Z"/><path d="M5.5 8h5v1.1h-5V8Zm0 2.4h5v1.1h-5v-1.1Z"/></svg><span>PDF</span>
</button>
<button type="button" class="vw-shot" title="Afbeelding opslaan" aria-label="Afbeelding opslaan">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 11.5a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm0-1.2a1.8 1.8 0 1 1 0-3.6 1.8 1.8 0 0 1 0 3.6Z"/><path d="M5.6 2h4.8l1 1.5H14a1 1 0 0 1 1 1V13a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1h2.6l1-1.5Zm.6 1.2-1 1.5H2.2v8.1h11.6V4.7h-2.9l-1-1.5H6.2Z"/></svg>
</button>
<button type="button" class="vw-full" title="Schermvullend" aria-label="Schermvullend" hidden>
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1.5 1h4v1.2H2.7v2.8H1.5V1Zm9 0h4v4h-1.2V2.2h-2.8V1ZM1.5 11h1.2v2.8h2.8V15h-4v-4Zm11.8 0h1.2v4h-4v-1.2h2.8V11Z"/></svg>
</button>
</div>
<div class="vw-pop vw-pop-layers" hidden>
<h3>Bouwfase</h3>
<select aria-label="Bouwfase"></select>
<h3>Lagen</h3>
<div class="vw-checks"></div>
</div>
<div class="vw-pop vw-pop-pdf" hidden><div class="vw-pdfs"></div></div>
</div>
</div>
<label class="vw-cutbar"><span>Snijhoogte</span>
<input type="range" min="300" max="3000" step="50" value="1500" aria-label="Snijhoogte boven de vloer">
<output>1,50 m</output></label>
<div class="vw-hint"></div>
<div class="vw-toast" role="status" aria-live="polite"></div>
<div class="vw-busy" aria-live="polite">Ontwerp opbouwen…</div>
<div class="vw-msg"></div>
</div>
<div class="vw-facts"></div>
</div>
{% if engine %}
<script type="application/json" id="design">{{ design|tojson }}</script>
<script type="application/json" id="viewer-cfg">{{ cfg|tojson }}</script>
{% for src in scripts %}<script src="{{ src }}"></script>
{% endfor %}<script src="{{ url_for('static', filename='viewer.js') }}"></script>
{% else %}
<script>
document.getElementById('viewer').className = 'vw is-error';
document.querySelector('.vw-msg').textContent = 'De 3D-engine van de configurator ontbreekt. Draai tools/configurator-sync.sh in de CRM-map.';
</script>
{% endif %}
</body>
</html>
+15
View File
@@ -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))"
+55
View File
@@ -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');
+44
View File
@@ -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 <tag>` (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"