3 Commits
51 changed files with 2925 additions and 697 deletions
+1
View File
@@ -1,3 +1,4 @@
data/
.venv/
__pycache__/
static/configurator/
+96 -7
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,
aannemer, installateur, ...; de typen pas je aan bij Instellingen). Een lead omzetten naar klant zet de
persoon in een nieuwe of bestaande klantrelatie. Leveranciers van producten worden automatisch partners.
- **Gekozen model** per lead/contact: een woningontwerp uit de configurator (`house-design`-JSON, via het
formulier, de API of geüpload bij Bestanden) wordt het gekozen model; het nieuwste telt, een ander ontwerp
kies je op de contactpagina. Daar staat het in een **3D-viewer** op de engine van de configurator
(`/files/<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.
- **API voor formulieren** (`POST /api/leads`): JSON, gewone formulieren en multipart met bestanden.
Nederlandse en Engelse veldnamen worden herkend, onbekende velden bewaard. Dubbele leads (zelfde
@@ -29,6 +43,22 @@ Eenvoudige CRM in Python (Flask) met SQLite, in de Van Emous-huisstijl. Versie 0
formaat als de import.
- **Offertes** met regels (product of vrije regel), btw per tarief en een afdrukversie die je als PDF
opslaat via de browser.
- **Bestek uit ontwerp** bij een offerte (of "Offerte met bestek" bij het gekozen model): de engine van de
configurator rekent het ontwerp door zoals Kosten & tijden (`static/bestek.js`, `CostModel.compute` met de
kosten-CSV's) en zet alle posten met hoeveelheden in hoofdstukken (casco, isolatie, gevel, dak, vloer,
kozijnen, afwerking, inrichting, verlichting, elektra met wcd's, schakelaars en meters leiding per type,
transport en plaatsing). Elke post krijgt zijn kostprijs plus algemene kosten en marge; samen precies het
totaal van de configurator. Schroeven, zaag- en boorwerk staan per hoofdstuk samen als "Bevestiging en
bewerking". In de offerte per post (standaard: aantal × prijs per eenheid) of per hoofdstuk; het
afrondingsverschil van de prijzen per eenheid gaat naar de laatste regel van 1 post zonder product, zodat
het totaal precies klopt. Het bestek zelf komt als bijlage (zonder prijzen) in de afdrukversie en het portaal.
- **Producten uit het bestek:** posten met een vaste prijs per eenheid (wcd, schakelaar, aansluitpunten,
leidingen per type, spots en lampen, toilet, douche, keuken per m, deuren, ramen per maat, gevel- en
dakbedekking per m², isolatie, plaatmateriaal, ...) worden gekoppeld aan een product met de kostencode van de
configurator als SKU (`elektra.wcdDubbel`, `inrichting.toilet`, `kozijnen.raam.1200x500`; dezelfde codes als
in `kosten/leveranciers.csv`). Ontbreekt het product, dan wordt het aangemaakt met de prijs per eenheid van de
berekening; bestaande productprijzen worden alleen bijgewerkt als je dat aanvinkt (met producthistorie).
De regels staan in `PRODUCT_RULES` in `static/bestek.js`.
- **Gebruikers** met de rollen beheerder (ook gebruikers en instellingen) en medewerker.
- **Klantportaal** (`/portal`): de klant krijgt een uitnodigingslink (kopiëren of mailen vanaf het
contact), stelt een wachtwoord in en kan dan offertes bekijken, accepteren of afwijzen, bestanden
@@ -44,22 +74,75 @@ python3 -m venv .venv
.venv/bin/python app.py # http://127.0.0.1:5000
```
De viewer heeft de engine van de configurator nodig (niet in git, ca. 160 MB met texturen):
```sh
tools/configurator-sync.sh [../configurator] # kopieert js/, assets/, kosten/ naar static/configurator/
```
Opnieuw draaien als er in de configurator iets aan de weergave verandert; `deploy.sh` doet dit zelf bij elke deploy.
Demoleads (lorem ipsum, elk met een standaardmodel als gekozen model):
```sh
node tools/export-models.js # standaardmodellen (HouseModel.MODELS) → demo/modellen/*.json
.venv/bin/python app.py demo # leads aanmaken; bestaande demoleads worden overgeslagen
```
Bij de eerste keer maak je het beheerdersaccount aan. Daarna vul je onder Instellingen de
bedrijfsgegevens, de e-mail en de toegestane websites voor de API in.
## Online zetten
## Online zetten (server)
Draai de app achter nginx/Apache met HTTPS, bijvoorbeeld met gunicorn:
Op een nieuwe Ubuntu-server (26.04 LTS) eenmalig, nadat de DNS van het domein naar de server wijst:
```sh
.venv/bin/pip install gunicorn
.venv/bin/gunicorn -w 2 -b 127.0.0.1:8000 app:app
scp -r server ubuntu@SERVER:
ssh ubuntu@SERVER sudo bash server/setup.sh crm.voorbeeld.nl jij@voorbeeld.nl
```
E-mail automatisch ophalen (cron, elke 10 minuten):
`server/setup.sh` installeert Python, nginx, certbot, rsync en sqlite3, zet de tijdzone op Europe/Amsterdam,
maakt de gebruiker `crm` met `/srv/vanemous-crm` (`releases/`, `current`, `data/`), de systemd-dienst
`vanemous-crm` (gunicorn op 127.0.0.1:5050, `CRM_PROXY=1`), de nginx-site met HTTPS, de firewall (SSH, 80, 443),
automatische beveiligingsupdates en cron (e-mail ophalen elke 10 minuten, elke nacht een databaseback-up, 7 dagen rond).
Een versie uitrollen (of terugrollen) vanaf je eigen machine:
```sh
tools/release.sh crm@crm.voorbeeld.nl # nieuwste tag
tools/release.sh crm@crm.voorbeeld.nl v0.0.1 # bepaalde versie
```
*/10 * * * * cd /pad/naar/crm && .venv/bin/python app.py sync
Dat stuurt `git archive <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.
### Tectum (configurator) op dezelfde server
De configurator (`../configurator`) staat als statische site op een eigen subdomein, helemaal achter één
wachtwoord (nginx basic auth). Eenmalig, na `server/setup.sh`:
```sh
scp -r server ubuntu@SERVER:
ssh -t ubuntu@SERVER sudo bash server/tectum-setup.sh tectum.voorbeeld.nl jij@voorbeeld.nl [gebruikersnaam]
```
Dat vraagt het wachtwoord (gebruikersnaam standaard `vanemous`; wijzigen: `sudo WACHTWOORD=1 bash ...`), maakt
`/srv/www/tectum` (van `crm`, zodat releases via dezelfde SSH-toegang gaan) en de nginx-site met HTTPS.
Uitrollen of terugrollen vanaf je eigen machine, vanuit een tag van de configurator:
```sh
tools/tectum-release.sh crm@tectum.voorbeeld.nl # nieuwste tag
tools/tectum-release.sh crm@tectum.voorbeeld.nl v0.7.6 # bepaalde versie
```
## Opslag
@@ -79,5 +162,11 @@ importer.py CSV/JSON inlezen en kolommen herkennen
mail.py IMAP/SMTP
schema.sql database
templates/ pagina's (Jinja)
static/ style.css (huisstijltokens), fx.js (GSAP-effecten) en logo.svg
static/ style.css (huisstijltokens), fx.js (GSAP-effecten), logo.svg, viewer.js (3D-viewer), bestek.js
static/configurator/ engine van de configurator (tools/configurator-sync.sh, niet in git)
tools/ configurator-sync.sh, export-models.js (standaardmodellen als JSON)
demo/modellen standaardmodellen als house-design-JSON voor de demoleads
```
De plattegrond in `static/viewer.js` is overgenomen uit `js/light/light.js` (`drawPlan`) van de
configurator; verandert die, dan hier meenemen.
+368 -17
View File
@@ -2,8 +2,9 @@
Starten: python app.py
E-mail ophalen: python app.py sync (bijv. elke 10 minuten via cron)
Demoleads: python app.py demo (standaardmodellen uit demo/modellen als gekozen model)
"""
__version__ = '0.0.1'
__version__ = '0.0.3'
import csv
import io
@@ -52,6 +53,18 @@ PRODUCT_LABELS = {'name': 'Naam', 'sku': 'SKU', 'description': 'Omschrijving', '
'supplier': 'Leverancier', 'price': 'Prijs', 'vat': 'Btw', 'active': 'Actief', 'created': 'Aangemaakt'}
INLINE_MIME = {'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'application/pdf'}
# Viewer voor woningontwerpen: de engine van de configurator, gekopieerd met tools/configurator-sync.sh
# (zelfde volgorde als light.html, zonder PDF en light-UI). Demo-modellen: tools/export-models.js.
ENGINE = os.path.join(BASE, 'static', 'configurator')
VIEWER_SCRIPTS = ['js/vendor/three.min.js', 'js/vendor/OrbitControls.js', 'js/core/geometry.js',
'js/core/view-layers.js', 'js/core/ui-kit.js', 'js/core/skybox.js', 'js/wall/kozijn-model.js',
'js/wall/wall-model.js', 'js/floor/floor-model.js', 'js/roof/roof-model.js', 'js/roof/post-model.js',
'js/lighting/lighting-model.js', 'js/elektra/elektra-model.js', 'js/sandwich/sandwich-model.js',
'js/wall/wall.js', 'js/floor/floor.js', 'js/unit/unit-model.js', 'js/room/room-model.js',
'js/interior/fixture-model.js', 'js/house/chimney-model.js', 'js/house/house-model.js',
'js/cost/cost-defaults.js', 'js/cost/cost-csv.js', 'js/cost/cost-model.js']
DEMO_MODELS = os.path.join(BASE, 'demo', 'modellen')
SETTING_KEYS = ['company_name', 'company_address', 'company_postcode_city', 'company_phone',
'company_email', 'company_website', 'company_kvk', 'company_btw', 'company_iban',
'quote_prefix', 'quote_valid_days', 'quote_intro', 'quote_footer',
@@ -81,6 +94,12 @@ app = Flask(__name__)
app.secret_key = _secret_key()
app.config.update(MAX_CONTENT_LENGTH=25 * 1024 * 1024, SESSION_COOKIE_SAMESITE='Lax',
PERMANENT_SESSION_LIFETIME=timedelta(days=30))
# Achter nginx/Apache met HTTPS (CRM_PROXY=1): schema en host uit X-Forwarded-*, zodat links naar buiten
# (portaaluitnodiging, API-URL) https://domein zijn, en sessiecookies alleen over HTTPS.
if os.environ.get('CRM_PROXY'):
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
app.config.update(SESSION_COOKIE_SECURE=True)
def now():
@@ -109,7 +128,10 @@ MIGRATIONS = {
'products': {'supplier': "TEXT DEFAULT ''", 'updated_at': 'TEXT',
'supplier_id': 'INTEGER REFERENCES relations(id) ON DELETE SET NULL'},
'contacts': {'relation_id': 'INTEGER REFERENCES relations(id) ON DELETE SET NULL',
'relation_role': "TEXT DEFAULT ''"},
'relation_role': "TEXT DEFAULT ''",
'design_file_id': 'INTEGER REFERENCES files(id) ON DELETE SET NULL'},
'files': {'title': "TEXT DEFAULT ''", 'kind': "TEXT DEFAULT ''", 'design_id': 'INTEGER'},
'quotes': {'bestek': "TEXT DEFAULT ''", 'design_file_id': 'INTEGER REFERENCES files(id) ON DELETE SET NULL'},
}
@@ -133,6 +155,18 @@ def init_db():
"INSERT INTO relations (kind, type, name, created_at, updated_at) VALUES ('partner', 'Leverancier', ?, ?, ?)",
(name, now(), now())).lastrowid
con.execute('UPDATE products SET supplier_id=? WHERE supplier=? AND supplier_id IS NULL', (rid, name))
# woningontwerpen van vóór het gekozen model: titel uit de JSON, het nieuwste wordt het gekozen model
for fid, cid, stored in con.execute("SELECT id, contact_id, stored FROM files WHERE original LIKE '%.json' "
"AND (title IS NULL OR title='') ORDER BY created_at DESC, id DESC").fetchall():
try:
with open(os.path.join(UPLOADS, os.path.basename(stored)), encoding='utf-8-sig') as f:
design = json.load(f)
except (ValueError, OSError):
continue
if isinstance(design, dict) and design.get('type') == 'house-design':
title = str((design.get('project') or {}).get('naam') or '').strip() or 'Woningontwerp'
con.execute('UPDATE files SET title=? WHERE id=?', (title, fid))
con.execute('UPDATE contacts SET design_file_id=? WHERE id=? AND design_file_id IS NULL', (fid, cid))
con.execute('PRAGMA journal_mode=WAL')
con.commit()
con.close()
@@ -337,38 +371,73 @@ def save_upload(fs, contact_id=None):
path = os.path.join(UPLOADS, stored)
fs.save(path)
if contact_id:
db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) '
cur = db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) '
'VALUES (?, ?, ?, ?, ?, ?)',
(contact_id, stored, fs.filename[:200], fs.mimetype or '', os.path.getsize(path), now()))
if ext == '.json':
note_design(contact_id, path)
note_design(contact_id, path, cur.lastrowid)
return stored
def note_design(contact_id, path):
"""Een woningontwerp uit de configurator krijgt een regel in de tijdlijn."""
def read_design(path):
"""Het woningontwerp (house-design-JSON uit de configurator) in een bestand, of None."""
try:
with open(path, encoding='utf-8-sig') as f:
design = json.load(f)
except (ValueError, OSError):
return None
return design if isinstance(design, dict) and design.get('type') == 'house-design' else None
def design_title(design):
return str((design.get('project') or {}).get('naam') or '').strip() or 'Woningontwerp'
def note_design(contact_id, path, file_id):
"""Een woningontwerp uit de configurator wordt het gekozen model van het contact (het nieuwste telt),
met een regel in de tijdlijn."""
design = read_design(path)
if not design:
return
if isinstance(design, dict) and design.get('type') == 'house-design':
project = design.get('project') or {}
parts = [f"Woningontwerp: {project.get('naam') or 'zonder naam'}",
title = design_title(design)
db().execute('UPDATE files SET title=? WHERE id=?', (title, file_id))
db().execute('UPDATE contacts SET design_file_id=?, updated_at=? WHERE id=?', (file_id, now(), contact_id))
parts = [f'Woningontwerp: {title} (gekozen model)',
' '.join(filter(None, [project.get('straat'), project.get('nummer'), project.get('plaats')])),
project.get('opmerking') or '']
log(contact_id, 'file', '\n'.join(p for p in parts if p))
PDF_KINDS = {'werk': 'werktekening', 'klant': 'klant-PDF'}
def design_pdfs(design_ids):
"""PDF's (werktekening, klant) per woningontwerp: {design_id: {kind: {url, date, id}}}."""
out = {}
if design_ids:
for f in q(f"SELECT * FROM files WHERE kind IN ('werk', 'klant') AND design_id IN ({', '.join('?' * len(design_ids))})",
*design_ids):
out.setdefault(f['design_id'], {})[f['kind']] = {
'url': url_for('upload_file', name=f['stored']), 'date': fmt_dt(f['created_at']), 'id': f['id']}
return out
def contact_designs(contact_id):
"""Alle woningontwerpen bij een contact, nieuwste eerst."""
return [f for f in q('SELECT * FROM files WHERE contact_id=? AND original LIKE ? ORDER BY created_at DESC, id DESC',
contact_id, '%.json') if f['title']]
def save_design_json(design, contact_id):
stored = uuid.uuid4().hex + '.json'
path = os.path.join(UPLOADS, stored)
with open(path, 'w', encoding='utf-8') as f:
json.dump(design, f, ensure_ascii=False)
name = secure_filename(str((design.get('project') or {}).get('naam') or 'ontwerp')) or 'ontwerp'
db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) VALUES (?, ?, ?, ?, ?, ?)',
cur = db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) VALUES (?, ?, ?, ?, ?, ?)',
(contact_id, stored, f'{name}.json', 'application/json', os.path.getsize(path), now()))
note_design(contact_id, path)
note_design(contact_id, path, cur.lastrowid)
def remove_upload(stored):
@@ -637,8 +706,9 @@ def dashboard():
def contacts():
term = request.args.get('q', '').strip()
args = {k: request.args.get(k, '') for k in ('type', 'status', 'owner')}
sql = ('SELECT c.*, u.name AS user_name, r.name AS relation_name, r.kind AS relation_kind FROM contacts c '
'LEFT JOIN users u ON u.id=c.assigned_user_id LEFT JOIN relations r ON r.id=c.relation_id WHERE 1=1')
sql = ('SELECT c.*, u.name AS user_name, r.name AS relation_name, r.kind AS relation_kind, f.title AS design_title '
'FROM contacts c LEFT JOIN users u ON u.id=c.assigned_user_id LEFT JOIN relations r ON r.id=c.relation_id '
'LEFT JOIN files f ON f.id=c.design_file_id WHERE 1=1')
params = []
if term:
sql += (' AND (c.name LIKE ? OR c.email LIKE ? OR c.company LIKE ? OR c.phone LIKE ? OR c.city LIKE ?'
@@ -713,6 +783,16 @@ def contact_view(cid):
c = q1('SELECT c.*, u.name AS user_name, r.name AS relation_name, r.kind AS relation_kind, r.type AS relation_type '
'FROM contacts c LEFT JOIN users u ON u.id=c.assigned_user_id LEFT JOIN relations r ON r.id=c.relation_id '
'WHERE c.id=?', cid) or abort(404)
designs = contact_designs(cid)
design = next((f for f in designs if f['id'] == c['design_file_id']), None)
design_meta = {}
if design:
d = read_design(os.path.join(UPLOADS, design['stored'])) or {}
project = d.get('project') or {}
design_meta = {'units': len(d.get('units') or []), 'exported': (d.get('exportedAt') or '')[:19],
'address': ' '.join(filter(None, [project.get('straat'), project.get('nummer'),
project.get('plaats')])),
'note': project.get('opmerking') or '', 'version': d.get('configurator') or ''}
return render_template(
'contact.html', c=c, extra=json.loads(c['extra'] or '{}'),
activities=q('SELECT a.*, u.name AS user_name FROM activities a LEFT JOIN users u ON u.id=a.user_id '
@@ -721,6 +801,8 @@ def contact_view(cid):
emails=q('SELECT * FROM emails WHERE contact_id=? ORDER BY date DESC LIMIT 50', cid),
tasks=q('SELECT t.*, u.name AS user_name FROM tasks t LEFT JOIN users u ON u.id=t.user_id '
'WHERE t.contact_id=? ORDER BY t.done, t.due_date IS NULL, t.due_date', cid),
designs=designs, design=design, design_meta=design_meta,
pdfs=design_pdfs([design['id']]).get(design['id'], {}) if design else {}, PDF_KINDS=PDF_KINDS,
quotes=quotes_with_totals('WHERE qu.contact_id=?', cid),
portal_link=url_for('portal_invite', token=c['portal_token'], _external=True) if c['portal_token'] else '',
klant_relations=q("SELECT id, name, type FROM relations WHERE kind='klant' ORDER BY name"),
@@ -864,12 +946,75 @@ def upload_file(name):
@app.post('/files/<int:fid>/delete')
def file_delete(fid):
f = get_or_404('files', fid)
chosen = f['contact_id'] and scalar('SELECT design_file_id IS ? FROM contacts WHERE id=?', fid, f['contact_id'])
for pdf in q('SELECT stored FROM files WHERE design_id=?', fid):
remove_upload(pdf['stored'])
db().execute('DELETE FROM files WHERE design_id=?', (fid,))
remove_upload(f['stored'])
db().execute('DELETE FROM files WHERE id=?', (fid,))
if chosen:
rest = contact_designs(f['contact_id'])
db().execute('UPDATE contacts SET design_file_id=? WHERE id=?', (rest[0]['id'] if rest else None, f['contact_id']))
db().commit()
return redirect(url_for('contact_view', cid=f['contact_id']))
@app.post('/contacts/<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
def relation_form_data():
@@ -969,6 +1114,9 @@ def relation_view(rid):
'WHERE c.relation_id=? ORDER BY c.name', rid)
ids = [m['id'] for m in members] or [0]
marks = ', '.join('?' * len(ids))
designs = q(f'SELECT f.*, c.name AS contact_name, c.design_file_id = f.id AS chosen FROM files f '
f"JOIN contacts c ON c.id=f.contact_id WHERE f.contact_id IN ({marks}) AND f.title<>'' "
f"AND f.original LIKE '%.json' ORDER BY chosen DESC, f.created_at DESC", *ids)
return render_template(
'relation.html', r=r, members=members,
candidates=q('SELECT id, name, email FROM contacts WHERE relation_id IS NULL ORDER BY name LIMIT 500'),
@@ -978,7 +1126,8 @@ def relation_view(rid):
f'WHERE a.contact_id IN ({marks}) ORDER BY a.created_at DESC LIMIT 30', *ids),
tasks=q(f'SELECT t.*, c.name AS contact_name FROM tasks t JOIN contacts c ON c.id=t.contact_id '
f'WHERE t.done=0 AND t.contact_id IN ({marks}) ORDER BY t.due_date IS NULL, t.due_date', *ids),
products=q('SELECT * FROM products WHERE supplier_id=? ORDER BY active DESC, name', rid))
products=q('SELECT * FROM products WHERE supplier_id=? ORDER BY active DESC, name', rid),
designs=designs, pdfs=design_pdfs([d['id'] for d in designs]), PDF_KINDS=PDF_KINDS)
@app.post('/relations/<int:rid>/member')
@@ -1442,6 +1591,8 @@ def quote_new():
if cid:
log(cid, 'quote', f'Offerte {number} aangemaakt')
db().commit()
if request.form.get('bestek'):
return redirect(url_for('quote_bestek', qid=cur.lastrowid))
return redirect(url_for('quote_edit', qid=cur.lastrowid))
@@ -1473,7 +1624,8 @@ def quote_edit(qid):
flash('Offerte opgeslagen.')
return redirect(url_for('quote_edit', qid=qid))
return render_template(
'quote_edit.html', qt=qt,
'quote_edit.html', qt=qt, bestek=quote_bestek_data(qt),
designs=contact_designs(qt['contact_id']) if qt['contact_id'] else [],
lines=q('SELECT * FROM quote_lines WHERE quote_id=? ORDER BY position', qid),
contacts=q('SELECT id, name, company FROM contacts ORDER BY name'),
products=q('SELECT * FROM products WHERE active=1 ORDER BY name'),
@@ -1485,7 +1637,7 @@ def render_quote(qt, portal=False):
contact = q1('SELECT c.*, r.name AS relation_name, r.kind AS relation_kind FROM contacts c '
'LEFT JOIN relations r ON r.id=c.relation_id WHERE c.id=?', qt['contact_id']) if qt['contact_id'] else None
return render_template('quote_print.html', qt=qt, lines=lines, c=contact, t=quote_totals(lines),
s=settings_dict(), portal=portal)
s=settings_dict(), portal=portal, bestek=quote_bestek_data(qt))
@app.route('/quotes/<int:qid>/print')
@@ -1493,6 +1645,160 @@ def quote_print(qid):
return render_quote(get_or_404('quotes', qid))
# Bestek uit het woningontwerp: de berekening draait in de browser op de engine van de configurator
# (static/bestek.js); hier worden het bestek bewaard en de offerteregels gemaakt.
def quote_bestek_data(qt):
try:
data = json.loads(qt['bestek'] or 'null')
except ValueError:
return None
return data if isinstance(data, dict) and isinstance(data.get('chapters'), list) else None
def fmt_qty(value, unit=''):
value = round(float(value or 0), 2 if unit == 'm³' else 1)
return f'{value:g}'.replace('.', ',')
def clean_bestek(raw):
"""Bestek uit het formulier controleren en tot de bekende velden terugbrengen."""
try:
data = json.loads(raw or '')
except ValueError:
return None
if not isinstance(data, dict) or not isinstance(data.get('chapters'), list):
return None
def product(p):
if not isinstance(p, dict) or not str(p.get('sku') or '').strip():
return None
return {k: str(p.get(k) or '').strip()[:limit] for k, limit in
(('sku', 80), ('name', 200), ('description', 1000), ('supplier', 200), ('article', 80))}
def line(l):
out = {'label': str(l.get('label') or '')[:300], 'qty': num(l.get('qty')), 'unit': str(l.get('unit') or '')[:20],
'price': round(num(l.get('price')), 2), 'unitPrice': round(num(l.get('unitPrice')), 4)}
if product(l.get('product')):
out['product'] = product(l['product'])
return out
chapters = [{'key': str(ch.get('key') or '')[:40], 'name': str(ch.get('name') or '')[:120],
'price': round(num(ch.get('price')), 2),
'lines': [line(l) for l in ch.get('lines') or [] if isinstance(l, dict)],
'info': [line(l) for l in ch.get('info') or [] if isinstance(l, dict)]}
for ch in data['chapters'] if isinstance(ch, dict)]
if not chapters:
return None
totals = data.get('totals') if isinstance(data.get('totals'), dict) else {}
design = data.get('design') if isinstance(data.get('design'), dict) else {}
return {'version': 1, 'computedAt': str(data.get('computedAt') or now())[:30], 'vat': num(data.get('vat'), 21),
'units': int(num(data.get('units'))), 'configurator': str(data.get('configurator') or '')[:20],
'design': {'file_id': design.get('file_id'), 'title': str(design.get('title') or '')[:120]},
'totals': {k: round(num(totals.get(k)), 2) for k in ('exBtw', 'btw', 'inclBtw')}, 'chapters': chapters}
def bestek_rows(b, mode='post'):
"""Offerteregels uit een bestek: dicts met desc, qty, unit, price (per eenheid) en product (of None).
post: elke post met zijn hoeveelheid (42 spots = 42 st. × prijs per spot); hoofdstuk: één regel per hoofdstuk.
De prijs per eenheid is afgerond op centen; het verschil met het hoofdstuk gaat naar de laatste regel van
1 stuk/post zonder product (bevestiging en bewerking, plaatsen met kraan), wat dan nog over is naar de
laatste zo'n regel van de offerte, zodat het totaal precies dat van de configurator is."""
def absorb(rows, target):
diff = round(target - sum(r['qty'] * r['price'] for r in rows), 2)
spot = next((r for r in reversed(rows) if r['qty'] == 1 and not r['product']), None) or \
next((r for r in reversed(rows) if r['qty'] == 1), None)
if spot and diff:
spot['price'] = round(spot['price'] + diff, 2)
if mode == 'hoofdstuk':
rows = [{'desc': ch['name'] + '\n' + ', '.join(f"{l['label']} {fmt_qty(l['qty'], l['unit'])} {l['unit']}".strip()
for l in ch['lines']),
'qty': 1, 'unit': 'post', 'price': ch['price'], 'product': None} for ch in b['chapters']]
else:
rows = []
for ch in b['chapters']:
part = [{'desc': f"{l['label']}\n{ch['name']}", 'qty': l['qty'] or 1, 'unit': l['unit'],
'price': round(l.get('unitPrice') or (l['price'] / l['qty'] if l['qty'] else l['price']), 2),
'product': l.get('product')} for l in ch['lines']]
absorb(part, ch['price'])
rows += part
absorb(rows, b['totals'].get('exBtw') or sum(ch['price'] for ch in b['chapters']))
return rows
def bestek_product(row, vat, update_price, source):
"""Product bij een bestekpost (SKU = code uit de kosten van de configurator); bestaat het nog niet, dan wordt
het aangemaakt met de prijs per eenheid van deze berekening."""
p = row['product']
existing = find_product(p['sku'])
if existing is None:
description = '\n'.join(filter(None, [p['description'], f"Artikelnummer leverancier: {p['article']}" if p['article'] else '']))
return upsert_product({'sku': p['sku'], 'name': p['name'] or row['desc'].split('\n')[0], 'unit': row['unit'],
'price': row['price'], 'vat': vat, 'description': description, 'supplier': p['supplier']},
source)[2]
if update_price:
upsert_product({'sku': p['sku'], 'price': row['price']}, source, existing=existing)
return existing['id']
@app.route('/quotes/<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')
def quote_delete(qid):
get_or_404('quotes', qid)
@@ -1573,7 +1879,7 @@ def portal_home():
return render_template(
'portal.html', c=g.portal,
quotes=quotes_with_totals("WHERE qu.contact_id=? AND qu.status<>'concept'", cid),
files=q('SELECT * FROM files WHERE contact_id=? ORDER BY created_at DESC', cid),
files=q("SELECT * FROM files WHERE contact_id=? AND kind<>'werk' ORDER BY created_at DESC", cid),
messages=q("SELECT * FROM activities WHERE contact_id=? AND type='portal' AND body LIKE 'Bericht:%' "
"ORDER BY created_at DESC LIMIT 20", cid),
s=settings_dict())
@@ -1626,7 +1932,7 @@ def portal_message():
@app.route('/portal/files/<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:
abort(404)
return serve_upload(name, f['original'])
@@ -1748,7 +2054,52 @@ def api_products():
return jsonify(ok=True, **result)
# ---------------------------------------------------------------- demo
DEMO_LEADS = [
('Lorem Ipsum', 'Utrecht', 'nieuw', 'amstel'),
('Dolor Sitamet', 'Amersfoort', 'contact', 'wetering'),
('Consectetur Adipiscing', 'Zwolle', 'nieuw', 'ijssel'),
('Sed Doeiusmod', 'Nijmegen', 'offerte', 'waal'),
('Tempor Incididunt', 'Maastricht', 'contact', 'maas'),
('Labore Etdolore', 'Arnhem', 'nieuw', 'rijn'),
('Magna Aliqua', 'Meppel', 'offerte', 'reest'),
('Enim Adminim', 'Breukelen', 'nieuw', 'vecht'),
]
DEMO_MESSAGE = ('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Wij zoeken een mantelzorgwoning '
'voor in de tuin; graag meer informatie over het model en de mogelijkheden.')
def seed_demo():
"""Demoleads met elk een standaardmodel als gekozen model (demo/modellen, uit tools/export-models.js).
Bestaat een demolead al (zelfde e-mailadres), dan wordt hij overgeslagen."""
admin = q1("SELECT id FROM users WHERE role='admin' AND active=1 ORDER BY id")
made = 0
for i, (name, city, status, model) in enumerate(DEMO_LEADS):
path = os.path.join(DEMO_MODELS, f'{model}.json')
design = read_design(path)
if not design:
print(f'{model}.json ontbreekt in {DEMO_MODELS}; draai node tools/export-models.js')
continue
email = name.lower().replace(' ', '.') + '@example.nl'
cid, outcome = upsert_contact(
{'name': name, 'email': email, 'phone': f'06-{12345670 + i}', 'city': city, 'status': status},
{'model': design_title(design)}, update_existing=False,
defaults={'source': 'Demo', 'assigned': admin['id'] if admin else None})
if outcome != 'created':
continue
log(cid, 'form', f'Formulier ontvangen via Demo\n{DEMO_MESSAGE}')
save_design_json(design, cid)
made += 1
db().commit()
print(f'{made} demolead(s) aangemaakt')
if __name__ == '__main__':
if len(sys.argv) > 1 and sys.argv[1] == 'demo':
with app.app_context():
seed_demo()
sys.exit(0)
if len(sys.argv) > 1 and sys.argv[1] == 'sync':
with app.app_context():
n, err = do_sync()
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;
}
}
+112
View File
@@ -0,0 +1,112 @@
#!/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
touch "$ROOT/.ssh/authorized_keys" # bij de eerste keer bestaat hij nog niet (en cat zou dan set -e laten stoppen)
sort -u "$src_keys" "$ROOT/.ssh/authorized_keys" > "$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"
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Eenmalig op de server (na server/setup.sh): Tectum, de configurator, als statische site achter een wachtwoord.
#
# scp -r server ubuntu@SERVER:
# ssh -t ubuntu@SERVER sudo bash server/tectum-setup.sh tectum.voorbeeld.nl jij@voorbeeld.nl [gebruikersnaam]
#
# Vraagt het wachtwoord als die gebruiker nog niet bestaat; met WACHTWOORD=1 ervoor wijzig je het:
# ssh -t ubuntu@SERVER sudo WACHTWOORD=1 bash server/tectum-setup.sh tectum.voorbeeld.nl jij@voorbeeld.nl
# Daarna rolt tools/tectum-release.sh (op je eigen machine) een versie uit naar crm@SERVER.
# Opnieuw draaien kan: elke stap controleert wat er al is.
set -euo pipefail
DOMAIN="${1:-}"
EMAIL="${2:-}"
LOGIN="${3:-vanemous}"
ROOT=/srv/www/tectum
SITE=tectum
HTPASSWD=/etc/nginx/tectum.htpasswd
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 tectum-setup.sh <domein> <e-mail voor Let's Encrypt> [gebruikersnaam]"
id crm >/dev/null 2>&1 || fail "gebruiker crm bestaat niet; draai eerst server/setup.sh"
command -v nginx >/dev/null || fail "nginx ontbreekt; draai eerst server/setup.sh"
# --- map: releases komen binnen als crm (zelfde SSH-toegang als de CRM-releases) ----------------
say "map $ROOT"
install -d -o root -g root -m 755 /srv/www
install -d -o crm -g crm -m 755 "$ROOT" "$ROOT/releases"
# --- wachtwoord ----------------------------------------------------------------------------------
touch "$HTPASSWD"
chown root:www-data "$HTPASSWD"; chmod 640 "$HTPASSWD"
if [ -n "${WACHTWOORD:-}" ] || ! grep -q "^$LOGIN:" "$HTPASSWD"; then
say "wachtwoord voor gebruiker $LOGIN"
read -rsp "Wachtwoord: " pw1 </dev/tty; echo
read -rsp "Nog een keer: " pw2 </dev/tty; echo
[ -n "$pw1" ] || fail "leeg wachtwoord"
[ "$pw1" = "$pw2" ] || fail "wachtwoorden verschillen"
hash="$(printf '%s' "$pw1" | openssl passwd -6 -stdin)"
{ grep -v "^$LOGIN:" "$HTPASSWD" || true; printf '%s:%s\n' "$LOGIN" "$hash"; } > "$HTPASSWD.new"
chown root:www-data "$HTPASSWD.new"; chmod 640 "$HTPASSWD.new"
mv "$HTPASSWD.new" "$HTPASSWD"
fi
# --- nginx -------------------------------------------------------------------------------
say "nginx voor $DOMAIN"
if [ ! -f /etc/nginx/sites-available/$SITE ] || ! grep -q "listen 443" /etc/nginx/sites-available/$SITE; then
sed "s/__DOMAIN__/$DOMAIN/g" "$HERE/tectum-site.conf" > /etc/nginx/sites-available/$SITE
fi
ln -sfn /etc/nginx/sites-available/$SITE /etc/nginx/sites-enabled/$SITE
nginx -t
systemctl reload nginx
# --- 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
say "klaar. Rol nu vanaf je eigen machine een versie uit: tools/tectum-release.sh crm@$DOMAIN"
+28
View File
@@ -0,0 +1,28 @@
# Tectum (configurator) als statische site achter één wachtwoord (server/tectum-setup.sh vult __DOMAIN__ in;
# certbot voegt HTTPS en de doorverwijzing toe; de ACME-controle valt niet onder auth_basic).
server {
listen 80;
listen [::]:80;
server_name __DOMAIN__;
root /srv/www/tectum/current;
index index.html;
auth_basic "Tectum";
auth_basic_user_file /etc/nginx/tectum.htpasswd;
add_header X-Robots-Tag "noindex, nofollow" always;
location / {
try_files $uri $uri/ =404;
# steeds even navragen zodat een nieuwe versie meteen zichtbaar is
add_header Cache-Control "no-cache";
add_header X-Robots-Tag "noindex, nofollow" always;
}
location /assets/ {
access_log off;
add_header Cache-Control "no-cache";
add_header X-Robots-Tag "noindex, nofollow" always;
}
}
+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 {
--orange: #b95913;
--orange-dark: #a24e11;
@@ -22,77 +24,154 @@
--radius-md: 1rem;
--radius-lg: 1.25rem;
--radius-pill: 50px;
/* Bootstrap op de huisstijl zetten */
--bs-body-font-family: var(--font-body);
--bs-body-font-size: 1rem;
--bs-body-line-height: 1.6;
--bs-body-color: var(--ink);
--bs-body-color-rgb: 33, 33, 33;
--bs-body-bg: var(--ultra-light);
--bs-heading-color: var(--ink);
--bs-primary: #b95913;
--bs-primary-rgb: 185, 89, 19;
--bs-secondary-color: var(--ink-muted);
--bs-border-color: var(--line);
--bs-border-radius: var(--radius-sm);
--bs-border-radius-lg: var(--radius-lg);
--bs-border-radius-pill: var(--radius-pill);
--bs-link-color: #b95913;
--bs-link-color-rgb: 185, 89, 19;
--bs-link-hover-color: #a24e11;
--bs-link-hover-color-rgb: 162, 78, 17;
--bs-focus-ring-color: var(--orange-focus);
--bs-code-color: var(--ink);
}
* { box-sizing: border-box; }
html { font-variant-numeric: lining-nums tabular-nums; }
body { margin: 0; font-family: var(--font-body); font-size: 16px; line-height: 1.6; color: var(--ink); background: var(--ultra-light); display: flex; min-height: 100vh; }
body { min-height: 100vh; }
h1, h2, h3 { font-family: var(--font-heading); font-weight: 500; line-height: 1.2; margin: 0 0 0.75rem; }
h1 { font-size: 2rem; line-height: 1.1; letter-spacing: -0.01em; }
h2 { font-size: 1.3rem; }
h3 { font-size: 1.05rem; margin-top: 1.25rem; }
a { color: var(--orange); font-weight: 600; text-decoration: none; }
a { font-weight: 600; text-decoration: none; }
a:hover { text-decoration: underline; }
a:focus-visible, button:focus-visible, .btn:focus-visible { outline: 3px solid var(--orange); outline-offset: 2px; }
a:focus-visible, button:focus-visible, .btn:focus-visible { outline: 3px solid var(--orange); outline-offset: 2px; box-shadow: none; }
/* zijbalk */
.side { width: 240px; flex: none; background: var(--white); border-right: 1px solid var(--line); padding: 1.5rem 1rem; display: flex; flex-direction: column; position: sticky; top: 0; height: 100vh; }
/* ---------- app-layout: zijbalk (desktop) / uitschuifmenu (mobiel) ---------- */
.side-panel { --bs-offcanvas-width: 270px; --bs-offcanvas-bg: var(--white); }
.side .offcanvas-header img { width: 130px; }
.side .offcanvas-body { display: flex; flex-direction: column; gap: 0.25rem; }
.brand img { width: 170px; display: block; margin: 0 0.75rem 2rem; }
.side nav { display: flex; flex-direction: column; gap: 0.25rem; flex: 1; }
.side nav a, .logout { color: var(--ink); padding: 0.6rem 1rem; border-radius: var(--radius-pill); display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; white-space: nowrap; }
.side nav a, .side .logout { color: var(--ink); padding: 0.6rem 1rem; border-radius: var(--radius-pill); display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; white-space: nowrap; }
.side nav a:hover { background: var(--orange-tint-soft); text-decoration: none; }
.side nav a.active { background: var(--orange); color: var(--white); }
.side .logout { color: var(--ink-muted); }
.count { background: var(--orange-tint); color: var(--orange-dark); font-size: 0.75rem; padding: 0 0.5rem; border-radius: 999px; }
.active .count { background: var(--white); }
.logout { color: var(--ink-muted); }
main { flex: 1; min-width: 0; padding: 2rem 2.5rem; max-width: 1400px; }
.topbar { position: sticky; top: 0; z-index: 1020; background: var(--white); border-bottom: 1px solid var(--line); padding: 0.6rem 1rem; display: flex; align-items: center; gap: 0.75rem; }
.topbar img { width: 120px; display: block; }
.topbar .menu-btn { border: 1px solid var(--line); background: var(--white); border-radius: var(--radius-pill); padding: 0.45rem 0.9rem; display: inline-flex; align-items: center; gap: 0.5rem; font-weight: 600; color: var(--ink); }
.topbar .menu-btn svg { width: 20px; height: 20px; }
.topbar .count { margin-left: auto; }
.app-main { flex: 1; min-width: 0; }
.content { padding: 1.25rem 1rem 2rem; max-width: 1400px; }
@media (min-width: 992px) {
.app { display: flex; min-height: 100vh; }
.side { width: 240px; flex: none; position: sticky; top: 0; height: 100vh; background: var(--white); border-right: 1px solid var(--line); padding: 1.5rem 1rem; display: flex; flex-direction: column; }
.side-panel { display: flex; flex-direction: column; }
.side .offcanvas-body { flex-grow: 1; overflow-y: auto; }
.content { padding: 2rem 2.5rem; }
}
/* ---------- paginakop en hulpklassen ---------- */
.head { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
.head h1 { margin: 0; }
.head h1 { margin: 0; overflow-wrap: anywhere; }
.kicker { font-size: 0.75rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--orange); }
.label { font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); }
.muted { color: var(--ink-muted); }
.empty { color: var(--ink-muted); padding: 1rem 0; }
.card { background: var(--white); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 1.5rem; margin-bottom: 1.5rem; }
.grid { display: grid; gap: 1.5rem; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); margin-bottom: 1.5rem; }
.grid .card { margin: 0; }
.two { display: grid; grid-template-columns: minmax(0, 1fr) 380px; gap: 1.5rem; align-items: start; }
.kpis { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); margin-bottom: 1.5rem; }
.kpi { background: var(--white); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 1.25rem 1.5rem; }
.kpi .value { font-family: var(--font-heading); font-weight: 600; font-size: 2rem; line-height: 1.1; margin: 0.35rem 0 0.15rem; }
/* knoppen en formulieren */
.btn { display: inline-flex; align-items: center; gap: 0.5rem; font: 600 15px/1 var(--font-body); padding: 0.75rem 1.4rem; border-radius: var(--radius-pill); border: 1px solid var(--orange); background: var(--orange); color: var(--white); cursor: pointer; white-space: nowrap; }
.btn:hover { background: var(--orange-dark); text-decoration: none; }
.btn.ghost { background: var(--white); color: var(--orange); border-color: var(--orange-line); }
.btn.ghost:hover { background: var(--orange-tint-soft); border-color: var(--orange); }
.btn.quiet { background: var(--white); color: var(--ink-muted); border-color: var(--line); }
.btn.small { padding: 0.45rem 0.9rem; font-size: 14px; }
form.inline { display: inline; }
label { display: block; font-weight: 600; font-size: 0.9rem; margin-bottom: 0.3rem; }
input, select, textarea { width: 100%; font: 16px/1.4 var(--font-body); color: var(--ink); padding: 0.6rem 0.9rem; border: 1px solid var(--control); border-radius: var(--radius-sm); background: var(--white); }
input[type=checkbox] { width: auto; margin-right: 0.4rem; accent-color: var(--orange); }
input[type=file] { border-style: dashed; }
textarea { min-height: 110px; resize: vertical; }
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--orange); box-shadow: 0 0 0 0.25rem var(--orange-focus); }
.fields { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.fields .full { grid-column: 1 / -1; }
.actions { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; margin-top: 1.25rem; }
.head .actions { margin: 0; }
.empty { color: var(--ink-muted); padding: 1rem 0; margin: 0; }
.hint { font-size: 0.875rem; color: var(--ink-muted); margin: 0.3rem 0 0; }
.actions { display: flex; gap: 0.6rem; flex-wrap: wrap; align-items: center; margin-top: 1.25rem; }
.head .actions, .card-top .actions { margin: 0; }
form.inline { display: inline; }
.search input { width: 260px; }
/* tabellen */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--line); white-space: nowrap; }
td { padding: 0.7rem 0.75rem; border-bottom: 1px solid var(--line); vertical-align: top; }
tbody tr:last-child td { border-bottom: 0; }
@media (max-width: 767.98px) {
h1 { font-size: 1.6rem; }
.head { flex-direction: column; align-items: stretch; }
.head .actions > * { flex: 1 1 auto; }
.head .actions .btn, .head .actions .form-select { width: 100%; }
.head .actions .search { flex-basis: 100%; }
.search input { width: 100%; }
}
/* ---------- kaarten en kerncijfers ---------- */
.card { --bs-card-spacer-y: 1.5rem; --bs-card-spacer-x: 1.5rem; --bs-card-border-color: var(--line); --bs-card-border-radius: var(--radius-lg); --bs-card-inner-border-radius: calc(var(--radius-lg) - 1px); --bs-card-bg: var(--white); margin-bottom: 1.5rem; }
.card-body > :last-child { margin-bottom: 0; }
.card-grid > * > .card { margin-bottom: 0; height: 100%; }
.card-top { display: flex; justify-content: space-between; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 1rem; }
.card-top h2 { margin: 0; }
.card-top .actions { gap: 0.5rem; }
@media (max-width: 575.98px) {
.card { --bs-card-spacer-y: 1.1rem; --bs-card-spacer-x: 1rem; }
}
.kpi { background: var(--white); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 1.25rem 1.5rem; height: 100%; }
.kpi .value { font-family: var(--font-heading); font-weight: 600; font-size: 2rem; line-height: 1.1; margin: 0.35rem 0 0.15rem; white-space: nowrap; }
/* ---------- knoppen ---------- */
.btn { --bs-btn-font-family: var(--font-body); --bs-btn-font-weight: 600; --bs-btn-font-size: 15px; --bs-btn-line-height: 1.1; --bs-btn-padding-x: 1.4rem; --bs-btn-padding-y: 0.72rem; --bs-btn-border-radius: var(--radius-pill); --bs-btn-focus-box-shadow: 0 0 0 0.25rem var(--orange-focus); display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; white-space: nowrap; }
.btn-sm { --bs-btn-padding-x: 0.9rem; --bs-btn-padding-y: 0.45rem; --bs-btn-font-size: 14px; --bs-btn-border-radius: var(--radius-pill); }
.btn-primary { --bs-btn-color: #fff; --bs-btn-bg: #b95913; --bs-btn-border-color: #b95913; --bs-btn-hover-color: #fff; --bs-btn-hover-bg: #a24e11; --bs-btn-hover-border-color: #a24e11; --bs-btn-active-color: #fff; --bs-btn-active-bg: #8f450f; --bs-btn-active-border-color: #8f450f; --bs-btn-disabled-color: #fff; --bs-btn-disabled-bg: #b95913; --bs-btn-disabled-border-color: #b95913; }
.btn-outline-primary { --bs-btn-color: #b95913; --bs-btn-bg: #fff; --bs-btn-border-color: var(--orange-line); --bs-btn-hover-color: #b95913; --bs-btn-hover-bg: var(--orange-tint-soft); --bs-btn-hover-border-color: #b95913; --bs-btn-active-color: #b95913; --bs-btn-active-bg: var(--orange-tint); --bs-btn-active-border-color: #b95913; --bs-btn-disabled-color: #b95913; --bs-btn-disabled-bg: #fff; --bs-btn-disabled-border-color: var(--orange-line); }
.btn-outline-secondary { --bs-btn-color: var(--ink-muted); --bs-btn-bg: #fff; --bs-btn-border-color: rgba(0, 0, 0, 0.12); --bs-btn-hover-color: var(--ink); --bs-btn-hover-bg: var(--light); --bs-btn-hover-border-color: rgba(0, 0, 0, 0.2); --bs-btn-active-color: var(--ink); --bs-btn-active-bg: var(--light); --bs-btn-active-border-color: rgba(0, 0, 0, 0.2); }
.linkbtn { background: none; border: 0; padding: 0; color: var(--ink-muted); font: 600 0.8rem var(--font-body); cursor: pointer; text-decoration: underline; }
/* ---------- formulieren ---------- */
.form-label { font-weight: 600; font-size: 0.9rem; margin-bottom: 0.3rem; }
.form-control, .form-select { --bs-border-color: var(--control); font-size: 16px; line-height: 1.4; padding-top: 0.6rem; padding-bottom: 0.6rem; padding-left: 0.9rem; color: var(--ink); }
.form-control { padding-right: 0.9rem; }
.form-control-sm, .form-select-sm { font-size: 15px; padding-top: 0.45rem; padding-bottom: 0.45rem; padding-left: 0.6rem; }
.form-control-sm { padding-right: 0.6rem; }
textarea.form-control { min-height: 110px; resize: vertical; }
textarea.form-control-sm { min-height: 42px; }
input[type=file].form-control { border-style: dashed; }
.form-control:focus, .form-select:focus { border-color: var(--orange); box-shadow: 0 0 0 0.25rem var(--orange-focus); }
.form-check-input { border-color: var(--control); }
.form-check-input:checked { background-color: var(--orange); border-color: var(--orange); }
.form-check-input:focus { border-color: var(--orange); box-shadow: 0 0 0 0.25rem var(--orange-focus); }
.form-check-label { font-weight: 400; }
/* ---------- tabellen ---------- */
.table { --bs-table-bg: transparent; --bs-table-color: var(--ink); --bs-table-border-color: var(--line); --bs-table-hover-bg: var(--orange-tint-soft); --bs-table-hover-color: var(--ink); margin-bottom: 0; }
.table > :not(caption) > * > * { padding: 0.7rem 0.75rem; vertical-align: top; }
.table > thead th { font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); padding-top: 0.5rem; padding-bottom: 0.5rem; white-space: nowrap; }
.table > tbody > tr:last-child > * { border-bottom-width: 0; }
tr.link { cursor: pointer; }
tr.link:hover td { background: var(--orange-tint-soft); }
.num { text-align: right; white-space: nowrap; }
.thumb-sm { width: 48px; height: 48px; border-radius: 10px; object-fit: cover; background: var(--blue); display: block; }
tr.unread td { font-weight: 600; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--orange); margin-right: 0.4rem; vertical-align: middle; }
/* mobiel: elke tabelrij wordt een blokje met label · waarde */
@media (max-width: 767.98px) {
.table-stack thead { display: none; }
.table-stack > tbody > tr { display: block; padding: 0.8rem 0; border-bottom: 1px solid var(--line); }
.table-stack > tbody > tr:last-child { border-bottom: 0; }
.table-stack > tbody > tr > td { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding: 0.1rem 0; border: 0; text-align: right; box-shadow: none; }
.table-stack > tbody > tr > td::before { content: attr(data-label); flex: none; text-align: left; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); }
.table-stack > tbody > tr > td.stack-title { display: block; text-align: left; padding-bottom: 0.3rem; }
.table-stack > tbody > tr > td.stack-title::before { content: none; }
.table-stack > tbody > tr > td:empty { display: none; }
.table-stack > tbody > tr.link:active { background: var(--orange-tint-soft); }
}
/* ---------- labels / statussen ---------- */
.chip { display: inline-block; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; padding: 0.15rem 0.65rem; border-radius: 999px; background: var(--blue); color: var(--ink); white-space: nowrap; }
.chip.nieuw, .chip.concept { background: var(--orange-tint); color: var(--orange-dark); }
.chip.offerte, .chip.verzonden { background: var(--blue-dark); }
@@ -100,77 +179,55 @@ tr.link:hover td { background: var(--orange-tint-soft); }
.chip.verloren, .chip.afgewezen { background: var(--light); color: var(--ink-muted); }
.filters { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; align-items: center; }
.filters a { padding: 0.35rem 0.9rem; border-radius: 999px; border: 1px solid var(--line); color: var(--ink); background: var(--white); font-size: 0.9rem; }
.filters a { padding: 0.35rem 0.9rem; border-radius: 999px; border: 1px solid var(--line); color: var(--ink); background: var(--white); font-size: 0.9rem; white-space: nowrap; }
.filters a:hover { border-color: var(--orange-line); text-decoration: none; }
.filters a.on { background: var(--orange); border-color: var(--orange); color: var(--white); }
.search { display: flex; gap: 0.5rem; }
.search input { width: 260px; }
.filters a.on .muted { color: inherit; }
@media (max-width: 767.98px) {
/* filterknoppen horizontaal schuiven in plaats van vijf regels hoog */
.filters { flex-wrap: nowrap; overflow-x: auto; margin-left: -1rem; margin-right: -1rem; padding: 0 1rem 0.25rem; scrollbar-width: none; }
.filters::-webkit-scrollbar { display: none; }
.filters > * { flex: none; }
}
.flash { padding: 0.8rem 1.2rem; border-radius: var(--radius-md); background: var(--blue-light); border: 1px solid var(--blue); margin-bottom: 1rem; }
.flash.error { background: var(--orange-tint-soft); border-color: var(--orange-line); }
/* grafieken */
/* ---------- grafieken ---------- */
.bars { display: flex; flex-direction: column; gap: 0.6rem; }
.bar { display: grid; grid-template-columns: 110px 1fr 40px; gap: 0.75rem; align-items: center; color: var(--ink); font-weight: 400; }
.bar { display: grid; grid-template-columns: minmax(80px, 110px) 1fr 40px; gap: 0.75rem; align-items: center; color: var(--ink); font-weight: 400; }
.bar > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
a.bar:hover { text-decoration: none; color: var(--orange); }
.bar .track { display: block; background: var(--blue-light); border-radius: 999px; height: 10px; overflow: hidden; }
.bar .fill { display: block; background: var(--orange); height: 100%; border-radius: 999px; }
.cols { display: flex; align-items: flex-end; gap: 6px; height: 170px; }
.cols > div { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; height: 100%; gap: 4px; font-size: 0.75rem; color: var(--ink-muted); }
.cols > div { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; height: 100%; gap: 4px; font-size: 0.75rem; color: var(--ink-muted); }
.cols .c { width: 100%; background: var(--orange); border-radius: 6px 6px 0 0; min-height: 2px; }
/* contactpagina */
dl.props { display: grid; grid-template-columns: max-content 1fr; gap: 0.4rem 1rem; margin: 0; }
dl.props dt { color: var(--ink-muted); }
dl.props dd { margin: 0; word-break: break-word; white-space: pre-line; }
/* ---------- contact- en relatiepagina ---------- */
dl.props { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 0.4rem 1rem; margin: 0; }
dl.props dt { color: var(--ink-muted); font-weight: 400; }
dl.props dd { margin: 0; overflow-wrap: anywhere; white-space: pre-line; }
.timeline { list-style: none; margin: 0; padding: 0; }
.timeline li { padding: 0.8rem 0; border-bottom: 1px solid var(--line); }
.timeline li:last-child { border: 0; }
.timeline .meta { font-size: 0.8125rem; color: var(--ink-muted); margin-bottom: 0.15rem; }
.timeline .body { white-space: pre-wrap; word-break: break-word; }
.timeline .body { white-space: pre-wrap; overflow-wrap: anywhere; }
.files { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 0.75rem; margin-bottom: 1rem; }
.file { border: 1px solid var(--line); border-radius: var(--radius-md); overflow: hidden; font-size: 0.8rem; background: var(--white); }
.file .thumb { aspect-ratio: 1; background: var(--blue); display: flex; align-items: center; justify-content: center; color: var(--ink); font-weight: 600; text-transform: uppercase; }
.file .thumb { aspect-ratio: 1; background: var(--blue); display: flex; align-items: center; justify-content: center; color: var(--ink); font-weight: 600; text-transform: uppercase; overflow: hidden; }
.file img { width: 100%; height: 100%; object-fit: cover; display: block; }
.file .name { padding: 0.35rem 0.5rem 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
.file form { padding: 0 0.5rem 0.35rem; }
.linkbtn { background: none; border: 0; padding: 0; color: var(--ink-muted); font: 600 0.8rem var(--font-body); cursor: pointer; text-decoration: underline; }
.file .muted { padding: 0 0.5rem 0.35rem; font-size: 0.8rem; }
.viewer-frame { aspect-ratio: 16 / 10; min-height: 320px; border: 1px solid var(--line); border-radius: var(--radius-md); overflow: hidden; background: var(--blue); }
.viewer-frame iframe { display: block; width: 100%; height: 100%; border: 0; }
details summary { cursor: pointer; font-family: var(--font-heading); font-size: 1.3rem; font-weight: 500; }
details[open] summary { margin-bottom: 1rem; }
/* inbox */
.mailbody { white-space: pre-wrap; word-break: break-word; font-size: 15px; line-height: 1.7; }
tr.unread td { font-weight: 600; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--orange); margin-right: 0.4rem; vertical-align: middle; }
/* offerte-editor */
.lines td { padding: 0.4rem 0.3rem; vertical-align: middle; }
.lines input, .lines select, .lines textarea { padding: 0.45rem 0.6rem; font-size: 15px; }
.lines textarea { min-height: 42px; height: 42px; }
.totals { margin-left: auto; width: 320px; max-width: 100%; margin-top: 1rem; }
.totals td { padding: 0.3rem 0.75rem; border: 0; }
.totals tr.grand td { font-family: var(--font-heading); font-weight: 600; font-size: 1.2rem; border-top: 1px solid var(--line); padding-top: 0.6rem; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; background: var(--blue-light); border-radius: var(--radius-sm); }
code { padding: 0.1rem 0.35rem; word-break: break-all; }
pre { padding: 1rem; overflow-x: auto; }
/* inloggen */
.auth { margin: auto; width: 100%; max-width: 420px; padding: 16px; }
.auth .card { padding: 2rem; }
.auth img { width: 200px; margin-bottom: 1.5rem; }
@media (max-width: 900px) {
body { flex-direction: column; }
.side { width: auto; height: auto; position: static; flex-direction: row; align-items: center; padding: 0.75rem 16px; gap: 0.75rem; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); }
.brand img { width: 110px; margin: 0; }
.side nav { flex-direction: row; }
.side nav a, .logout { padding: 0.45rem 0.8rem; }
main { padding: 1.25rem 16px; }
.two { grid-template-columns: 1fr; }
.search, .search input { width: 100%; }
h1 { font-size: 1.6rem; }
}
.mailbody { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 15px; line-height: 1.7; }
.members { display: grid; }
.member { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem 1rem; padding: 0.6rem 0; border-bottom: 1px solid var(--line); }
.member:last-child { border-bottom: 0; }
/* taken */
.task { display: flex; gap: 0.75rem; align-items: flex-start; padding: 0.6rem 0; border-bottom: 1px solid var(--line); }
@@ -179,28 +236,59 @@ pre { padding: 1rem; overflow-x: auto; }
.check { flex: none; width: 22px; height: 22px; margin-top: 2px; border-radius: 50%; border: 2px solid var(--orange); background: var(--white); cursor: pointer; padding: 0; }
.check:hover, .task.done .check { background: var(--orange); }
.overdue { color: var(--orange-dark); font-weight: 600; }
.inline-fields { display: grid; grid-template-columns: 1fr 160px; gap: 0.5rem; }
/* portaal */
.portal-top { background: var(--white); border-bottom: 1px solid var(--line); padding: 1rem 16px; }
.portal-top .wrap, .portal-main { max-width: 1000px; margin: 0 auto; }
.portal-top .wrap { display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
/* contact/relatie op mobiel: gegevens bovenaan, de 3D-weergave en de rest daaronder */
@media (max-width: 991.98px) {
.stack { display: flex; flex-direction: column; margin: 0; }
.stack > * { display: contents; }
.stack .o-1 { order: -3; }
.stack .o-2 { order: -2; }
.stack .o-3 { order: -1; }
.viewer-frame { aspect-ratio: 4 / 5; min-height: 0; max-height: 70vh; }
}
/* ---------- offerte-editor ---------- */
.lines > :not(caption) > * > * { padding: 0.4rem 0.3rem; vertical-align: middle; }
.lines textarea.form-control { height: 42px; }
.totals { margin-left: auto; width: 320px; max-width: 100%; margin-top: 1rem; }
.totals td { padding: 0.3rem 0.75rem; border: 0; }
.totals tr.grand td { font-family: var(--font-heading); font-weight: 600; font-size: 1.2rem; border-top: 1px solid var(--line); padding-top: 0.6rem; }
@media (max-width: 767.98px) {
/* elke regel als blokje: product en omschrijving breed, de getallen twee aan twee */
.lines thead { display: none; }
.lines, .lines > tbody { display: block; }
.lines > tbody > tr { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem 0.75rem; padding: 0.9rem 0; border-bottom: 1px solid var(--line); }
.lines > tbody > tr > td { display: block; padding: 0; border: 0; }
.lines > tbody > tr > td::before { content: attr(data-label); display: block; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); margin-bottom: 0.15rem; }
.lines > tbody > tr > td:nth-child(-n+2) { grid-column: 1 / -1; }
.lines .amt { text-align: left; font-weight: 600; align-self: end; }
.lines .del-cell { text-align: right; align-self: end; }
.lines .del-cell::before { content: none; }
.lines .del { font-size: 0.9rem; }
.lines .del::after { content: " verwijderen"; }
}
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; background: var(--blue-light); border-radius: var(--radius-sm); }
code { padding: 0.1rem 0.35rem; word-break: break-all; color: var(--ink); }
pre { padding: 1rem; overflow-x: auto; }
/* ---------- inloggen ---------- */
.auth-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 16px; }
.auth { width: 100%; max-width: 420px; }
.auth .card { --bs-card-spacer-y: 2rem; --bs-card-spacer-x: 2rem; }
.auth img { width: 200px; margin-bottom: 1.5rem; }
/* ---------- portaal ---------- */
.portal-top { background: var(--white); border-bottom: 1px solid var(--line); padding: 1rem 0; }
.portal-top img { width: 160px; display: block; }
.portal-main { padding: 2rem 16px; width: 100%; }
body.portal { flex-direction: column; }
.portal-main { padding-top: 2rem; padding-bottom: 2rem; }
/* dialoog */
.dialog { border: 0; border-radius: var(--radius-lg); padding: 1.75rem; width: min(480px, calc(100vw - 32px)); box-shadow: var(--shadow-menu, 0 10px 30px rgba(0,0,0,.1)); }
/* ---------- dialoog ---------- */
.dialog { border: 0; border-radius: var(--radius-lg); padding: 1.75rem; width: min(480px, calc(100vw - 32px)); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); }
.dialog::backdrop { background: rgba(255, 255, 255, 0.6); backdrop-filter: blur(2px); }
.dialog label { font-weight: 600; }
/* relaties */
.members { display: grid; gap: 0.5rem; }
.member { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 0.6rem 0; border-bottom: 1px solid var(--line); }
.member:last-child { border-bottom: 0; }
/* beweging (huisstijl: lift 4–6px met shadow-hover en oranje rand, beeld schaalt 1.03–1.06) */
.fx-wait main, .fx-wait .portal-main, .fx-wait .auth { opacity: 0; }
/* ---------- beweging (huisstijl: lift 4–6px met shadow-hover en oranje rand, beeld schaalt 1.03–1.06) ---------- */
.fx-wait .content, .fx-wait .portal-main, .fx-wait .auth { opacity: 0; }
.kpi, .file, a.bar, .btn, .side nav a { transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.6s cubic-bezier(0.22, 1, 0.36, 1), border-color 0.4s, background-color 0.25s, color 0.25s; }
.kpi:hover, .file:hover { transform: translateY(-5px); box-shadow: 0 1.25rem 2.5rem -1rem rgba(0, 0, 0, 0.18); border-color: var(--orange-line); }
.file img { transition: transform 0.8s cubic-bezier(0.22, 1, 0.36, 1); }
+512
View File
@@ -0,0 +1,512 @@
/* 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';
// Afgewerkte woning met 3D, zoals light.js: met skip3D hebben de wandstukken geen specs (gewicht) en faalt computeTotals.
const full = HouseModel.computeParts(st, Object.assign({ layers: ViewLayers.all(), finishedOnly: true }, mats));
HouseModel.rootGroups(full).forEach(disposeGeo);
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 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>
<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>
<div class="fields">
<div class="full"><label for="old">Huidig wachtwoord</label><input 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="row g-3">
<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="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 class="actions"><button class="btn">Opslaan</button></div>
</form>
<div class="actions"><button class="btn btn-primary">Opslaan</button></div>
</div></form>
{% endblock %}
+17 -13
View File
@@ -2,32 +2,36 @@
{% set titles = {'setup': 'Welkom', 'login': 'Inloggen', 'portal_login': 'Klantportaal', 'invite': 'Wachtwoord instellen'} %}
{% block title %}{{ titles[mode] }}{% endblock %}
{% block body %}
<div class="auth-wrap">
<div class="auth">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous">
{% with msgs = get_flashed_messages(with_categories=true) %}
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
{% endwith %}
<div class="card">
<div class="card"><div class="card-body">
<form method="post">
<h1>{{ titles[mode] }}</h1>
<div class="row g-3">
{% if mode == 'setup' %}
<p class="muted">Maak het eerste beheerdersaccount aan.</p>
<label for="name">Naam</label><input id="name" name="name" required autofocus>
<label for="email" style="margin-top:1rem">E-mailadres</label><input id="email" type="email" name="email" required>
<p class="muted mb-0">Maak het eerste beheerdersaccount aan.</p>
<div class="col-12"><label class="form-label" for="name">Naam</label><input class="form-control" id="name" name="name" required autofocus></div>
<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' %}
<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 %}
{% if mode == 'portal_login' %}<p class="muted">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>
{% if mode == 'portal_login' %}<p class="muted mb-0">Bekijk uw offertes en deel bestanden met ons.</p>{% endif %}
<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 %}
<label for="pw" style="margin-top:1rem">Wachtwoord</label>
<input id="pw" type="password" name="password" {{ 'minlength=8' if mode in ('setup', 'invite') }} required>
<div class="col-12"><label class="form-label" for="pw">Wachtwoord</label>
<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') %}
<label for="pw2" style="margin-top:1rem">Herhaal wachtwoord</label>
<input id="pw2" type="password" name="password2" minlength="8" required>
<div class="col-12"><label class="form-label" for="pw2">Herhaal wachtwoord</label>
<input class="form-control" id="pw2" type="password" name="password2" minlength="8" required autocomplete="new-password"></div>
{% endif %}
<div class="actions"><button class="btn">{{ 'Inloggen' if mode in ('login', 'portal_login') else 'Opslaan en doorgaan' }}</button></div>
</form>
</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>
</div></div>
</div>
</div>
{% endblock %}
+24 -3
View File
@@ -7,6 +7,7 @@
<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') }}">
<script>
/* inhoud verbergen tot de GSAP-intro klaar staat; na 3 s altijd zichtbaar */
@@ -15,14 +16,22 @@
setTimeout(() => document.documentElement.classList.remove('fx-wait'), 3000);
}
</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="{{ url_for('static', filename='fx.js') }}" defer></script>
</head>
<body>
{% block body %}
{% set ep = request.endpoint or '' %}
<div class="app">
<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">
<div class="offcanvas-header">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous">
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#sidenav" aria-label="Menu sluiten"></button>
</div>
<div class="offcanvas-body">
<a href="{{ url_for('dashboard') }}" class="brand d-none d-lg-block"><img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous"></a>
{% set ctype = request.args.get('type') or (c.type if c is defined and c and c.type is defined else '') %}
{% set rkind = request.args.get('kind') or (r.kind if r is defined and r and r.kind is defined else '') %}
<nav>
@@ -42,13 +51,25 @@
</nav>
<a href="{{ url_for('account') }}" class="logout">{{ g.user.name }}</a>
<a href="{{ url_for('logout') }}" class="logout">Uitloggen</a>
</div>
</div>
</aside>
<main>
<div class="app-main">
<header class="topbar d-lg-none">
<button class="menu-btn" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidenav" aria-controls="sidenav">
<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
</button>
<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>
</main>
</div>
</div>
{% endblock %}
</body>
</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' %}
{% block title %}{{ c.name }}{% if c.type == 'lead' %}
<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 title %}{{ c.name }}{% endblock %}
{% block content %}
<div class="head">
<div>
@@ -30,24 +10,77 @@
</div>
<div class="actions">
<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 %}
</select>
</form>
{% 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 %}
<form method="post" action="{{ url_for('quote_new') }}" class="inline">
<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>
<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 class="two">
<div>
<div class="card">
<div class="row gx-4 stack">
<div class="col-lg-7 col-xl-8">
<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>
{% for t in tasks %}
<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>
</form>
{% endfor %}
<form method="post" action="{{ url_for('contact_task', cid=c.id) }}" style="margin-top:1rem">
<div class="inline-fields">
<input name="title" placeholder="Bijv. terugbellen, offerte nabellen" required aria-label="Taak">
<input type="date" name="due_date" aria-label="Datum">
</div>
<div class="actions" style="margin-top:.5rem">
<select name="user_id" style="width:auto" aria-label="Voor">
<form method="post" action="{{ url_for('contact_task', cid=c.id) }}" class="row g-2 mt-2">
<div class="col-12 col-md"><input class="form-control" name="title" placeholder="Bijv. terugbellen, offerte nabellen" required aria-label="Taak"></div>
<div class="col-6 col-md-auto"><input class="form-control" type="date" name="due_date" aria-label="Datum"></div>
<div class="col-6 col-md-auto">
<select class="form-select" name="user_id" aria-label="Voor">
{% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if u.id == g.user.id }}>{{ u.name }}</option>{% endfor %}
</select>
<button class="btn ghost small">Taak toevoegen</button>
</div>
<div class="col-12 col-md-auto"><button class="btn btn-outline-primary w-100">Taak toevoegen</button></div>
</form>
</div>
</div></div>
<div class="card">
<div class="card o-3"><div class="card-body">
<h2>Notitie toevoegen</h2>
<form method="post" action="{{ url_for('contact_note', cid=c.id) }}">
<textarea name="body" required placeholder="Gebeld, afspraak gemaakt, wensen…" aria-label="Notitie"></textarea>
<div class="actions"><button class="btn">Opslaan</button></div>
<textarea class="form-control" name="body" required placeholder="Gebeld, afspraak gemaakt, wensen…" aria-label="Notitie"></textarea>
<div class="actions"><button class="btn btn-primary">Opslaan</button></div>
</form>
</div>
</div></div>
{% if c.email %}
<div class="card">
<div class="card"><div class="card-body">
<details>
<summary>E-mail sturen</summary>
<form method="post" action="{{ url_for('mail_send') }}">
<input type="hidden" name="contact_id" value="{{ c.id }}">
<div class="fields">
<div class="full"><label>Aan</label><input type="email" name="to" value="{{ c.email }}" required></div>
<div class="full"><label>Onderwerp</label><input name="subject" required></div>
<div class="full"><label>Bericht</label><textarea name="body" rows="8" required></textarea></div>
<div class="row g-3">
<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="col-12"><label class="form-label" for="m-subject">Onderwerp</label><input class="form-control" id="m-subject" name="subject" required></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 class="actions"><button class="btn">Versturen</button></div>
<div class="actions"><button class="btn btn-primary">Versturen</button></div>
</form>
</details>
</div>
</div></div>
{% endif %}
<div class="card">
<div class="card"><div class="card-body">
<h2>Tijdlijn</h2>
<ul class="timeline">
{% for a in activities %}
@@ -107,28 +138,28 @@
<li class="empty">Nog geen activiteit.</li>
{% endfor %}
</ul>
</div>
</div></div>
{% if emails %}
<div class="card">
<div class="card"><div class="card-body">
<h2>E-mail</h2>
<div class="table-wrap"><table>
<div class="table-responsive"><table class="table table-hover table-stack">
<tbody>
{% for e in emails %}
<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><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td>
<td class="muted num">{{ e.date|dt }}</td>
<td class="stack-title"><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 data-label="Datum" class="muted num">{{ e.date|dt }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
</div>
</div></div>
{% endif %}
</div>
<div>
<div class="card">
<div class="col-lg-5 col-xl-4">
<div class="card o-1"><div class="card-body">
<h2>Gegevens</h2>
<dl class="props">
<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 %}
</dl>
{% endif %}
</div>
</div></div>
<div class="card">
<div class="card"><div class="card-body">
<h2>Bestanden</h2>
{% if files %}
<div class="files">
{% for f in files %}
<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">
{% 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>
<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?')">
<button class="linkbtn">Verwijderen</button>
</form>
@@ -172,12 +207,12 @@
</div>
{% endif %}
<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">
<div class="actions"><button class="btn ghost small">Uploaden</button></div>
<input class="form-control" type="file" name="files" multiple required aria-label="Bestanden kiezen">
<div class="actions"><button class="btn btn-outline-primary btn-sm">Uploaden</button></div>
</form>
</div>
</div></div>
<div class="card">
<div class="card"><div class="card-body">
<h2>Offertes</h2>
{% 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>
@@ -185,27 +220,27 @@
{% else %}
<p class="empty">Nog geen offertes.</p>
{% endfor %}
</div>
</div></div>
<div class="card">
<div class="card"><div class="card-body">
<h2>Klantportaal</h2>
{% 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 %}
<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>
{% 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 %}
<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 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 c.portal_password_hash or portal_link %}<button class="btn quiet small" name="action" value="revoke">Toegang intrekken</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 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 btn-outline-secondary btn-sm" name="action" value="revoke">Toegang intrekken</button>{% endif %}
</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?')">
<button class="btn quiet small">Contact verwijderen</button>
<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 btn-outline-secondary btn-sm">Contact verwijderen</button>
</form>
</div>
</div>
@@ -214,17 +249,23 @@
<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">
<div class="form-check">
<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 %}
<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">
<div class="form-check">
<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 %}
</select>
{% endif %}
<div class="actions">
<button class="btn">Omzetten</button>
<button class="btn quiet" type="button" onclick="this.closest('dialog').close()">Annuleren</button>
<button class="btn btn-primary">Omzetten</button>
<button class="btn btn-outline-secondary" type="button" onclick="this.closest('dialog').close()">Annuleren</button>
</div>
</form>
</dialog>
+24 -24
View File
@@ -7,33 +7,33 @@
<h1>{{ ('Nieuwe ' ~ TYPE.get(c.type, 'lead')|lower) if new else 'Contact bewerken' }}</h1>
</div>
</div>
<form method="post" class="card">
<div class="fields">
<div><label for="name">Naam *</label><input 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><label for="email">E-mail</label><input 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="full"><label for="address">Adres</label><input id="address" name="address" value="{{ c.address }}"></div>
<div><label for="postcode">Postcode</label><input id="postcode" name="postcode" value="{{ c.postcode }}"></div>
<div><label for="city">Plaats</label><input id="city" name="city" value="{{ c.city }}"></div>
<div><label for="status">Status</label>
<select id="status" name="status">
<form method="post" class="card"><div class="card-body">
<div class="row g-3">
<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 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 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 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="col-12"><label class="form-label" for="address">Adres</label><input class="form-control" id="address" name="address" value="{{ c.address }}"></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 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 class="col-md-6 col-xl-4"><label class="form-label" for="status">Status</label>
<select class="form-select" id="status" name="status">
{% for k, l in STATUSES %}<option value="{{ k }}" {{ 'selected' if c.status == k }}>{{ l }}</option>{% endfor %}
</select>
</div>
<div><label for="type">Soort</label>
<select id="type" name="type">
<div class="col-md-6 col-xl-4"><label class="form-label" for="type">Soort</label>
<select class="form-select" id="type" name="type">
{% for k, l in TYPES %}<option value="{{ k }}" {{ 'selected' if c.type == k }}>{{ l }}</option>{% endfor %}
</select>
</div>
<div><label for="assigned">Toegewezen aan</label>
<select id="assigned" name="assigned_user_id">
<div class="col-md-6 col-xl-4"><label class="form-label" for="assigned">Toegewezen aan</label>
<select class="form-select" id="assigned" name="assigned_user_id">
<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 %}
</select>
</div>
<div><label for="relation">Relatie</label>
<select id="relation" name="relation_id">
<div class="col-md-6"><label class="form-label" for="relation">Relatie</label>
<select class="form-select" id="relation" name="relation_id">
<option value="">— Geen —</option>
{% for kind, label in RELATION_KINDS %}
<optgroup label="{{ label }}s">
@@ -43,14 +43,14 @@
</select>
<p class="hint">Huishouden, familie of bedrijf van de klant, of de partner waar deze persoon werkt.</p>
</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><label for="value">Verwachte waarde (€)</label><input id="value" type="number" step="0.01" name="value" value="{{ c.value or '' }}"></div>
<div><label for="source">Bron</label><input 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-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 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 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="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 class="actions">
<button class="btn">Opslaan</button>
<a class="btn quiet" href="{{ url_for('contacts') if new else url_for('contact_view', cid=c.id) }}">Annuleren</a>
<button class="btn btn-primary">Opslaan</button>
<a class="btn btn-outline-secondary" href="{{ url_for('contacts') if new else url_for('contact_view', cid=c.id) }}">Annuleren</a>
</div>
</form>
</div></form>
{% endblock %}
+16 -15
View File
@@ -6,10 +6,10 @@
<div class="actions">
<form class="search" method="get">
{% 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>
<a class="btn ghost" 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-outline-primary" href="{{ url_for('contacts_export') }}">Exporteer CSV</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>
@@ -30,25 +30,26 @@
<a href="{{ url_for('contacts', owner='none', **base) }}" class="{{ 'on' if a.owner == 'none' }}">Niet toegewezen</a>
</div>
<div class="card">
<div class="card"><div class="card-body">
{% if rows %}
<div class="table-wrap"><table>
<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>
<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>Model</th><th>Bron</th><th>Toegewezen</th><th>Status</th><th>Aangemaakt</th></tr></thead>
<tbody>
{% for c in rows %}
<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>{% 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>{{ c.phone }}</td>
<td>{{ c.city }}</td>
<td>{{ c.source }}</td>
<td>{{ c.user_name or '—' }}</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 class="muted">{{ c.created_at|dt('%d-%m-%Y') }}</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 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 data-label="Telefoon">{{ c.phone }}</td>
<td data-label="Plaats">{{ c.city }}</td>
<td data-label="Model">{{ c.design_title or '' }}</td>
<td data-label="Bron">{{ c.source }}</td>
<td data-label="Toegewezen">{{ c.user_name or '—' }}</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>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Geen contacten gevonden.</p>{% endif %}
</div>
</div></div>
{% endblock %}
+33 -25
View File
@@ -3,19 +3,20 @@
{% block content %}
<div class="head">
<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 class="kpis">
<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="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="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="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="row g-3 mb-4">
<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="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>
<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="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 class="grid">
<div class="card">
<div class="row g-4 mb-4 card-grid">
<div class="col-md-6 col-xxl-4">
<div class="card"><div class="card-body">
<h2>Leads per status</h2>
<div class="bars">
{% for key, label, n in pipeline %}
@@ -26,8 +27,10 @@
</a>
{% endfor %}
</div>
</div></div>
</div>
<div class="card">
<div class="col-md-6 col-xxl-4">
<div class="card"><div class="card-body">
<h2>Leads per week</h2>
<div class="cols">
{% for wk, n in weekly %}
@@ -38,45 +41,50 @@
</div>
{% endfor %}
</div>
</div></div>
</div>
<div class="card">
<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 }}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ s.s }}</span>
<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 class="two">
<div class="card">
<div class="row gx-4">
<div class="col-xl-8">
<div class="card"><div class="card-body">
<h2>Laatste leads</h2>
{% if recent %}
<div class="table-wrap"><table>
<div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th>Naam</th><th>Bron</th><th>Toegewezen</th><th>Status</th><th>Binnen</th></tr></thead>
<tbody>
{% for c in recent %}
<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>{{ c.source }}</td>
<td>{{ c.user_name or '—' }}</td>
<td><span class="chip {{ c.status }}">{{ STATUS.get(c.status, c.status) }}</span></td>
<td class="muted">{{ c.created_at|dt }}</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 data-label="Bron">{{ c.source }}</td>
<td data-label="Toegewezen">{{ c.user_name or '—' }}</td>
<td data-label="Status"><span class="chip {{ c.status }}">{{ STATUS.get(c.status, c.status) }}</span></td>
<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>
<div class="card">
<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">
@@ -88,8 +96,8 @@
{% else %}
<p class="empty">Geen openstaande taken.</p>
{% endfor %}
</div>
<div class="card">
</div></div>
<div class="card"><div class="card-body">
<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>
@@ -97,7 +105,7 @@
<p class="empty">Geen ongelezen berichten.</p>
{% endfor %}
<a href="{{ url_for('inbox') }}">Naar inbox</a>
</div>
</div></div>
</div>
</div>
{% endblock %}
+11 -11
View File
@@ -5,35 +5,35 @@
<div>
<a class="label" href="{{ url_for('inbox') }}">← Inbox</a>
<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 %}
· {{ e.date|dt }}
</div>
</div>
<div class="actions">
{% 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' %}
<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 %}
</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' %}
<div class="card">
<div class="card"><div class="card-body">
<h2>Beantwoorden</h2>
<form method="post" action="{{ url_for('mail_send') }}">
<input type="hidden" name="reply_to" value="{{ e.id }}">
{% if contact %}<input type="hidden" name="contact_id" value="{{ contact.id }}">{% endif %}
<div class="fields">
<div><label>Aan</label><input 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="full"><label>Bericht</label><textarea name="body" rows="8" required></textarea></div>
<div class="row g-3">
<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 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="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 class="actions"><button class="btn">Versturen</button></div>
<div class="actions"><button class="btn btn-primary">Versturen</button></div>
</form>
</div>
</div></div>
{% endif %}
{% endblock %}
+14 -10
View File
@@ -2,27 +2,31 @@
{% block title %}Importeren{% endblock %}
{% block content %}
<div class="head"><div><div class="kicker">Gegevens</div><h1>Importeren</h1></div></div>
<div class="two">
<form method="post" enctype="multipart/form-data" class="card">
<div class="row g-4 card-grid">
<div class="col-xl-7">
<form method="post" enctype="multipart/form-data" class="card"><div class="card-body">
<h2>Bestand kiezen</h2>
<div class="fields">
<div class="full"><label for="kind">Wat importeer je?</label>
<select id="kind" name="kind">
<div class="row g-3">
<div class="col-12"><label class="form-label" for="kind">Wat importeer je?</label>
<select class="form-select" id="kind" name="kind">
<option value="contacts">Leads / klanten</option>
<option value="relations" {{ 'selected' if request.args.get('kind') == 'relations' }}>Relaties (klanten of partners)</option>
<option value="products" {{ 'selected' if request.args.get('kind') == 'products' }}>Producten</option>
</select>
</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="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 class="actions"><button class="btn">Volgende: kolommen controleren</button></div>
</form>
<div class="card">
<div class="actions"><button class="btn btn-primary">Volgende: kolommen controleren</button></div>
</div></form>
</div>
<div class="col-xl-5">
<div class="card"><div class="card-body">
<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>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 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>
{% endblock %}
+18 -17
View File
@@ -7,17 +7,18 @@
<div><a class="label" href="{{ url_for('import_start') }}">← Ander bestand</a>
<h1>{{ filename }}</h1>
<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 class="card">
<div class="card"><div class="card-body">
<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>
{% for col in cols %}
<th style="min-width:170px">
<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 %}
</select>
</th>
@@ -30,30 +31,30 @@
</tbody>
</table></div>
{% 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>
<div class="fields">
<div class="row g-3">
{% 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><label>Status (als de kolom ontbreekt)</label><select 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><label>Bron (als de kolom ontbreekt)</label><input name="source" value="Import"></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 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 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 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 %}
{% 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><label>Type (als de kolom ontbreekt)</label>
<select name="relation_type"><option value="">—</option>
<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 class="col-md-6"><label class="form-label" for="o-rtype">Type (als de kolom ontbreekt)</label>
<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 %}
</select></div>
{% endif %}
{% 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>
{% 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>
</form>
{% endblock %}
+9 -10
View File
@@ -2,10 +2,9 @@
{% block title %}Inbox{% endblock %}
{% block content %}
<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">
{% 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">Nieuwe e-mail ophalen</button></form>
<form method="post" action="{{ url_for('inbox_sync') }}" class="inline"><button class="btn btn-primary">Nieuwe e-mail ophalen</button></form>
</div>
</div>
@@ -19,21 +18,21 @@
<a href="{{ url_for('inbox', f='unlinked') }}" class="{{ 'on' if f == 'unlinked' }}">Niet gekoppeld aan contact</a>
</div>
<div class="card">
<div class="card"><div class="card-body">
{% 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>
<tbody>
{% for e in rows %}
<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><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td>
<td>{{ e.contact_name or '' }}</td>
<td class="muted num">{{ e.date|dt }}</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 class="d-none d-md-table-cell"><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td>
<td data-label="Contact">{{ e.contact_name or '' }}</td>
<td data-label="Datum" class="muted num">{{ e.date|dt }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Geen berichten.</p>{% endif %}
</div>
</div></div>
{% endblock %}
+22 -19
View File
@@ -1,41 +1,41 @@
{% extends 'base.html' %}
{% block title %}Klantportaal{% endblock %}
{% block body %}
<script>document.body.classList.add('portal')</script>
<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' }}">
<a href="{{ url_for('portal_logout') }}">Uitloggen</a>
</div>
</header>
<div class="portal-main">
<div class="portal-main container">
{% with msgs = get_flashed_messages(with_categories=true) %}
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
{% endwith %}
<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>
{% 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>
<tbody>
{% for qt in quotes %}
<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>{{ qt.title }}</td>
<td>{{ 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 class="num">{{ qt.total|euro }}</td>
<td class="stack-title"><a href="{{ url_for('portal_quote', qid=qt.id) }}">{{ qt.number }}</a></td>
<td data-label="Onderwerp">{{ qt.title }}</td>
<td data-label="Datum">{{ qt.date|dt('%d-%m-%Y') }}</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 data-label="Totaal incl. btw" class="num">{{ qt.total|euro }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Er staan nog geen offertes voor u klaar.</p>{% endif %}
</div>
</div></div>
<div class="grid">
<div class="card">
<div class="row g-4 mb-4 card-grid">
<div class="col-md-6">
<div class="card"><div class="card-body">
<h2>Bestanden</h2>
{% if files %}
<div class="files">
@@ -51,20 +51,23 @@
</div>
{% endif %}
<form method="post" action="{{ url_for('portal_upload') }}" enctype="multipart/form-data">
<label for="files">Foto's of documenten delen (bijv. van uw tuin of tekeningen)</label>
<input id="files" type="file" name="files" multiple required>
<div class="actions"><button class="btn ghost small">Uploaden</button></div>
<label class="form-label" for="files">Foto's of documenten delen (bijv. van uw tuin of tekeningen)</label>
<input class="form-control" id="files" type="file" name="files" multiple required>
<div class="actions"><button class="btn btn-outline-primary btn-sm">Uploaden</button></div>
</form>
</div></div>
</div>
<div class="card">
<div class="col-md-6">
<div class="card"><div class="card-body">
<h2>Bericht sturen</h2>
<form method="post" action="{{ url_for('portal_message') }}">
<textarea name="body" required aria-label="Bericht" placeholder="Uw vraag of opmerking"></textarea>
<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>
<div class="actions"><button class="btn btn-primary">Versturen</button></div>
</form>
{% for m in messages %}
<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>
{% if s.company_phone or s.company_email %}
+31 -31
View File
@@ -4,61 +4,61 @@
<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>
<form method="post" enctype="multipart/form-data" class="card">
<div class="fields">
<div class="full"><label for="name">Naam *</label><input id="name" name="name" value="{{ p.name }}" required></div>
<div><label for="sku">SKU / artikelnummer</label><input id="sku" name="sku" value="{{ p.sku }}">
<form method="post" enctype="multipart/form-data" class="card"><div class="card-body">
<div class="row g-3">
<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 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>
<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>
<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><label for="price">Prijs excl. btw (€)</label><input id="price" type="number" step="0.01" name="price" value="{{ p.price }}" required></div>
<div><label for="vat">Btw %</label>
<select id="vat" name="vat">
<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 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 class="col-sm-4"><label class="form-label" for="vat">Btw %</label>
<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 %}
</select>
</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>
<div class="full">
<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 %}
<input id="image" type="file" name="image" accept="image/*">
<div class="col-12">
<label class="form-label" for="image">Afbeelding</label>
{% 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 class="form-control" id="image" type="file" name="image" accept="image/*">
</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 class="actions">
<button class="btn">Opslaan</button>
<a class="btn quiet" href="{{ url_for('products') }}">Annuleren</a>
<button class="btn btn-primary">Opslaan</button>
<a class="btn btn-outline-secondary" href="{{ url_for('products') }}">Annuleren</a>
</div>
</form>
</div></form>
{% if p.id %}
<div class="card">
<div class="card"><div class="card-body">
<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 %}
<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>
<tbody>
{% for h in history %}
<tr>
<td class="muted">{{ h.created_at|dt }}</td>
<td>{{ 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 }}
{% 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>
{% elif h.field == 'created' %}<td></td><td 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>
{% else %}<td>{{ h.old_value|truncate(60) }}</td><td>{{ h.new_value|truncate(60) }}</td>{% endif %}
<td class="muted">{{ h.source }}{% if h.user_name %} · {{ h.user_name }}{% endif %}</td>
<td class="stack-title muted">{{ h.created_at|dt }}</td>
<td data-label="Veld">{{ LABELS.get(h.field, h.field) }}</td>
{% 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 %}</span></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 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 data-label="Oud">{{ h.old_value|truncate(60) }}</td><td data-label="Nieuw">{{ h.new_value|truncate(60) }}</td>{% endif %}
<td data-label="Bron" class="muted">{{ h.source }}{% if h.user_name %} · {{ h.user_name }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% 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?' }}')">
<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>
{% endif %}
{% endblock %}
+17 -17
View File
@@ -6,11 +6,11 @@
<div class="actions">
<form class="search" method="get">
<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>
<a class="btn ghost" href="{{ url_for('products_export') }}">Exporteer CSV</a>
<a class="btn ghost" href="{{ url_for('import_start', kind='products') }}">Importeren</a>
<a class="btn" href="{{ url_for('product_edit') }}">Nieuw product</a>
<a class="btn btn-outline-primary" href="{{ url_for('products_export') }}">Exporteer CSV</a>
<a class="btn btn-outline-primary" href="{{ url_for('import_start', kind='products') }}">Importeren</a>
<a class="btn btn-primary" href="{{ url_for('product_edit') }}">Nieuw product</a>
</div>
</div>
<div class="filters">
@@ -19,26 +19,26 @@
{% endfor %}
<span class="muted">{{ rows|length }} product(en)</span>
</div>
<div class="card">
<div class="card"><div class="card-body">
{% if rows %}
<div class="table-wrap"><table>
<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>
<div class="table-responsive"><table class="table table-hover table-stack">
<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>
{% for p in rows %}
<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><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>{% 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 class="num">{{ p.price|euro }}</td>
<td class="num">{{ p.vat|nr }}%</td>
<td 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 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 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 data-label="SKU"><code>{{ p.sku }}</code></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 data-label="Eenheid">{{ p.unit }}</td>
<td data-label="Prijs excl." class="num">{{ p.price|euro }}</td>
<td data-label="Btw" class="num">{{ p.vat|nr }}%</td>
<td data-label="Bijgewerkt" class="muted">{{ (p.updated_at or p.created_at)|dt('%d-%m-%Y') }}</td>
<td data-label="Status">{% if not p.active %}<span class="chip verloren">Inactief</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</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 %}
</div>
</div></div>
{% endblock %}
+54 -31
View File
@@ -2,8 +2,8 @@
{% block title %}Offerte {{ qt.number }}{% endblock %}
{% macro row(l) %}
<tr>
<td>
<select name="l_product" class="prod" aria-label="Product">
<td data-label="Product">
<select name="l_product" class="prod form-select form-select-sm" aria-label="Product">
<option value="">Vrije regel</option>
{% for p in products %}
<option value="{{ p.id }}" {{ 'selected' if l.product_id == p.id }}
@@ -12,14 +12,14 @@
{% endfor %}
</select>
</td>
<td><textarea 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><input 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="Omschrijving"><textarea class="form-control form-control-sm" name="l_desc" rows="1" aria-label="Omschrijving">{{ l.description }}</textarea></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 data-label="Eenheid"><input class="form-control form-control-sm" name="l_unit" value="{{ l.unit }}" aria-label="Eenheid"></td>
<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>
<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 class="num amt"></td>
<td><button type="button" class="linkbtn del" aria-label="Regel verwijderen">✕</button></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" data-label="Totaal"></td>
<td class="del-cell"><button type="button" class="linkbtn del" aria-label="Regel verwijderen">✕</button></td>
</tr>
{% endmacro %}
{% block content %}
@@ -27,52 +27,75 @@
<div class="head">
<div><a class="label" href="{{ url_for('quotes') }}">← Offertes</a><h1>Offerte {{ qt.number }}</h1></div>
<div class="actions">
<button class="btn ghost" name="then" value="print">Opslaan en afdrukken</button>
<button class="btn">Opslaan</button>
<button class="btn btn-outline-primary" name="then" value="print">Opslaan en afdrukken</button>
<button class="btn btn-primary">Opslaan</button>
</div>
</div>
<div class="card">
<div class="fields">
<div><label for="contact">Klant</label>
<select id="contact" name="contact_id">
<div class="card"><div class="card-body">
<div class="row g-3">
<div class="col-md-6"><label class="form-label" for="contact">Klant</label>
<select class="form-select" id="contact" name="contact_id">
<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 %}
</select>
</div>
<div><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>
<div class="col-md-6"><label class="form-label" for="status">Status</label>
<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>
</div>
<div><label for="date">Datum</label><input 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="full"><label for="title">Onderwerp</label><input 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-6"><label class="form-label" for="date">Datum</label><input class="form-control" id="date" type="date" name="date" value="{{ qt.date }}"></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="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="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 class="card">
<div class="card"><div class="card-body">
<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>
<tbody id="rows">{% for l in lines %}{{ row(l) }}{% endfor %}</tbody>
</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">
<tr><td>Subtotaal</td><td class="num" id="t-sub"></td></tr>
<tbody id="t-vat"></tbody>
<tr class="grand"><td>Totaal</td><td class="num" id="t-total"></td></tr>
</table>
</div>
</div></div>
<div class="card">
<label for="notes">Opmerkingen en voorwaarden</label>
<textarea id="notes" name="notes">{{ qt.notes }}</textarea>
</div>
<div class="card"><div class="card-body">
<label class="form-label" for="notes">Opmerkingen en voorwaarden</label>
<textarea class="form-control" id="notes" name="notes">{{ qt.notes }}</textarea>
</div></div>
</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?')">
<button class="btn quiet small">Offerte verwijderen</button>
<button class="btn btn-outline-secondary btn-sm">Offerte verwijderen</button>
</form>
<template id="tpl">{{ row({}) }}</template>
+23
View File
@@ -40,6 +40,13 @@
.notes { margin-top: 32px; }
.sign { margin-top: 40px; display: flex; gap: 48px; }
.sign div { flex: 1; border-top: 1px solid rgba(0,0,0,.3); padding-top: 6px; color: #5c6670; font-size: 12px; }
.bestek { break-before: page; margin-top: 48px; }
.bestek h2 { font-family: "Kantumruy Pro", sans-serif; font-weight: 500; font-size: 15px; margin: 22px 0 4px; display: flex; justify-content: space-between; border-bottom: 2px solid #212121; padding-bottom: 3px; }
.bestek table { width: 100%; border-collapse: collapse; }
.bestek td { padding: 4px 8px 4px 0; border-bottom: 1px solid rgba(0,0,0,.08); vertical-align: top; }
.bestek tr { break-inside: avoid; }
.bestek .info td { color: #5c6670; font-style: italic; }
.bestek .intro { color: #5c6670; font-size: 13px; }
footer { margin-top: 40px; padding-top: 12px; border-top: 1px solid rgba(0,0,0,.08); color: #5c6670; font-size: 12px; text-align: center; }
@media print {
body { background: #fff; }
@@ -124,6 +131,22 @@
{% if s.quote_footer %}<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(' · ') }}
</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>
</body>
</html>
+12 -10
View File
@@ -3,29 +3,31 @@
{% block content %}
<div class="head">
<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 class="filters">
<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 %}
</div>
<div class="card">
<div class="card"><div class="card-body">
{% 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>
<tbody>
{% for qt in rows %}
<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>{{ qt.contact_name or '—' }}</td>
<td>{{ qt.title }}</td>
<td>{{ qt.date|dt('%d-%m-%Y') }}</td>
<td><span class="chip {{ qt.status }}">{{ QSTATUS.get(qt.status, qt.status) }}</span></td>
<td class="num">{{ qt.total|euro }}</td>
<td class="stack-title"><a href="{{ url_for('quote_edit', qid=qt.id) }}">{{ qt.number }}</a></td>
<td data-label="Klant">{{ qt.contact_name or '—' }}</td>
<td data-label="Onderwerp">{{ qt.title }}</td>
<td data-label="Datum">{{ qt.date|dt('%d-%m-%Y') }}</td>
<td data-label="Status"><span class="chip {{ qt.status }}">{{ QSTATUS.get(qt.status, qt.status) }}</span></td>
<td data-label="Totaal incl." class="num">{{ qt.total|euro }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Nog geen offertes.</p>{% endif %}
</div>
</div></div>
{% endblock %}
+52 -30
View File
@@ -11,16 +11,16 @@
{% if r.kind == 'klant' and members %}
<form method="post" action="{{ url_for('quote_new') }}" class="inline">
<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>
{% 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 class="two">
<div>
<div class="card">
<div class="row gx-4 stack">
<div class="col-lg-7 col-xl-8">
<div class="card o-2"><div class="card-body">
<h2>Contactpersonen</h2>
<div class="members">
{% for m in members %}
@@ -28,9 +28,9 @@
<div>
<a href="{{ url_for('contact_view', cid=m.id) }}">{{ m.name }}</a>
{% 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 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 %}
<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>
@@ -41,41 +41,63 @@
<p class="empty">Nog geen contactpersonen.</p>
{% endfor %}
</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 %}
<select name="contact_id" style="width:auto;flex:1;min-width:180px" aria-label="Bestaand contact" required>
<div class="col-md">
<select class="form-select" name="contact_id" aria-label="Bestaand contact" required>
<option value="">Bestaand contact toevoegen…</option>
{% for c in candidates %}<option value="{{ c.id }}">{{ c.name }}{% if c.email %} ({{ c.email }}){% endif %}</option>{% endfor %}
</select>
<input name="relation_role" placeholder="Rol" style="width:160px" aria-label="Rol">
<button class="btn ghost small">Toevoegen</button>
{% 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>
</form>
</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 %}
<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>
</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' %}
<div class="card">
<div class="card"><div class="card-body">
<h2>Producten van deze leverancier</h2>
{% 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>
<tbody>
{% for p in products %}
<tr class="link" onclick="location='{{ url_for('product_edit', pid=p.id) }}'">
<td><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="num">{{ p.price|euro }}</td>
<td class="muted">{{ (p.updated_at or p.created_at)|dt('%d-%m-%Y') }}</td>
<td data-label="SKU"><code>{{ p.sku }}</code></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 data-label="Prijs excl." class="num">{{ p.price|euro }}</td>
<td data-label="Bijgewerkt" class="muted">{{ (p.updated_at or p.created_at)|dt('%d-%m-%Y') }}</td>
</tr>
{% endfor %}
</tbody>
</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 %}
</div>
</div></div>
{% endif %}
<div class="card">
<div class="card"><div class="card-body">
<h2>Recente activiteit</h2>
<ul class="timeline">
{% for a in activities %}
@@ -87,11 +109,11 @@
<li class="empty">Nog geen activiteit.</li>
{% endfor %}
</ul>
</div>
</div></div>
</div>
<div>
<div class="card">
<div class="col-lg-5 col-xl-4">
<div class="card o-1"><div class="card-body">
<h2>Gegevens</h2>
<dl class="props">
{% 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>
</dl>
{% if r.notes %}<h3>Notities</h3><p style="white-space:pre-line;margin:0">{{ r.notes }}</p>{% endif %}
</div>
</div></div>
{% if tasks %}
<div class="card">
<div class="card"><div class="card-body">
<h2>Open taken</h2>
{% for t in tasks %}
<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>
</form>
{% endfor %}
</div>
</div></div>
{% endif %}
{% if r.kind == 'klant' %}
<div class="card">
<div class="card"><div class="card-body">
<h2>Offertes</h2>
{% 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>
@@ -126,7 +148,7 @@
{% else %}
<p class="empty">Nog geen offertes.</p>
{% endfor %}
</div>
</div></div>
{% endif %}
</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>
</div>
</div>
<form method="post" class="card">
<div class="fields">
<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><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>
<form method="post" class="card"><div class="card-body">
<div class="row g-3">
<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 class="col-md-6"><label class="form-label" for="kind">Soort</label>
<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><label for="type">Type</label>
<input id="type" name="type" value="{{ r.type }}" list="types" autocomplete="off">
<div class="col-md-6"><label class="form-label" for="type">Type</label>
<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>
</div>
<div><label for="email">E-mail</label><input 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="full"><label for="address">Adres</label><input id="address" name="address" value="{{ r.address }}"></div>
<div><label for="postcode">Postcode</label><input id="postcode" name="postcode" value="{{ r.postcode }}"></div>
<div><label for="city">Plaats</label><input id="city" name="city" value="{{ r.city }}"></div>
<div><label for="website">Website</label><input 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="full"><label for="notes">Notities</label><textarea id="notes" name="notes">{{ r.notes }}</textarea></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 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="col-12"><label class="form-label" for="address">Adres</label><input class="form-control" id="address" name="address" value="{{ r.address }}"></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 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 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 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="col-12"><label class="form-label" for="notes">Notities</label><textarea class="form-control" id="notes" name="notes">{{ r.notes }}</textarea></div>
</div>
<div class="actions">
<button class="btn">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>
<button class="btn btn-primary">Opslaan</button>
<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>
</form>
</div></form>
{% if r.id %}
<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>
{% endif %}
{% endblock %}
+12 -12
View File
@@ -11,10 +11,10 @@
<form class="search" method="get">
<input type="hidden" name="kind" value="{{ kind }}">
{% 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>
<a class="btn ghost" 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-outline-primary" href="{{ url_for('import_start', kind='relations') }}">Importeren</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>
@@ -25,23 +25,23 @@
{% endfor %}
</div>
<div class="card">
<div class="card"><div class="card-body">
{% 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>
<tbody>
{% for r in rows %}
<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>{% 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>{{ r.city }}</td>
<td class="muted">{{ [r.phone, r.email]|select|join(' · ') }}</td>
{% if kind == 'partner' %}<td class="num">{{ r.products or '' }}</td>{% endif %}
<td class="stack-title"><a href="{{ url_for('relation_view', rid=r.id) }}">{{ r.name }}</a></td>
<td data-label="Type">{% if r.type %}<span class="chip">{{ r.type }}</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 data-label="Plaats">{{ r.city }}</td>
<td data-label="Contact" class="muted">{{ [r.phone, r.email]|select|join(' · ') }}</td>
{% if kind == 'partner' %}<td data-label="Producten" class="num">{{ r.products or '' }}</td>{% endif %}
</tr>
{% endfor %}
</tbody>
</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 %}
</div>
</div></div>
{% endblock %}
+32 -32
View File
@@ -1,16 +1,16 @@
{% extends 'base.html' %}
{% block title %}Instellingen{% endblock %}
{% 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 %}
{% block content %}
<div class="head"><div><div class="kicker">Beheer</div><h1>Instellingen</h1></div></div>
<form method="post">
<div class="card">
<div class="card"><div class="card-body">
<h2>Bedrijfsgegevens</h2>
<p class="hint" style="margin-top:-.4rem;margin-bottom:1rem">Deze gegevens staan op de offertes.</p>
<div class="fields">
<p class="hint" style="margin:-.4rem 0 1rem">Deze gegevens staan op de offertes.</p>
<div class="row g-3">
{{ field('company_name', 'Bedrijfsnaam', full=true) }}
{{ field('company_address', 'Adres') }}
{{ field('company_postcode_city', 'Postcode en plaats') }}
@@ -21,66 +21,66 @@
{{ field('company_btw', 'Btw-nummer') }}
{{ field('company_iban', 'IBAN') }}
</div>
</div>
</div></div>
<div class="card">
<div class="card"><div class="card-body">
<h2>Offertes</h2>
<div class="fields">
<div class="row g-3">
{{ field('quote_prefix', 'Voorvoegsel nummer', placeholder='OFF') }}
{{ 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="full"><label for="quote_footer">Voettekst (bijv. betalingsvoorwaarden)</label><textarea id="quote_footer" name="quote_footer">{{ s.quote_footer }}</textarea></div>
</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="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 class="card" id="mail">
<div class="card" id="mail"><div class="card-body">
<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>
<div class="fields">
<div class="row g-3">
{{ field('imap_host', 'Server', placeholder='imap.gmail.com') }}
{{ field('imap_port', 'Poort', 'number', '993') }}
{{ 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_days', 'Aantal dagen terug ophalen', 'number', '30') }}
</div>
<h3>Versturen (SMTP)</h3>
<div class="fields">
<div class="row g-3">
{{ field('smtp_host', 'Server', placeholder='smtp.gmail.com') }}
{{ field('smtp_port', 'Poort', 'number', '587') }}
{{ 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) }}
</div>
</div>
</div></div>
<div class="card">
<div class="card"><div class="card-body">
<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) }}
<div><label for="api_assign_user">Nieuwe leads toewijzen aan</label>
<select id="api_assign_user" name="api_assign_user">
<div class="col-md-6 col-xl-4"><label class="form-label" for="api_assign_user">Nieuwe leads toewijzen aan</label>
<select class="form-select" id="api_assign_user" name="api_assign_user">
<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 %}
</select>
</div>
</div>
</div>
</div></div>
<div class="card">
<div class="card"><div class="card-body">
<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>
<div class="fields">
<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="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>
<p class="hint" style="margin:-.4rem 0 1rem">Komma-gescheiden. Bestaande relaties houden hun type als je er een weghaalt.</p>
<div class="row g-3">
<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="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 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>
<div class="card">
<div class="card"><div class="card-body">
<h2>API-koppeling</h2>
<dl class="props">
<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.')">
<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>
</div>
</div></div>
{% endblock %}
+12 -12
View File
@@ -4,21 +4,21 @@
<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>
<form method="post" class="card">
<div class="fields">
<div><label for="name">Naam *</label><input 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><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>
<form method="post" class="card"><div class="card-body">
<div class="row g-3">
<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 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 class="col-md-6"><label class="form-label" for="role">Rol</label>
<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><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">
<div class="col-md-6"><label class="form-label" for="password">{{ 'Nieuw wachtwoord' if u.id else 'Wachtwoord *' }}</label>
<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>
<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 class="actions">
<button class="btn">Opslaan</button>
<a class="btn quiet" href="{{ url_for('users') }}">Annuleren</a>
<button class="btn btn-primary">Opslaan</button>
<a class="btn btn-outline-secondary" href="{{ url_for('users') }}">Annuleren</a>
</div>
</form>
</div></form>
{% endblock %}
+9 -9
View File
@@ -3,23 +3,23 @@
{% block content %}
<div class="head">
<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 class="card">
<div class="table-wrap"><table>
<div class="card"><div class="card-body">
<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>
<tbody>
{% for u in rows %}
<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>{{ u.email }}</td>
<td>{{ ROLE.get(u.role, u.role) }}</td>
<td class="num">{{ u.n }}</td>
<td>{% if not u.active %}<span class="chip verloren">Inactief</span>{% endif %}</td>
<td class="stack-title"><a href="{{ url_for('user_edit', uid=u.id) }}">{{ u.name }}</a></td>
<td data-label="E-mail">{{ u.email }}</td>
<td data-label="Rol">{{ ROLE.get(u.role, u.role) }}</td>
<td data-label="Contacten" class="num">{{ u.n }}</td>
<td data-label="Status">{% if not u.active %}<span class="chip verloren">Inactief</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</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>
{% 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"
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Een getagde versie van Tectum (de configurator) naar de server brengen (eerst eenmalig server/tectum-setup.sh).
#
# tools/tectum-release.sh crm@tectum.voorbeeld.nl nieuwste tag
# tools/tectum-release.sh crm@tectum.voorbeeld.nl v0.7.6 een bepaalde versie (ook om terug te rollen)
#
# De bestanden komen uit `git archive <tag>` van $CONFIGURATOR (standaard ../configurator), dus niet-gecommit
# werk gaat niet mee. rsync met hardlinks naar de vorige versie, zodat alleen wijzigingen over de lijn gaan;
# daarna wijst `current` naar de nieuwe versie. De laatste 5 versies blijven staan.
set -euo pipefail
REPO="$(cd "$(dirname "$0")/.." && pwd)"
HOST="${1:-}"
CONFIGURATOR="${CONFIGURATOR:-$REPO/../configurator}"
ROOT=/srv/www/tectum
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/tectum-release.sh crm@server [vX.Y.Z]"
[ -f "$CONFIGURATOR/index.html" ] || fail "geen configurator in $CONFIGURATOR (zet CONFIGURATOR=pad)"
TAG="${2:-$(git -C "$CONFIGURATOR" 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 "$CONFIGURATOR" rev-parse -q --verify "refs/tags/$TAG" >/dev/null || fail "tag $TAG bestaat niet"
[ -z "$(git -C "$CONFIGURATOR" status --porcelain)" ] || say "let op: niet-gecommitte wijzigingen in de configurator zitten niet in $TAG"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
chmod 755 "$tmp" # mktemp maakt hem 700, en rsync -a neemt dat mee (nginx moet erin kunnen)
git -C "$CONFIGURATOR" archive "$TAG" | tar -x -C "$tmp"
rm -f "$tmp/README.md" "$tmp/start.sh" "$tmp/.gitignore"
say "Tectum $TAG uploaden naar $HOST"
ssh "$HOST" "mkdir -p $ROOT/releases/$TAG"
rsync -az --delete --link-dest="$ROOT/current/" "$tmp/" "$HOST:$ROOT/releases/$TAG/"
say "$TAG actief maken"
ssh "$HOST" "set -e; cd $ROOT; ln -sfn releases/$TAG current.new; mv -T current.new current
ls -1 releases | sort -V | head -n -5 | grep -vxF '$TAG' | sed 's|^|releases/|' | xargs -r rm -rf"
say "klaar: Tectum $TAG staat live"