From bffa053c0504f6b10d38691a248bcd8893ba059a Mon Sep 17 00:00:00 2001
From: Machiel
Date: Wed, 14 Oct 2026 21:56:55 +0200
Subject: [PATCH] v0.0.1: eerste versie van de Van Emous CRM
Flask + SQLite, in de Van Emous-huisstijl (tokens uit het design system, GSAP-effecten
volgens de motion-regels, uit bij prefers-reduced-motion).
- Leads en contactpersonen: status, toewijzen, verwachte waarde, omzetten naar klant,
tijdlijn, bestanden, taken/opvolging
- Relaties (zoals accounts in EspoCRM): klantbundels (huishouden/familie) en partners met
type (leverancier, aannemer, ...); leveranciers van producten worden automatisch partners
- API voor formulieren (POST /api/leads) met dubbele-leaddetectie, bestanden en
configurator-ontwerpen (house-design JSON)
- Import van CSV/JSON (leads, relaties, producten) met kolomkoppeling; configurator-formaat
- Producten met SKU als vaste sleutel, mutatiehistorie, POST /api/products, CSV-export;
offerteregels houden hun eigen prijs
- Offertes met btw per tarief en afdrukversie (PDF via de browser)
- Inbox via IMAP/SMTP, gekoppeld aan contacten
- Gebruikers (beheerder/medewerker) en klantportaal (offertes accepteren, bestanden, berichten)
Co-Authored-By: Claude Opus 5.5
---
.gitignore | 3 +
README.md | 83 ++
app.py | 1758 +++++++++++++++++++++++++++++++++
importer.py | 130 +++
mail.py | 116 +++
requirements.txt | 1 +
schema.sql | 160 +++
static/fx.js | 62 ++
static/logo.svg | 9 +
static/style.css | 215 ++++
templates/account.html | 13 +
templates/auth.html | 33 +
templates/base.html | 54 +
templates/contact.html | 232 +++++
templates/contact_form.html | 56 ++
templates/contacts.html | 54 +
templates/dashboard.html | 103 ++
templates/email.html | 39 +
templates/import.html | 28 +
templates/import_preview.html | 59 ++
templates/inbox.html | 39 +
templates/portal.html | 74 ++
templates/product_form.html | 64 ++
templates/products.html | 44 +
templates/quote_edit.html | 125 +++
templates/quote_print.html | 129 +++
templates/quotes.html | 31 +
templates/relation.html | 133 +++
templates/relation_form.html | 39 +
templates/relations.html | 47 +
templates/settings.html | 129 +++
templates/user_form.html | 24 +
templates/users.html | 25 +
33 files changed, 4111 insertions(+)
create mode 100644 .gitignore
create mode 100644 README.md
create mode 100644 app.py
create mode 100644 importer.py
create mode 100644 mail.py
create mode 100644 requirements.txt
create mode 100644 schema.sql
create mode 100644 static/fx.js
create mode 100644 static/logo.svg
create mode 100644 static/style.css
create mode 100644 templates/account.html
create mode 100644 templates/auth.html
create mode 100644 templates/base.html
create mode 100644 templates/contact.html
create mode 100644 templates/contact_form.html
create mode 100644 templates/contacts.html
create mode 100644 templates/dashboard.html
create mode 100644 templates/email.html
create mode 100644 templates/import.html
create mode 100644 templates/import_preview.html
create mode 100644 templates/inbox.html
create mode 100644 templates/portal.html
create mode 100644 templates/product_form.html
create mode 100644 templates/products.html
create mode 100644 templates/quote_edit.html
create mode 100644 templates/quote_print.html
create mode 100644 templates/quotes.html
create mode 100644 templates/relation.html
create mode 100644 templates/relation_form.html
create mode 100644 templates/relations.html
create mode 100644 templates/settings.html
create mode 100644 templates/user_form.html
create mode 100644 templates/users.html
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..bb4730c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+data/
+.venv/
+__pycache__/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6f538e3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,83 @@
+# Van Emous CRM
+
+Eenvoudige CRM in Python (Flask) met SQLite, in de Van Emous-huisstijl. Versie 0.0.1.
+
+## Functies
+
+- **Leads en klanten** (zoals in EspoCRM): status (nieuw → in behandeling → offerte → gewonnen/verloren),
+ toewijzen aan een gebruiker, verwachte waarde, bron, omzetten van lead naar klant, tijdlijn met
+ notities, e-mail, statuswissels en formulierinzendingen, bestanden en afbeeldingen, CSV-export.
+- **Relaties** (zoals accounts in EspoCRM), in twee soorten: **klanten** (bundels zoals een huishouden,
+ 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.
+- **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
+ e-mail) komen bij het bestaande contact. Er is een honeypot (`_gotcha`) en een `redirect` naar een
+ bedankpagina. Een `house-design`-object uit de configurator wordt als ontwerp bij de lead opgeslagen.
+ Voorbeelden staan onder Instellingen.
+- **Import** van CSV en JSON voor leads/contactpersonen, relaties en producten, met een voorbeeld en kolomkoppeling.
+ Het formaat van de configurator (`;`, komma-decimalen, BOM, `#`-regels) werkt direct.
+- **Inbox**: IMAP ophalen en SMTP versturen, mails automatisch gekoppeld aan contacten (op e-mailadres),
+ contact aanmaken vanuit een mail, beantwoorden.
+- **Producten** met SKU als vaste sleutel, prijs, btw, eenheid, leverancier (partner) en afbeelding.
+ Een import of `POST /api/products` werkt producten bij op SKU (alleen de meegegeven velden) en legt
+ elke mutatie vast met de oude en de nieuwe waarde. Optioneel worden producten die niet meer in de lijst
+ staan inactief gezet. Offerteregels bewaren hun eigen prijs, dus bestaande offertes veranderen niet. Een
+ product dat in offertes gebruikt is, wordt bij verwijderen gearchiveerd. Export naar CSV in hetzelfde
+ 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.
+- **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
+ uploaden en berichten sturen. Een geaccepteerde offerte zet het contact op gewonnen/klant.
+- **Effecten** met GSAP (cdnjs, 3.12.5) volgens de motion-regels van de huisstijl, in `static/fx.js`.
+ Ze staan uit bij `prefers-reduced-motion`, en na 3 seconden is de inhoud altijd zichtbaar.
+
+## Starten
+
+```sh
+python3 -m venv .venv
+.venv/bin/pip install -r requirements.txt
+.venv/bin/python app.py # http://127.0.0.1:5000
+```
+
+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
+
+Draai de app achter nginx/Apache met HTTPS, bijvoorbeeld met gunicorn:
+
+```sh
+.venv/bin/pip install gunicorn
+.venv/bin/gunicorn -w 2 -b 127.0.0.1:8000 app:app
+```
+
+E-mail automatisch ophalen (cron, elke 10 minuten):
+
+```
+*/10 * * * * cd /pad/naar/crm && .venv/bin/python app.py sync
+```
+
+## Opslag
+
+Alles staat in `data/`: `crm.db` (SQLite), `uploads/` (bestanden), `secret.key` (sessies).
+Een back-up is een kopie van die map. Nieuwe kolommen worden bij het starten automatisch aan een
+bestaande database toegevoegd (`MIGRATIONS` in `app.py`). Houd `data/` buiten de webroot: er staan ook
+mailwachtwoorden in.
+
+`CRM_DATA=/ander/pad` verplaatst de datamap, `HOST` en `PORT` stellen de ontwikkelserver in.
+
+## Structuur
+
+```
+app.py routes en logica
+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
+```
diff --git a/app.py b/app.py
new file mode 100644
index 0000000..0402b3d
--- /dev/null
+++ b/app.py
@@ -0,0 +1,1758 @@
+"""Van Emous CRM — eenvoudige CRM op Flask + SQLite.
+
+Starten: python app.py
+E-mail ophalen: python app.py sync (bijv. elke 10 minuten via cron)
+"""
+__version__ = '0.0.1'
+
+import csv
+import io
+import json
+import mimetypes
+import os
+import re
+import secrets
+import sqlite3
+import sys
+import uuid
+from datetime import date, datetime, timedelta
+from urllib.parse import urlparse
+
+from flask import (Flask, abort, flash, g, jsonify, make_response, redirect,
+ render_template, request, send_from_directory, session, url_for)
+from werkzeug.security import check_password_hash, generate_password_hash
+from werkzeug.utils import secure_filename
+
+import importer
+import mail
+
+BASE = os.path.dirname(os.path.abspath(__file__))
+DATA = os.environ.get('CRM_DATA', os.path.join(BASE, 'data'))
+UPLOADS = os.path.join(DATA, 'uploads')
+IMPORTS = os.path.join(DATA, 'imports')
+DB_PATH = os.path.join(DATA, 'crm.db')
+for _dir in (UPLOADS, IMPORTS):
+ os.makedirs(_dir, exist_ok=True)
+
+TYPES = [('lead', 'Lead'), ('klant', 'Klant'), ('partner', 'Partnercontact')]
+RELATION_KINDS = [('klant', 'Klant'), ('partner', 'Partner')]
+DEFAULT_RELATION_TYPES = {'klant': 'Huishouden / familie, Particulier, Zakelijk, Zorginstelling',
+ 'partner': 'Leverancier, Aannemer, Installateur, Architect / adviseur, Gemeente, Overig'}
+RELATION_FIELDS = ['name', 'type', 'email', 'phone', 'website', 'address', 'postcode', 'city', 'kvk', 'notes']
+STATUSES = [('nieuw', 'Nieuw'), ('contact', 'In behandeling'), ('offerte', 'Offerte'),
+ ('gewonnen', 'Gewonnen'), ('verloren', 'Verloren')]
+QUOTE_STATUSES = [('concept', 'Concept'), ('verzonden', 'Verzonden'),
+ ('geaccepteerd', 'Geaccepteerd'), ('afgewezen', 'Afgewezen')]
+ROLES = [('admin', 'Beheerder'), ('user', 'Medewerker')]
+ACTIVITY_TYPES = {'note': 'Notitie', 'form': 'Formulier', 'email': 'E-mail', 'status': 'Status',
+ 'quote': 'Offerte', 'file': 'Bestand', 'task': 'Taak', 'portal': 'Portaal', 'import': 'Import'}
+CONTACT_FIELDS = ['name', 'email', 'phone', 'company', 'address', 'postcode', 'city', 'source', 'notes']
+PRODUCT_FIELDS = ['name', 'sku', 'description', 'unit', 'supplier', 'price', 'vat']
+PRODUCT_LABELS = {'name': 'Naam', 'sku': 'SKU', 'description': 'Omschrijving', 'unit': 'Eenheid',
+ 'supplier': 'Leverancier', 'price': 'Prijs', 'vat': 'Btw', 'active': 'Actief', 'created': 'Aangemaakt'}
+INLINE_MIME = {'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'application/pdf'}
+
+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',
+ 'imap_host', 'imap_port', 'imap_user', 'imap_folder', 'imap_days',
+ 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_from', 'allowed_origins', 'api_assign_user',
+ 'klant_types', 'partner_types']
+SECRET_KEYS = ['imap_pass', 'smtp_pass']
+
+FIELD_LOOKUP = importer.lookup(importer.FIELD_ALIASES)
+API_SKIP = {'api_key', 'redirect', '_gotcha', 'website_hp', 'g-recaptcha-response'}
+
+PUBLIC = {'static', 'login', 'setup', 'api_leads', 'api_products'}
+PORTAL_PUBLIC = {'portal_login', 'portal_invite'}
+ADMIN_ONLY = {'settings', 'users', 'user_edit'}
+
+
+def _secret_key():
+ path = os.path.join(DATA, 'secret.key')
+ if not os.path.exists(path):
+ with open(path, 'w') as f:
+ f.write(secrets.token_hex(32))
+ with open(path) as f:
+ return f.read().strip()
+
+
+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))
+
+
+def now():
+ return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+
+
+def num(value, default=0.0):
+ s = str(value if value is not None else '').strip().replace('€', '').replace(' ', '')
+ if ',' in s:
+ s = s.replace('.', '').replace(',', '.')
+ try:
+ return float(s)
+ except ValueError:
+ return default
+
+
+def back(default):
+ ref = request.referrer or ''
+ return ref if ref.startswith(request.host_url) else default
+
+
+# ---------------------------------------------------------------- database
+
+# Kolommen die later zijn bijgekomen; bestaande databases krijgen ze bij het starten.
+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 ''"},
+}
+
+
+def init_db():
+ con = sqlite3.connect(DB_PATH)
+ with open(os.path.join(BASE, 'schema.sql')) as f:
+ con.executescript(f.read())
+ for table, cols in MIGRATIONS.items():
+ have = {r[1] for r in con.execute(f'PRAGMA table_info({table})')}
+ for col, ddl in cols.items():
+ if col not in have:
+ con.execute(f'ALTER TABLE {table} ADD COLUMN {col} {ddl}')
+ try:
+ con.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_products_sku ON products(sku COLLATE NOCASE) WHERE sku <> ''")
+ except sqlite3.IntegrityError:
+ print('Let op: dubbele SKU\'s in de producten; maak ze uniek zodat import op SKU betrouwbaar is.')
+ # leveranciers die alleen als tekst bij producten staan, worden partners
+ for (name,) in con.execute("SELECT DISTINCT supplier FROM products WHERE supplier<>'' AND supplier_id IS NULL").fetchall():
+ row = con.execute("SELECT id FROM relations WHERE kind='partner' AND name=? COLLATE NOCASE", (name,)).fetchone()
+ rid = row[0] if row else con.execute(
+ "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))
+ con.execute('PRAGMA journal_mode=WAL')
+ con.commit()
+ con.close()
+
+
+init_db()
+
+
+def db():
+ if 'db' not in g:
+ g.db = sqlite3.connect(DB_PATH, timeout=10)
+ g.db.row_factory = sqlite3.Row
+ g.db.execute('PRAGMA foreign_keys = ON')
+ return g.db
+
+
+@app.teardown_appcontext
+def close_db(exc):
+ con = g.pop('db', None)
+ if con:
+ con.close()
+
+
+def q(sql, *args):
+ return db().execute(sql, args).fetchall()
+
+
+def q1(sql, *args):
+ return db().execute(sql, args).fetchone()
+
+
+def scalar(sql, *args):
+ return db().execute(sql, args).fetchone()[0]
+
+
+def get_or_404(table, row_id):
+ row = q1(f'SELECT * FROM {table} WHERE id=?', row_id)
+ if row is None:
+ abort(404)
+ return row
+
+
+def get_setting(key, default=''):
+ row = q1('SELECT value FROM settings WHERE key=?', key)
+ return row['value'] if row and row['value'] is not None else default
+
+
+def set_setting(key, value):
+ db().execute('INSERT INTO settings (key, value) VALUES (?, ?) '
+ 'ON CONFLICT(key) DO UPDATE SET value=excluded.value', (key, value))
+
+
+def settings_dict():
+ return {r['key']: r['value'] or '' for r in q('SELECT key, value FROM settings')}
+
+
+def current_user_id():
+ user = g.get('user')
+ return user['id'] if user else None
+
+
+def log(contact_id, type_, body):
+ db().execute('INSERT INTO activities (contact_id, type, body, user_id, created_at) VALUES (?, ?, ?, ?, ?)',
+ (contact_id, type_, body, current_user_id(), now()))
+
+
+def link_emails(contact_id, address):
+ if address:
+ db().execute('UPDATE emails SET contact_id=? WHERE contact_id IS NULL AND from_addr=?',
+ (contact_id, address.strip().lower()))
+
+
+def status_key(value):
+ """Accepteer een statussleutel of -label (voor import en API)."""
+ value = str(value or '').strip().lower()
+ for key, label in STATUSES:
+ if value in (key, label.lower()):
+ return key
+ return None
+
+
+def insert_contact(data, extra=None, status='nieuw', type_='lead', assigned=None, value=0.0):
+ ts = now()
+ values = [str(data.get(f) or '').strip() for f in CONTACT_FIELDS]
+ cur = db().execute(
+ f'INSERT INTO contacts ({", ".join(CONTACT_FIELDS)}, status, type, assigned_user_id, value, extra, '
+ f'created_at, updated_at) VALUES ({", ".join("?" * len(CONTACT_FIELDS))}, ?, ?, ?, ?, ?, ?, ?)',
+ [*values, status, type_, assigned, value, json.dumps(extra or {}, ensure_ascii=False), ts, ts])
+ link_emails(cur.lastrowid, data.get('email'))
+ return cur.lastrowid
+
+
+def relation_types(kind):
+ return [t.strip() for t in get_setting(f'{kind}_types', DEFAULT_RELATION_TYPES[kind]).split(',') if t.strip()]
+
+
+def find_or_create_relation(name, kind, type_=None):
+ name = str(name or '').strip()
+ if not name or kind not in dict(RELATION_KINDS):
+ return None
+ row = q1('SELECT id FROM relations WHERE kind=? AND name=? COLLATE NOCASE', kind, name)
+ if row:
+ return row['id']
+ if type_ is None:
+ types = relation_types(kind)
+ type_ = next((t for t in types if t.lower().startswith('leverancier')), '') if kind == 'partner' else (
+ types[0] if types else '')
+ ts = now()
+ return db().execute('INSERT INTO relations (kind, type, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
+ (kind, type_, name, ts, ts)).lastrowid
+
+
+def link_relation(contact_id, relation_id, role=None):
+ """Koppel een contactpersoon aan een relatie; bij een partner wordt het een partnercontact."""
+ rel = q1('SELECT * FROM relations WHERE id=?', relation_id) if relation_id else None
+ old = q1('SELECT relation_id, type FROM contacts WHERE id=?', contact_id)
+ db().execute('UPDATE contacts SET relation_id=?, relation_role=COALESCE(?, relation_role), updated_at=? WHERE id=?',
+ (rel['id'] if rel else None, role, now(), contact_id))
+ if rel and rel['kind'] == 'partner':
+ db().execute("UPDATE contacts SET type='partner' WHERE id=?", (contact_id,))
+ elif old and old['type'] == 'partner':
+ db().execute("UPDATE contacts SET type=? WHERE id=?", ('klant' if rel else 'lead', contact_id))
+ if old and (rel['id'] if rel else None) != old['relation_id']:
+ log(contact_id, 'status', f"Gekoppeld aan relatie {rel['name']}" if rel else 'Losgekoppeld van relatie')
+
+
+def upsert_contact(fields, extra=None, update_existing=True, defaults=None):
+ """Maak een lead aan of vul een bestaande (zelfde e-mailadres) aan.
+
+ Bestaande gegevens worden nooit overschreven, alleen lege velden aangevuld.
+ Geeft (contact_id, 'created' | 'updated' | 'skipped') terug.
+ """
+ fields = {k: str(v).strip() for k, v in fields.items() if v is not None and str(v).strip()}
+ extra = extra or {}
+ defaults = defaults or {}
+ first, last = fields.pop('first_name', ''), fields.pop('last_name', '')
+ fields.setdefault('name', f'{first} {last}'.strip())
+ if not (fields['name'] or fields.get('email') or fields.get('phone')):
+ return None, 'skipped'
+ fields['name'] = fields['name'] or fields.get('email') or fields.get('phone')
+ status = status_key(fields.pop('status', '')) or defaults.get('status', 'nieuw')
+ value = num(fields.pop('value', 0))
+ relation_name, role = fields.pop('relation', ''), fields.pop('relation_role', None)
+ fields.setdefault('source', defaults.get('source', ''))
+ cid, outcome = _upsert_contact(fields, extra, update_existing, defaults, status, value)
+ if cid and outcome != 'skipped' and (relation_name or role):
+ current = q1('SELECT relation_id, type FROM contacts WHERE id=?', cid)
+ if relation_name and not current['relation_id']:
+ kind = 'partner' if current['type'] == 'partner' else 'klant'
+ link_relation(cid, find_or_create_relation(relation_name, kind), role)
+ elif role:
+ db().execute('UPDATE contacts SET relation_role=? WHERE id=?', (role, cid))
+ return cid, outcome
+
+
+def _upsert_contact(fields, extra, update_existing, defaults, status, value):
+ existing = q1('SELECT * FROM contacts WHERE email=? COLLATE NOCASE', fields['email']) if fields.get('email') else None
+ if not existing:
+ cid = insert_contact(fields, extra, status=status, type_=defaults.get('type', 'lead'),
+ assigned=defaults.get('assigned'), value=value)
+ return cid, 'created'
+ if not update_existing:
+ return existing['id'], 'skipped'
+ updates = {k: v for k, v in fields.items() if k in CONTACT_FIELDS and v and not existing[k]}
+ if value and not existing['value']:
+ updates['value'] = value
+ merged = {**json.loads(existing['extra'] or '{}'), **extra}
+ sets = ''.join(f'{k}=?, ' for k in updates)
+ db().execute(f'UPDATE contacts SET {sets}extra=?, updated_at=? WHERE id=?',
+ [*updates.values(), json.dumps(merged, ensure_ascii=False), now(), existing['id']])
+ return existing['id'], 'updated'
+
+
+def set_contact_status(contact_id, status):
+ labels = dict(STATUSES)
+ row = q1('SELECT status FROM contacts WHERE id=?', contact_id)
+ if row and status in labels and status != row['status']:
+ db().execute('UPDATE contacts SET status=?, updated_at=? WHERE id=?', (status, now(), contact_id))
+ log(contact_id, 'status', f"Status: {labels.get(row['status'], row['status'])} → {labels[status]}")
+
+
+def convert_contact(contact_id, relation_id=None, new_relation=''):
+ """Lead → klant (zoals 'Convert' in EspoCRM): de contactpersoon komt in een klantrelatie.
+ Zonder keuze wordt er een relatie aangemaakt met de bedrijfs- of persoonsnaam."""
+ row = q1('SELECT * FROM contacts WHERE id=?', contact_id)
+ if not row:
+ return
+ if row['type'] != 'klant':
+ db().execute("UPDATE contacts SET type='klant', updated_at=? WHERE id=?", (now(), contact_id))
+ log(contact_id, 'status', 'Lead omgezet naar klant')
+ if relation_id or new_relation or not row['relation_id']:
+ rid = relation_id or find_or_create_relation(new_relation or row['company'] or row['name'], 'klant')
+ if rid != row['relation_id']:
+ link_relation(contact_id, rid)
+
+
+def save_upload(fs, contact_id=None):
+ if not fs or not fs.filename:
+ return None
+ ext = os.path.splitext(secure_filename(fs.filename))[1].lower()
+ stored = uuid.uuid4().hex + ext
+ path = os.path.join(UPLOADS, stored)
+ fs.save(path)
+ if contact_id:
+ db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) '
+ 'VALUES (?, ?, ?, ?, ?, ?)',
+ (contact_id, stored, fs.filename[:200], fs.mimetype or '', os.path.getsize(path), now()))
+ if ext == '.json':
+ note_design(contact_id, path)
+ return stored
+
+
+def note_design(contact_id, path):
+ """Een woningontwerp uit de configurator krijgt een regel in de tijdlijn."""
+ try:
+ with open(path, encoding='utf-8-sig') as f:
+ design = json.load(f)
+ except (ValueError, OSError):
+ return
+ if isinstance(design, dict) and design.get('type') == 'house-design':
+ project = design.get('project') or {}
+ parts = [f"Woningontwerp: {project.get('naam') or 'zonder naam'}",
+ ' '.join(filter(None, [project.get('straat'), project.get('nummer'), project.get('plaats')])),
+ project.get('opmerking') or '']
+ log(contact_id, 'file', '\n'.join(p for p in parts if p))
+
+
+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 (?, ?, ?, ?, ?, ?)',
+ (contact_id, stored, f'{name}.json', 'application/json', os.path.getsize(path), now()))
+ note_design(contact_id, path)
+
+
+def remove_upload(stored):
+ if stored:
+ try:
+ os.remove(os.path.join(UPLOADS, os.path.basename(stored)))
+ except FileNotFoundError:
+ pass
+
+
+def serve_upload(name, original=None):
+ mime = mimetypes.guess_type(name)[0] or 'application/octet-stream'
+ return send_from_directory(UPLOADS, name, mimetype=mime, as_attachment=mime not in INLINE_MIME,
+ download_name=original or name)
+
+
+def quotes_with_totals(where='', *args):
+ return q(f'''SELECT qu.*, c.name AS contact_name,
+ COALESCE((SELECT SUM(qty*price) FROM quote_lines WHERE quote_id=qu.id), 0) AS subtotal,
+ COALESCE((SELECT SUM(qty*price*(1+vat/100.0)) FROM quote_lines WHERE quote_id=qu.id), 0) AS total
+ FROM quotes qu LEFT JOIN contacts c ON c.id=qu.contact_id {where}
+ ORDER BY qu.date DESC, qu.id DESC''', *args)
+
+
+def quote_totals(lines):
+ subtotal, vat = 0.0, {}
+ for line in lines:
+ amount = line['qty'] * line['price']
+ subtotal += amount
+ vat[line['vat']] = vat.get(line['vat'], 0) + amount * line['vat'] / 100
+ return {'subtotal': subtotal, 'vat': sorted(vat.items()), 'total': subtotal + sum(vat.values())}
+
+
+def change_quote_status(qt, status):
+ """Statuswissel van een offerte, met de gevolgen voor het contact."""
+ if status == qt['status']:
+ return
+ db().execute('UPDATE quotes SET status=?, updated_at=? WHERE id=?', (status, now(), qt['id']))
+ cid = qt['contact_id']
+ if not cid:
+ return
+ log(cid, 'quote', f"Offerte {qt['number']}: {dict(QUOTE_STATUSES)[status]}")
+ contact = q1('SELECT status FROM contacts WHERE id=?', cid)
+ if status == 'verzonden' and contact and contact['status'] in ('nieuw', 'contact'):
+ set_contact_status(cid, 'offerte')
+ elif status == 'geaccepteerd':
+ set_contact_status(cid, 'gewonnen')
+ convert_contact(cid)
+
+
+# ---------------------------------------------------------------- templates
+
+@app.template_filter('euro')
+def euro(value):
+ s = f'{float(value or 0):,.2f}'
+ return '€ ' + s.replace(',', 'X').replace('.', ',').replace('X', '.')
+
+
+@app.template_filter('dt')
+def fmt_dt(value, fmt='%d-%m-%Y %H:%M'):
+ if not value:
+ return ''
+ try:
+ return datetime.fromisoformat(value).strftime(fmt)
+ except ValueError:
+ return value
+
+
+@app.template_filter('nr')
+def fmt_number(value):
+ return f'{value:g}'.replace('.', ',')
+
+
+@app.context_processor
+def inject():
+ ctx = dict(RELATION_KINDS=RELATION_KINDS, RKIND=dict(RELATION_KINDS), STATUSES=STATUSES, STATUS=dict(STATUSES), QUOTE_STATUSES=QUOTE_STATUSES, TYPES=TYPES,
+ TYPE=dict(TYPES), QSTATUS=dict(QUOTE_STATUSES), ROLES=ROLES, ROLE=dict(ROLES),
+ ACT=ACTIVITY_TYPES, LABELS=PRODUCT_LABELS, unread=0, USERS=[], today=date.today().isoformat())
+ if g.get('user'):
+ ctx['unread'] = scalar("SELECT COUNT(*) FROM emails WHERE direction='in' AND is_read=0")
+ ctx['USERS'] = q('SELECT id, name FROM users WHERE active=1 ORDER BY name')
+ return ctx
+
+
+# ---------------------------------------------------------------- toegang
+
+@app.before_request
+def guard():
+ ep = request.endpoint or ''
+ if ep in PUBLIC:
+ return None
+ if ep.startswith('portal'):
+ cid = session.get('portal_cid')
+ g.portal = q1('SELECT * FROM contacts WHERE id=? AND portal_password_hash IS NOT NULL', cid) if cid else None
+ if ep not in PORTAL_PUBLIC and not g.portal:
+ return redirect(url_for('portal_login'))
+ return None
+ if not scalar('SELECT COUNT(*) FROM users'):
+ return redirect(url_for('setup'))
+ uid = session.get('uid')
+ g.user = q1('SELECT * FROM users WHERE id=? AND active=1', uid) if uid else None
+ if not g.user:
+ return redirect(url_for('login', next=request.full_path))
+ if ep in ADMIN_ONLY and g.user['role'] != 'admin':
+ flash('Alleen beheerders hebben toegang tot die pagina.', 'error')
+ return redirect(url_for('dashboard'))
+ return None
+
+
+@app.route('/setup', methods=['GET', 'POST'])
+def setup():
+ if scalar('SELECT COUNT(*) FROM users'):
+ return redirect(url_for('login'))
+ if request.method == 'POST':
+ f = request.form
+ pw = f.get('password', '')
+ if not f.get('name', '').strip() or not f.get('email', '').strip():
+ flash('Vul je naam en e-mailadres in.', 'error')
+ elif len(pw) < 8 or pw != f.get('password2'):
+ flash('Het wachtwoord moet minimaal 8 tekens hebben en twee keer hetzelfde zijn.', 'error')
+ else:
+ cur = db().execute("INSERT INTO users (name, email, password_hash, role, created_at) "
+ "VALUES (?, ?, ?, 'admin', ?)",
+ (f['name'].strip(), f['email'].strip().lower(), generate_password_hash(pw), now()))
+ set_setting('api_key', secrets.token_urlsafe(24))
+ for key, value in [('quote_prefix', 'OFF'), ('quote_valid_days', '30'), ('imap_port', '993'),
+ ('imap_folder', 'INBOX'), ('imap_days', '30'), ('smtp_port', '587'),
+ ('company_name', 'Van Emous Mantelzorgwoningen')]:
+ set_setting(key, value)
+ db().commit()
+ session.clear()
+ session.update(uid=cur.lastrowid)
+ session.permanent = True
+ flash('Account aangemaakt. Vul hieronder je bedrijfsgegevens en e-mail in.')
+ return redirect(url_for('settings'))
+ return render_template('auth.html', mode='setup')
+
+
+@app.route('/login', methods=['GET', 'POST'])
+def login():
+ if request.method == 'POST':
+ user = q1('SELECT * FROM users WHERE email=? AND active=1', request.form.get('email', '').strip())
+ if user and check_password_hash(user['password_hash'], request.form.get('password', '')):
+ session.pop('portal_cid', None)
+ session['uid'] = user['id']
+ session.permanent = True
+ nxt = request.args.get('next', '')
+ return redirect(nxt if nxt.startswith('/') and not nxt.startswith('//') else url_for('dashboard'))
+ flash('Onjuist e-mailadres of wachtwoord.', 'error')
+ return render_template('auth.html', mode='login')
+
+
+@app.route('/logout')
+def logout():
+ session.pop('uid', None)
+ return redirect(url_for('login'))
+
+
+@app.route('/account', methods=['GET', 'POST'])
+def account():
+ if request.method == 'POST':
+ f = request.form
+ if not check_password_hash(g.user['password_hash'], f.get('old_password', '')):
+ flash('Huidig wachtwoord klopt niet.', 'error')
+ elif len(f.get('new_password', '')) < 8:
+ flash('Het nieuwe wachtwoord moet minimaal 8 tekens hebben.', 'error')
+ else:
+ db().execute('UPDATE users SET password_hash=? WHERE id=?',
+ (generate_password_hash(f['new_password']), g.user['id']))
+ db().commit()
+ flash('Wachtwoord gewijzigd.')
+ return redirect(url_for('account'))
+ return render_template('account.html')
+
+
+@app.route('/users')
+def users():
+ return render_template('users.html', rows=q(
+ 'SELECT u.*, (SELECT COUNT(*) FROM contacts WHERE assigned_user_id=u.id) AS n '
+ 'FROM users u ORDER BY active DESC, name'))
+
+
+@app.route('/users/new', methods=['GET', 'POST'], defaults={'uid': None})
+@app.route('/users/', methods=['GET', 'POST'])
+def user_edit(uid):
+ u = get_or_404('users', uid) if uid else None
+ if request.method == 'POST':
+ f = request.form
+ data = {'name': f.get('name', '').strip(), 'email': f.get('email', '').strip().lower(),
+ 'role': f.get('role') if f.get('role') in dict(ROLES) else 'user',
+ 'active': 1 if f.get('active') else 0}
+ pw = f.get('password', '')
+ if u and u['id'] == g.user['id']:
+ data.update(role='admin', active=1) # jezelf niet buitensluiten
+ error = None
+ if not data['name'] or not data['email']:
+ error = 'Vul naam en e-mailadres in.'
+ elif (pw or not u) and len(pw) < 8:
+ error = 'Het wachtwoord moet minimaal 8 tekens hebben.'
+ elif q1('SELECT id FROM users WHERE email=? AND id<>?', data['email'], uid or 0):
+ error = 'Er bestaat al een gebruiker met dit e-mailadres.'
+ if error:
+ flash(error, 'error')
+ return render_template('user_form.html', u={**(dict(u) if u else {}), **data})
+ if pw:
+ data['password_hash'] = generate_password_hash(pw)
+ if u:
+ db().execute(f'UPDATE users SET {", ".join(f"{k}=?" for k in data)} WHERE id=?', [*data.values(), uid])
+ else:
+ data['created_at'] = now()
+ db().execute(f'INSERT INTO users ({", ".join(data)}) VALUES ({", ".join("?" * len(data))})',
+ list(data.values()))
+ db().commit()
+ flash('Gebruiker opgeslagen.')
+ return redirect(url_for('users'))
+ return render_template('user_form.html', u=u or {'role': 'user', 'active': 1})
+
+
+# ---------------------------------------------------------------- dashboard
+
+@app.route('/')
+def dashboard():
+ since = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')
+ line_sum = 'SELECT COALESCE(SUM(l.qty*l.price), 0) FROM quote_lines l JOIN quotes qu ON qu.id=l.quote_id'
+ open_leads = "type='lead' AND status NOT IN ('gewonnen', 'verloren')"
+ stats = {
+ 'new30': scalar("SELECT COUNT(*) FROM contacts WHERE type='lead' AND created_at >= ?", since),
+ 'open_leads': scalar(f'SELECT COUNT(*) FROM contacts WHERE {open_leads}'),
+ 'pipeline_value': scalar(f'SELECT COALESCE(SUM(value), 0) FROM contacts WHERE {open_leads}'),
+ 'unassigned': scalar(f'SELECT COUNT(*) FROM contacts WHERE {open_leads} AND assigned_user_id IS NULL'),
+ 'open_quotes': scalar("SELECT COUNT(*) FROM quotes WHERE status IN ('concept', 'verzonden')"),
+ 'open_value': scalar(line_sum + " WHERE qu.status IN ('concept', 'verzonden')"),
+ 'won_value': scalar(line_sum + " WHERE qu.status='geaccepteerd' AND qu.updated_at >= ?", since),
+ 'customers': scalar("SELECT COUNT(*) FROM relations WHERE kind='klant'"),
+ 'partners': scalar("SELECT COUNT(*) FROM relations WHERE kind='partner'"),
+ }
+
+ counts = {r[0]: r[1] for r in q("SELECT status, COUNT(*) FROM contacts WHERE type='lead' GROUP BY status")}
+ pipeline = [(key, label, counts.get(key, 0)) for key, label in STATUSES]
+
+ monday = date.today() - timedelta(days=date.today().weekday())
+ per_week = {monday - timedelta(weeks=i): 0 for i in range(11, -1, -1)}
+ for r in q('SELECT created_at FROM contacts WHERE created_at >= ?', min(per_week).isoformat()):
+ d = datetime.fromisoformat(r[0]).date()
+ week = d - timedelta(days=d.weekday())
+ if week in per_week:
+ per_week[week] += 1
+ weekly = [(w.isocalendar()[1], n) for w, n in per_week.items()]
+
+ sources = q("SELECT COALESCE(NULLIF(source, ''), 'Onbekend') AS s, COUNT(*) AS n "
+ "FROM contacts GROUP BY s ORDER BY n DESC LIMIT 6")
+ tasks = q('SELECT t.*, c.name AS contact_name FROM tasks t LEFT JOIN contacts c ON c.id=t.contact_id '
+ 'WHERE t.done=0 AND t.user_id=? ORDER BY t.due_date IS NULL, t.due_date LIMIT 10', g.user['id'])
+ return render_template(
+ 'dashboard.html', stats=stats, pipeline=pipeline, weekly=weekly, sources=sources, tasks=tasks,
+ pipe_max=max([n for _, _, n in pipeline] + [1]), week_max=max([n for _, n in weekly] + [1]),
+ source_max=max([r['n'] for r in sources] + [1]),
+ recent=q("SELECT c.*, u.name AS user_name FROM contacts c LEFT JOIN users u ON u.id=c.assigned_user_id "
+ "WHERE c.type='lead' ORDER BY c.created_at DESC LIMIT 8"),
+ mails=q("SELECT * FROM emails WHERE direction='in' AND is_read=0 ORDER BY date DESC LIMIT 5"))
+
+
+# ---------------------------------------------------------------- contacten
+
+@app.route('/contacts')
+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')
+ params = []
+ if term:
+ sql += (' AND (c.name LIKE ? OR c.email LIKE ? OR c.company LIKE ? OR c.phone LIKE ? OR c.city LIKE ?'
+ ' OR r.name LIKE ?)')
+ params += [f'%{term}%'] * 6
+ if args['type'] in dict(TYPES):
+ sql += ' AND c.type=?'
+ params.append(args['type'])
+ if args['status']:
+ sql += ' AND c.status=?'
+ params.append(args['status'])
+ if args['owner'] == 'me':
+ sql += ' AND c.assigned_user_id=?'
+ params.append(g.user['id'])
+ elif args['owner'] == 'none':
+ sql += ' AND c.assigned_user_id IS NULL'
+ rows = db().execute(sql + ' ORDER BY c.created_at DESC LIMIT 500', params).fetchall()
+ return render_template('contacts.html', rows=rows, term=term, a=args)
+
+
+@app.route('/contacts/export.csv')
+def contacts_export():
+ out = io.StringIO()
+ writer = csv.writer(out, delimiter=';')
+ cols = ['id', 'type', *CONTACT_FIELDS, 'status', 'value', 'created_at']
+ writer.writerow(cols)
+ for r in q(f'SELECT {", ".join(cols)} FROM contacts ORDER BY id'):
+ writer.writerow(list(r))
+ resp = make_response('' + out.getvalue())
+ resp.headers['Content-Type'] = 'text/csv; charset=utf-8'
+ resp.headers['Content-Disposition'] = f'attachment; filename=contacten-{date.today()}.csv'
+ return resp
+
+
+def contact_form_values():
+ f = request.form
+ data = {k: f.get(k, '').strip() for k in CONTACT_FIELDS}
+ meta = {'type': f.get('type') if f.get('type') in dict(TYPES) else 'lead',
+ 'status': f.get('status') if f.get('status') in dict(STATUSES) else 'nieuw',
+ 'assigned': f.get('assigned_user_id', type=int), 'value': num(f.get('value')),
+ 'relation': f.get('relation_id', type=int), 'role': f.get('relation_role', '').strip()}
+ return data, meta
+
+
+@app.route('/contacts/new', methods=['GET', 'POST'])
+def contact_new():
+ if request.method == 'POST':
+ data, meta = contact_form_values()
+ if not data['name']:
+ flash('Vul een naam in.', 'error')
+ return render_template('contact_form.html', c=request.form, new=True, relations=all_relations())
+ cid = insert_contact(data, status=meta['status'], type_=meta['type'], assigned=meta['assigned'],
+ value=meta['value'])
+ log(cid, 'note', 'Contact handmatig aangemaakt')
+ if meta['relation']:
+ link_relation(cid, meta['relation'], meta['role'])
+ db().commit()
+ return redirect(url_for('contact_view', cid=cid))
+ c = {'status': 'nieuw', 'type': 'lead', 'assigned_user_id': g.user['id'], **request.args.to_dict()}
+ if c.get('relation_id'):
+ rel = q1('SELECT kind FROM relations WHERE id=?', c['relation_id'])
+ c['type'] = 'partner' if rel and rel['kind'] == 'partner' else 'klant'
+ return render_template('contact_form.html', c=c, new=True, relations=all_relations())
+
+
+def all_relations():
+ return q('SELECT id, kind, type, name FROM relations ORDER BY kind, name')
+
+
+@app.route('/contacts/')
+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)
+ 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 '
+ 'WHERE a.contact_id=? ORDER BY a.created_at DESC, a.id DESC', cid),
+ files=q('SELECT * FROM files WHERE contact_id=? ORDER BY created_at DESC', 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),
+ 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"),
+ housemates=q('SELECT id, name, relation_role FROM contacts WHERE relation_id=? AND id<>? ORDER BY name',
+ c['relation_id'], cid) if c['relation_id'] else [])
+
+
+@app.route('/contacts//edit', methods=['GET', 'POST'])
+def contact_edit(cid):
+ c = get_or_404('contacts', cid)
+ if request.method == 'POST':
+ data, meta = contact_form_values()
+ if not data['name']:
+ flash('Vul een naam in.', 'error')
+ return render_template('contact_form.html', c={**request.form, 'id': cid}, new=False,
+ relations=all_relations())
+ sets = ', '.join(f'{k}=?' for k in CONTACT_FIELDS)
+ db().execute(f'UPDATE contacts SET {sets}, assigned_user_id=?, value=?, updated_at=? WHERE id=?',
+ [*data.values(), meta['assigned'], meta['value'], now(), cid])
+ if meta['assigned'] != c['assigned_user_id']:
+ user = q1('SELECT name FROM users WHERE id=?', meta['assigned']) if meta['assigned'] else None
+ log(cid, 'status', f"Toegewezen aan {user['name'] if user else 'niemand'}")
+ set_contact_status(cid, meta['status'])
+ if meta['type'] != c['type']:
+ db().execute('UPDATE contacts SET type=? WHERE id=?', (meta['type'], cid))
+ log(cid, 'status', f"Soort: {dict(TYPES).get(c['type'])} → {dict(TYPES)[meta['type']]}")
+ if meta['relation'] != c['relation_id'] or meta['role'] != (c['relation_role'] or ''):
+ link_relation(cid, meta['relation'], meta['role'])
+ link_emails(cid, data['email'])
+ db().commit()
+ flash('Contact opgeslagen.')
+ return redirect(url_for('contact_view', cid=cid))
+ return render_template('contact_form.html', c=c, new=False, relations=all_relations())
+
+
+@app.post('/contacts//status')
+def contact_status(cid):
+ get_or_404('contacts', cid)
+ set_contact_status(cid, request.form.get('status'))
+ db().commit()
+ return redirect(url_for('contact_view', cid=cid))
+
+
+@app.post('/contacts//convert')
+def contact_convert(cid):
+ get_or_404('contacts', cid)
+ convert_contact(cid, request.form.get('relation_id', type=int), request.form.get('new_relation', '').strip())
+ db().commit()
+ flash('Lead is omgezet naar klant.')
+ return redirect(url_for('contact_view', cid=cid))
+
+
+@app.post('/contacts//note')
+def contact_note(cid):
+ get_or_404('contacts', cid)
+ body = request.form.get('body', '').strip()
+ if body:
+ log(cid, 'note', body)
+ db().commit()
+ return redirect(url_for('contact_view', cid=cid))
+
+
+@app.post('/contacts//upload')
+def contact_upload(cid):
+ get_or_404('contacts', cid)
+ count = sum(1 for fs in request.files.getlist('files') if save_upload(fs, cid))
+ if count:
+ log(cid, 'file', f'{count} bestand(en) toegevoegd')
+ db().commit()
+ return redirect(url_for('contact_view', cid=cid))
+
+
+@app.post('/contacts//task')
+def contact_task(cid):
+ get_or_404('contacts', cid)
+ title = request.form.get('title', '').strip()
+ if title:
+ user_id = request.form.get('user_id', type=int) or g.user['id']
+ due = request.form.get('due_date') or None
+ db().execute('INSERT INTO tasks (contact_id, user_id, title, due_date, created_at) VALUES (?, ?, ?, ?, ?)',
+ (cid, user_id, title, due, now()))
+ log(cid, 'task', f"Taak: {title}" + (f" (voor {fmt_dt(due, '%d-%m-%Y')})" if due else ''))
+ db().commit()
+ return redirect(url_for('contact_view', cid=cid))
+
+
+@app.post('/tasks//done')
+def task_done(tid):
+ t = get_or_404('tasks', tid)
+ db().execute('UPDATE tasks SET done=? WHERE id=?', (0 if t['done'] else 1, tid))
+ if not t['done'] and t['contact_id']:
+ log(t['contact_id'], 'task', f"Taak afgerond: {t['title']}")
+ db().commit()
+ return redirect(back(url_for('dashboard')))
+
+
+@app.post('/contacts//portal')
+def contact_portal(cid):
+ c = get_or_404('contacts', cid)
+ action = request.form.get('action')
+ if action == 'invite':
+ db().execute('UPDATE contacts SET portal_token=? WHERE id=?', (secrets.token_urlsafe(24), cid))
+ log(cid, 'portal', 'Uitnodiging voor het klantportaal aangemaakt')
+ flash('Uitnodigingslink aangemaakt. Kopieer de link of verstuur hem per e-mail.')
+ elif action == 'mail' and c['portal_token'] and c['email']:
+ link = url_for('portal_invite', token=c['portal_token'], _external=True)
+ company = get_setting('company_name', 'ons')
+ body = (f"Beste {c['name']},\n\nVia ons klantportaal kunt u uw offertes bekijken en goedkeuren, en "
+ f"bestanden met ons delen. Stel via deze link uw wachtwoord in:\n\n{link}\n\n"
+ f"Met vriendelijke groet,\n{company}")
+ try:
+ mail.send(settings_dict(), c['email'], f'Uw toegang tot het klantportaal van {company}', body)
+ log(cid, 'portal', 'Uitnodiging voor het klantportaal gemaild')
+ flash('Uitnodiging verstuurd.')
+ except Exception as e:
+ flash(f'Versturen mislukt: {e}', 'error')
+ elif action == 'revoke':
+ db().execute('UPDATE contacts SET portal_token=NULL, portal_password_hash=NULL WHERE id=?', (cid,))
+ log(cid, 'portal', 'Toegang tot het klantportaal ingetrokken')
+ flash('Portaaltoegang ingetrokken.')
+ db().commit()
+ return redirect(url_for('contact_view', cid=cid))
+
+
+@app.post('/contacts//delete')
+def contact_delete(cid):
+ for f in q('SELECT stored FROM files WHERE contact_id=?', cid):
+ remove_upload(f['stored'])
+ db().execute('DELETE FROM contacts WHERE id=?', (cid,))
+ db().commit()
+ flash('Contact verwijderd.')
+ return redirect(url_for('contacts'))
+
+
+@app.route('/uploads/')
+def upload_file(name):
+ f = q1('SELECT original FROM files WHERE stored=?', name)
+ return serve_upload(name, f['original'] if f else None)
+
+
+@app.post('/files//delete')
+def file_delete(fid):
+ f = get_or_404('files', fid)
+ remove_upload(f['stored'])
+ db().execute('DELETE FROM files WHERE id=?', (fid,))
+ db().commit()
+ return redirect(url_for('contact_view', cid=f['contact_id']))
+
+
+# ---------------------------------------------------------------- relaties
+
+def relation_form_data():
+ data = {k: request.form.get(k, '').strip() for k in RELATION_FIELDS}
+ kind = request.form.get('kind') if request.form.get('kind') in dict(RELATION_KINDS) else 'klant'
+ return data, kind
+
+
+def upsert_relation(raw, kind, update_existing=True):
+ """Voor de import: zelfde soort + naam (of KvK-nummer) = dezelfde relatie; alleen lege velden aanvullen."""
+ data = {k: str(raw.get(k) or '').strip() for k in RELATION_FIELDS if str(raw.get(k) or '').strip()}
+ kind = {'klant': 'klant', 'klanten': 'klant', 'partner': 'partner', 'partners': 'partner',
+ 'leverancier': 'partner'}.get(str(raw.get('kind', '')).strip().lower(), kind)
+ if not data.get('name'):
+ return None, 'skipped'
+ existing = (q1("SELECT * FROM relations WHERE kind=? AND kvk=? AND kvk<>''", kind, data['kvk'])
+ if data.get('kvk') else None) or q1('SELECT * FROM relations WHERE kind=? AND name=? COLLATE NOCASE',
+ kind, data['name'])
+ if existing:
+ if not update_existing:
+ return existing['id'], 'skipped'
+ updates = {k: v for k, v in data.items() if not existing[k]}
+ if not updates:
+ return existing['id'], 'unchanged'
+ updates['updated_at'] = now()
+ db().execute(f'UPDATE relations SET {", ".join(f"{k}=?" for k in updates)} WHERE id=?',
+ [*updates.values(), existing['id']])
+ return existing['id'], 'updated'
+ if not data.get('type'):
+ rid = find_or_create_relation(data['name'], kind)
+ data.pop('name')
+ if data:
+ db().execute(f'UPDATE relations SET {", ".join(f"{k}=?" for k in data)} WHERE id=?', [*data.values(), rid])
+ return rid, 'created'
+ ts = now()
+ data.update(kind=kind, created_at=ts, updated_at=ts)
+ cur = db().execute(f'INSERT INTO relations ({", ".join(data)}) VALUES ({", ".join("?" * len(data))})',
+ list(data.values()))
+ return cur.lastrowid, 'created'
+
+
+@app.route('/relations')
+def relations():
+ kind = request.args.get('kind') if request.args.get('kind') in dict(RELATION_KINDS) else 'klant'
+ type_, term = request.args.get('type', ''), request.args.get('q', '').strip()
+ sql = ('SELECT r.*, (SELECT COUNT(*) FROM contacts WHERE relation_id=r.id) AS members, '
+ '(SELECT GROUP_CONCAT(name, ", ") FROM (SELECT name FROM contacts WHERE relation_id=r.id LIMIT 4)) AS member_names, '
+ '(SELECT COUNT(*) FROM products WHERE supplier_id=r.id) AS products '
+ 'FROM relations r WHERE r.kind=?')
+ params = [kind]
+ if type_:
+ sql += ' AND r.type=?'
+ params.append(type_)
+ if term:
+ sql += (' AND (r.name LIKE ? OR r.city LIKE ? OR r.email LIKE ? OR EXISTS '
+ '(SELECT 1 FROM contacts c WHERE c.relation_id=r.id AND c.name LIKE ?))')
+ params += [f'%{term}%'] * 4
+ rows = db().execute(sql + ' ORDER BY r.name LIMIT 500', params).fetchall()
+ counts = {r[0]: r[1] for r in q('SELECT type, COUNT(*) FROM relations WHERE kind=? GROUP BY type', kind)}
+ return render_template('relations.html', rows=rows, kind=kind, type=type_, term=term,
+ types=relation_types(kind), counts=counts)
+
+
+@app.route('/relations/new', methods=['GET', 'POST'], defaults={'rid': None})
+@app.route('/relations//edit', methods=['GET', 'POST'])
+def relation_edit(rid):
+ r = get_or_404('relations', rid) if rid else None
+ if request.method == 'POST':
+ data, kind = relation_form_data()
+ if not data['name']:
+ flash('Vul een naam in.', 'error')
+ return render_template('relation_form.html', r={**data, 'kind': kind, 'id': rid},
+ types=relation_types(kind))
+ data['updated_at'] = now()
+ if r:
+ data['kind'] = kind
+ db().execute(f'UPDATE relations SET {", ".join(f"{k}=?" for k in data)} WHERE id=?', [*data.values(), rid])
+ if kind != r['kind']:
+ for c in q('SELECT id FROM contacts WHERE relation_id=?', rid):
+ link_relation(c['id'], rid)
+ else:
+ data.update(kind=kind, created_at=data['updated_at'])
+ rid = db().execute(f'INSERT INTO relations ({", ".join(data)}) VALUES ({", ".join("?" * len(data))})',
+ list(data.values())).lastrowid
+ db().commit()
+ flash('Relatie opgeslagen.')
+ return redirect(url_for('relation_view', rid=rid))
+ kind = r['kind'] if r else (request.args.get('kind') if request.args.get('kind') in dict(RELATION_KINDS) else 'klant')
+ return render_template('relation_form.html', r=r or {'kind': kind, 'type': request.args.get('type', '')},
+ types=relation_types(kind))
+
+
+@app.route('/relations/')
+def relation_view(rid):
+ r = get_or_404('relations', rid)
+ members = q('SELECT c.*, u.name AS user_name FROM contacts c LEFT JOIN users u ON u.id=c.assigned_user_id '
+ 'WHERE c.relation_id=? ORDER BY c.name', rid)
+ ids = [m['id'] for m in members] or [0]
+ marks = ', '.join('?' * len(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'),
+ quotes=quotes_with_totals(f'WHERE qu.contact_id IN ({marks})', *ids),
+ activities=q(f'SELECT a.*, c.name AS contact_name, u.name AS user_name FROM activities a '
+ f'JOIN contacts c ON c.id=a.contact_id LEFT JOIN users u ON u.id=a.user_id '
+ 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))
+
+
+@app.post('/relations//member')
+def relation_member(rid):
+ get_or_404('relations', rid)
+ cid = request.form.get('contact_id', type=int)
+ if cid and q1('SELECT id FROM contacts WHERE id=?', cid):
+ link_relation(cid, rid, request.form.get('relation_role', '').strip())
+ db().commit()
+ return redirect(url_for('relation_view', rid=rid))
+
+
+@app.post('/relations//member//remove')
+def relation_member_remove(rid, cid):
+ if q1('SELECT id FROM contacts WHERE id=? AND relation_id=?', cid, rid):
+ link_relation(cid, None, '')
+ db().commit()
+ return redirect(url_for('relation_view', rid=rid))
+
+
+@app.post('/relations//delete')
+def relation_delete(rid):
+ r = get_or_404('relations', rid)
+ for c in q('SELECT id FROM contacts WHERE relation_id=?', rid):
+ link_relation(c['id'], None, '')
+ db().execute("UPDATE products SET supplier_id=NULL WHERE supplier_id=?", (rid,))
+ db().execute('DELETE FROM relations WHERE id=?', (rid,))
+ db().commit()
+ flash('Relatie verwijderd; de contactpersonen en producten zijn bewaard.')
+ return redirect(url_for('relations', kind=r['kind']))
+
+
+# ---------------------------------------------------------------- import
+
+IMPORT_NAME = re.compile(r'[0-9a-f]{32}\.(csv|json)')
+
+
+@app.route('/import', methods=['GET', 'POST'])
+def import_start():
+ if request.method == 'POST':
+ fs = request.files.get('file')
+ kind = request.form.get('kind') if request.form.get('kind') in importer.TARGETS else 'contacts'
+ ext = os.path.splitext(fs.filename if fs else '')[1].lower()
+ if ext not in ('.csv', '.json', '.txt'):
+ flash('Kies een .csv- of .json-bestand.', 'error')
+ return redirect(url_for('import_start'))
+ token = uuid.uuid4().hex + ('.json' if ext == '.json' else '.csv')
+ fs.save(os.path.join(IMPORTS, token))
+ session[f'import_{token}'] = fs.filename
+ return redirect(url_for('import_preview', token=token, kind=kind))
+ return render_template('import.html')
+
+
+@app.route('/import/', methods=['GET', 'POST'])
+def import_preview(token):
+ if not IMPORT_NAME.fullmatch(token) or not os.path.exists(os.path.join(IMPORTS, token)):
+ flash('Importbestand niet gevonden, upload het opnieuw.', 'error')
+ return redirect(url_for('import_start'))
+ path = os.path.join(IMPORTS, token)
+ kind = request.values.get('kind') if request.values.get('kind') in importer.TARGETS else 'contacts'
+ filename = session.get(f'import_{token}', token)
+ try:
+ cols, rows = importer.read(path)
+ except importer.ImportFout as e:
+ os.remove(path)
+ flash(str(e), 'error')
+ return redirect(url_for('import_start'))
+
+ if request.method == 'GET':
+ return render_template('import_preview.html', token=token, kind=kind, cols=cols, rows=rows[:8],
+ total=len(rows), mapping=importer.guess(cols, kind),
+ targets=importer.TARGETS[kind], filename=filename,
+ relation_types={k: relation_types(k) for k, _ in RELATION_KINDS})
+
+ valid = dict(importer.TARGETS[kind])
+ mapping = [(col, request.form.get(f'map_{i}', '')) for i, col in enumerate(cols)]
+ mapping = [(col, t) for col, t in mapping if t in valid and t]
+ update = bool(request.form.get('update'))
+ result = {'created': 0, 'updated': 0, 'unchanged': 0, 'skipped': 0}
+
+ if kind == 'relations':
+ rel_kind = request.form.get('relation_kind') if request.form.get('relation_kind') in dict(RELATION_KINDS) else 'klant'
+ for row in rows:
+ data = {}
+ for col, target in mapping:
+ value = row.get(col)
+ if isinstance(value, (str, int, float)) and str(value).strip():
+ data.setdefault(target, value)
+ if not data.get('type') and request.form.get('relation_type'):
+ data['type'] = request.form['relation_type']
+ _, outcome = upsert_relation(data, rel_kind, update)
+ result[outcome] = result.get(outcome, 0) + 1
+ elif kind == 'contacts':
+ defaults = {'type': request.form.get('type') if request.form.get('type') in dict(TYPES) else 'lead',
+ 'status': request.form.get('status') if request.form.get('status') in dict(STATUSES) else 'nieuw',
+ 'assigned': request.form.get('assigned_user_id', type=int),
+ 'source': request.form.get('source', '').strip() or 'Import'}
+ for row in rows:
+ fields, extra = {}, {}
+ for col, target in mapping:
+ value = row.get(col)
+ if target == 'extra':
+ if value not in (None, ''):
+ extra[col] = value
+ elif isinstance(value, (str, int, float)) and str(value).strip():
+ fields.setdefault(target, value)
+ message = str(fields.pop('message', '')).strip()
+ cid, outcome = upsert_contact(fields, extra, update, defaults)
+ result[outcome] += 1
+ if outcome != 'skipped':
+ log(cid, 'import', f'Geïmporteerd uit {filename}' + (f'\n{message}' if message else ''))
+ else:
+ records = []
+ for row in rows:
+ data = {}
+ for col, target in mapping:
+ value = row.get(col)
+ if isinstance(value, (str, int, float)) and str(value).strip():
+ data.setdefault(target, value)
+ records.append(data)
+ has_sku = any(t == 'sku' for _, t in mapping)
+ result = import_products(records, f'Import {filename}', update,
+ deactivate_missing=has_sku and bool(request.form.get('deactivate_missing')))
+ db().commit()
+ os.remove(path)
+ session.pop(f'import_{token}', None)
+ flash(import_summary(result))
+ if kind == 'relations':
+ return redirect(url_for('relations', kind=request.form.get('relation_kind', 'klant')))
+ return redirect(url_for('contacts' if kind == 'contacts' else 'products'))
+
+
+# ---------------------------------------------------------------- inbox
+
+def do_sync():
+ cfg = settings_dict()
+ if not cfg.get('imap_host'):
+ return 0, 'Er is nog geen IMAP-server ingesteld.'
+ known = {r[0] for r in q('SELECT uid FROM emails WHERE uid IS NOT NULL')}
+ try:
+ messages = mail.fetch(cfg, known)
+ except Exception as e: # netwerk-, login- of mapfouten tonen we aan de gebruiker
+ return 0, f'Ophalen mislukt: {e}'
+ for m in messages:
+ c = q1('SELECT id FROM contacts WHERE email=? COLLATE NOCASE', m['from_addr']) if m['from_addr'] else None
+ db().execute('INSERT OR IGNORE INTO emails (uid, message_id, direction, contact_id, from_name, '
+ 'from_addr, to_addr, subject, body, date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
+ (m['uid'], m['message_id'], 'in', c['id'] if c else None, m['from_name'],
+ m['from_addr'], m['to_addr'], m['subject'], m['body'], m['date']))
+ if c:
+ log(c['id'], 'email', f"E-mail ontvangen: {m['subject']}")
+ set_setting('last_sync', now())
+ db().commit()
+ return len(messages), None
+
+
+@app.route('/inbox')
+def inbox():
+ flt = request.args.get('f', 'all')
+ where = {'unread': 'AND e.is_read=0', 'unlinked': 'AND e.contact_id IS NULL'}.get(flt, '')
+ rows = q(f"SELECT e.*, c.name AS contact_name FROM emails e LEFT JOIN contacts c ON c.id=e.contact_id "
+ f"WHERE e.direction='in' {where} ORDER BY e.date DESC LIMIT 300")
+ return render_template('inbox.html', rows=rows, f=flt, last_sync=get_setting('last_sync'),
+ configured=bool(get_setting('imap_host')))
+
+
+@app.post('/inbox/sync')
+def inbox_sync():
+ count, error = do_sync()
+ if error:
+ flash(error, 'error')
+ else:
+ flash(f'{count} nieuwe e-mail(s) opgehaald.')
+ return redirect(url_for('inbox'))
+
+
+@app.route('/inbox/')
+def inbox_view(eid):
+ e = get_or_404('emails', eid)
+ if not e['is_read']:
+ db().execute('UPDATE emails SET is_read=1 WHERE id=?', (eid,))
+ db().commit()
+ contact = q1('SELECT * FROM contacts WHERE id=?', e['contact_id']) if e['contact_id'] else None
+ return render_template('email.html', e=e, contact=contact)
+
+
+@app.post('/inbox//contact')
+def inbox_contact(eid):
+ e = get_or_404('emails', eid)
+ c = q1('SELECT id FROM contacts WHERE email=? COLLATE NOCASE', e['from_addr'])
+ if c:
+ cid = c['id']
+ link_emails(cid, e['from_addr'])
+ else:
+ cid = insert_contact({'name': e['from_name'] or e['from_addr'], 'email': e['from_addr'], 'source': 'E-mail'},
+ assigned=g.user['id'])
+ log(cid, 'note', f"Aangemaakt vanuit e-mail: {e['subject']}")
+ db().commit()
+ return redirect(url_for('contact_view', cid=cid))
+
+
+@app.post('/mail/send')
+def mail_send():
+ f = request.form
+ to, subject, body = f.get('to', '').strip(), f.get('subject', '').strip(), f.get('body', '')
+ cid = f.get('contact_id', type=int)
+ reply = q1('SELECT * FROM emails WHERE id=?', f.get('reply_to', type=int)) if f.get('reply_to') else None
+ target = back(url_for('inbox'))
+ try:
+ message_id = mail.send(settings_dict(), to, subject, body, reply['message_id'] if reply else None)
+ except Exception as e:
+ flash(f'Versturen mislukt: {e}', 'error')
+ return redirect(target)
+ if not cid:
+ c = q1('SELECT id FROM contacts WHERE email=? COLLATE NOCASE', to)
+ cid = c['id'] if c else None
+ db().execute("INSERT INTO emails (message_id, direction, contact_id, from_addr, to_addr, subject, body, date, "
+ "is_read) VALUES (?, 'out', ?, ?, ?, ?, ?, ?, 1)",
+ (message_id, cid, get_setting('smtp_from') or get_setting('smtp_user'), to, subject, body, now()))
+ if cid:
+ log(cid, 'email', f'E-mail verstuurd: {subject}')
+ db().commit()
+ flash('E-mail verstuurd.')
+ return redirect(target)
+
+
+# ---------------------------------------------------------------- producten
+#
+# Een product wordt herkend aan zijn SKU (hoofdletterongevoelig); zonder SKU aan zijn naam.
+# Imports en de API werken bestaande producten bij in plaats van ze opnieuw aan te maken, en elke
+# wijziging komt in product_history. Offerteregels bewaren hun eigen omschrijving en prijs, dus een
+# prijsmutatie verandert nooit een offerte die al bestaat.
+
+def parse_bool(value):
+ return 0 if str(value).strip().lower() in ('0', 'nee', 'no', 'false', 'n', 'inactief', 'uit') else 1
+
+
+def clean_product(data):
+ """Zet ruwe invoer om; alleen meegegeven velden blijven over (lege waarden tellen niet)."""
+ out = {}
+ for key in (*PRODUCT_FIELDS, 'active'):
+ value = data.get(key)
+ if value is None or (isinstance(value, str) and not value.strip()):
+ continue
+ if key == 'price':
+ out[key] = round(num(value), 4)
+ elif key == 'vat':
+ out[key] = num(value, 21)
+ elif key == 'active':
+ out[key] = value if isinstance(value, int) else parse_bool(value)
+ else:
+ out[key] = str(value).strip()
+ return out
+
+
+def product_history(pid, field, old, new, source):
+ db().execute('INSERT INTO product_history (product_id, field, old_value, new_value, source, user_id, created_at) '
+ 'VALUES (?, ?, ?, ?, ?, ?, ?)',
+ (pid, field, '' if old is None else str(old), '' if new is None else str(new), source,
+ current_user_id(), now()))
+
+
+def find_product(sku='', name=''):
+ if sku:
+ return q1('SELECT * FROM products WHERE sku=? COLLATE NOCASE', sku)
+ return q1("SELECT * FROM products WHERE sku='' AND name=? COLLATE NOCASE", name) if name else None
+
+
+def upsert_product(raw, source, update_existing=True, existing=None):
+ """Maak een product aan of werk het bij. Geeft (uitkomst, prijs_gewijzigd, product_id) terug;
+ uitkomst is 'created', 'updated', 'unchanged' of 'skipped'."""
+ data = clean_product(raw)
+ if 'supplier' in data:
+ data['supplier_id'] = find_or_create_relation(data['supplier'], 'partner')
+ elif raw.get('supplier_id'):
+ rel = q1("SELECT id, name FROM relations WHERE id=? AND kind='partner'", raw['supplier_id'])
+ if rel:
+ data.update(supplier_id=rel['id'], supplier=rel['name'])
+ existing = existing or find_product(data.get('sku', ''), data.get('name', ''))
+ if existing is None:
+ if not data.get('name'):
+ return 'skipped', False, None
+ data.setdefault('unit', 'stuk')
+ data['created_at'] = data['updated_at'] = now()
+ cur = db().execute(f'INSERT INTO products ({", ".join(data)}) VALUES ({", ".join("?" * len(data))})',
+ list(data.values()))
+ product_history(cur.lastrowid, 'created', '', data.get('price', 0), source)
+ return 'created', False, cur.lastrowid
+ if not update_existing:
+ return 'skipped', False, existing['id']
+ changes = {k: v for k, v in data.items() if v != existing[k]}
+ if str(changes.get('sku', '')).lower() == (existing['sku'] or '').lower():
+ changes.pop('sku', None) # zelfde SKU in andere schrijfwijze: de bestaande houden
+ if not changes:
+ return 'unchanged', False, existing['id']
+ changes['updated_at'] = now()
+ db().execute(f'UPDATE products SET {", ".join(f"{k}=?" for k in changes)} WHERE id=?',
+ [*changes.values(), existing['id']])
+ for key, value in changes.items():
+ if key not in ('updated_at', 'supplier_id'):
+ product_history(existing['id'], key, existing[key], value, source)
+ return 'updated', 'price' in changes, existing['id']
+
+
+def import_products(rows, source, update_existing=True, deactivate_missing=False):
+ """Gedeelde logica voor de CSV/JSON-import en de API. `rows` zijn dicts met productvelden."""
+ result = {'created': 0, 'updated': 0, 'unchanged': 0, 'skipped': 0, 'prices': 0, 'deactivated': 0}
+ seen = set()
+ for row in rows:
+ outcome, price_changed, pid = upsert_product(row, source, update_existing)
+ result[outcome] += 1
+ result['prices'] += price_changed
+ if pid:
+ seen.add(pid)
+ if deactivate_missing:
+ for p in q("SELECT id FROM products WHERE active=1 AND sku<>''"):
+ if p['id'] not in seen:
+ db().execute('UPDATE products SET active=0, updated_at=? WHERE id=?', (now(), p['id']))
+ product_history(p['id'], 'active', 1, 0, f'{source} (niet meer in bestand)')
+ result['deactivated'] += 1
+ return result
+
+
+def import_summary(r):
+ parts = [f"{r['created']} nieuw", f"{r['updated']} bijgewerkt"]
+ if r.get('prices'):
+ parts.append(f"{r['prices']} prijswijziging(en)")
+ parts += [f"{r.get('unchanged', 0)} ongewijzigd", f"{r['skipped']} overgeslagen"]
+ if r.get('deactivated'):
+ parts.append(f"{r['deactivated']} op inactief gezet")
+ return 'Import klaar: ' + ', '.join(parts) + '.'
+
+
+@app.route('/products')
+def products():
+ term = request.args.get('q', '').strip()
+ show = request.args.get('show', 'active')
+ sql, params = 'SELECT * FROM products WHERE 1=1', []
+ if term:
+ sql += ' AND (name LIKE ? OR sku LIKE ? OR supplier LIKE ?)'
+ params += [f'%{term}%'] * 3
+ if show in ('active', 'inactive'):
+ sql += ' AND active=?'
+ params.append(1 if show == 'active' else 0)
+ rows = db().execute(sql + ' ORDER BY name', params).fetchall()
+ return render_template('products.html', rows=rows, term=term, show=show)
+
+
+@app.route('/products/export.csv')
+def products_export():
+ out = io.StringIO()
+ writer = csv.writer(out, delimiter=';')
+ writer.writerow(['sku', 'naam', 'omschrijving', 'eenheid', 'prijs', 'btw', 'leverancier', 'actief', 'bijgewerkt'])
+ for p in q('SELECT * FROM products ORDER BY sku, name'):
+ writer.writerow([p['sku'], p['name'], p['description'], p['unit'], f"{p['price']:.2f}".replace('.', ','),
+ fmt_number(p['vat']), p['supplier'], 'ja' if p['active'] else 'nee',
+ (p['updated_at'] or p['created_at'])[:10]])
+ resp = make_response('' + out.getvalue())
+ resp.headers['Content-Type'] = 'text/csv; charset=utf-8'
+ resp.headers['Content-Disposition'] = f'attachment; filename=producten-{date.today()}.csv'
+ return resp
+
+
+@app.route('/products/new', methods=['GET', 'POST'], defaults={'pid': None})
+@app.route('/products//edit', methods=['GET', 'POST'])
+def product_edit(pid):
+ p = get_or_404('products', pid) if pid else None
+ if request.method == 'POST':
+ f = request.form
+ data = {k: f.get(k, '') for k in PRODUCT_FIELDS}
+ data['active'] = 1 if f.get('active') else 0
+ sku = data['sku'].strip()
+ clash = find_product(sku) if sku else None
+ error = None
+ if not data['name'].strip():
+ error = 'Vul een productnaam in.'
+ elif clash and (not p or clash['id'] != p['id']):
+ error = f"SKU {sku} is al in gebruik bij {clash['name']}."
+ if error:
+ flash(error, 'error')
+ return render_template('product_form.html', p={**(dict(p) if p else {}), **data},
+ history=history_for(pid) if pid else [])
+ if p:
+ # leeggemaakte tekstvelden moeten ook leeg kunnen worden
+ for key in ('sku', 'description', 'supplier'):
+ if not data[key].strip() and p[key]:
+ db().execute(f'UPDATE products SET {key}=? WHERE id=?', ('', pid))
+ if key == 'supplier':
+ db().execute('UPDATE products SET supplier_id=NULL WHERE id=?', (pid,))
+ product_history(pid, key, p[key], '', 'Handmatig')
+ p = get_or_404('products', pid)
+ outcome, _, pid = upsert_product(data, 'Handmatig', existing=p)
+ image = save_upload(request.files.get('image'))
+ if image:
+ old = q1('SELECT image FROM products WHERE id=?', pid)['image']
+ remove_upload(old)
+ db().execute('UPDATE products SET image=?, updated_at=? WHERE id=?', (image, now(), pid))
+ product_history(pid, 'image', '', 'nieuwe afbeelding', 'Handmatig')
+ db().commit()
+ flash('Product opgeslagen.' if outcome != 'unchanged' or image else 'Geen wijzigingen.')
+ return redirect(url_for('products'))
+ return render_template('product_form.html', p=p or {'unit': 'stuk', 'vat': 21, 'active': 1},
+ suppliers=q("SELECT id, name FROM relations WHERE kind='partner' ORDER BY name"),
+ history=history_for(pid) if pid else [],
+ used=scalar('SELECT COUNT(DISTINCT quote_id) FROM quote_lines WHERE product_id=?', pid)
+ if pid else 0)
+
+
+def history_for(pid):
+ return q('SELECT h.*, u.name AS user_name FROM product_history h LEFT JOIN users u ON u.id=h.user_id '
+ 'WHERE h.product_id=? ORDER BY h.created_at DESC, h.id DESC LIMIT 100', pid)
+
+
+@app.post('/products//delete')
+def product_delete(pid):
+ p = get_or_404('products', pid)
+ if scalar('SELECT COUNT(*) FROM quote_lines WHERE product_id=?', pid):
+ # in gebruik in offertes: archiveren zodat de koppeling en de historie blijven bestaan
+ if p['active']:
+ db().execute('UPDATE products SET active=0, updated_at=? WHERE id=?', (now(), pid))
+ product_history(pid, 'active', 1, 0, 'Handmatig (gearchiveerd)')
+ db().commit()
+ flash('Dit product staat in offertes en is daarom op inactief gezet in plaats van verwijderd.')
+ return redirect(url_for('products'))
+ remove_upload(p['image'])
+ db().execute('DELETE FROM products WHERE id=?', (pid,))
+ db().commit()
+ flash('Product verwijderd.')
+ return redirect(url_for('products'))
+
+
+# ---------------------------------------------------------------- offertes
+
+def next_quote_number():
+ base = f"{get_setting('quote_prefix', 'OFF') or 'OFF'}-{date.today().year}-"
+ n = 1
+ for r in q('SELECT number FROM quotes WHERE number LIKE ?', base + '%'):
+ tail = r['number'][len(base):]
+ if tail.isdigit():
+ n = max(n, int(tail) + 1)
+ return f'{base}{n:04d}'
+
+
+@app.route('/quotes')
+def quotes():
+ status = request.args.get('status', '')
+ rows = quotes_with_totals('WHERE qu.status=?', status) if status else quotes_with_totals()
+ return render_template('quotes.html', rows=rows, status=status)
+
+
+@app.post('/quotes/new')
+def quote_new():
+ cid = request.form.get('contact_id', type=int)
+ today = date.today()
+ valid = today + timedelta(days=int(num(get_setting('quote_valid_days', '30'), 30)))
+ number, ts = next_quote_number(), now()
+ cur = db().execute('INSERT INTO quotes (number, contact_id, status, date, valid_until, intro, notes, '
+ 'created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
+ (number, cid, 'concept', today.isoformat(), valid.isoformat(),
+ get_setting('quote_intro'), '', ts, ts))
+ if cid:
+ log(cid, 'quote', f'Offerte {number} aangemaakt')
+ db().commit()
+ return redirect(url_for('quote_edit', qid=cur.lastrowid))
+
+
+@app.route('/quotes/', methods=['GET', 'POST'])
+def quote_edit(qid):
+ qt = get_or_404('quotes', qid)
+ if request.method == 'POST':
+ f = request.form
+ cid = f.get('contact_id', type=int)
+ db().execute('UPDATE quotes SET contact_id=?, title=?, date=?, valid_until=?, intro=?, notes=?, updated_at=? '
+ 'WHERE id=?',
+ (cid, f.get('title', '').strip(), f.get('date') or qt['date'], f.get('valid_until') or None,
+ f.get('intro', ''), f.get('notes', ''), now(), qid))
+ db().execute('DELETE FROM quote_lines WHERE quote_id=?', (qid,))
+ lines = zip(f.getlist('l_product'), f.getlist('l_desc'), f.getlist('l_qty'),
+ f.getlist('l_unit'), f.getlist('l_price'), f.getlist('l_vat'))
+ for pos, (product, desc, qty, unit, price, vat) in enumerate(lines):
+ if not desc.strip() and not num(price):
+ continue
+ db().execute('INSERT INTO quote_lines (quote_id, position, product_id, description, qty, unit, price, vat) '
+ 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
+ (qid, pos, int(product) if product.isdigit() else None, desc.strip(),
+ num(qty, 1), unit.strip(), num(price), num(vat, 21)))
+ if f.get('status') in dict(QUOTE_STATUSES):
+ change_quote_status(get_or_404('quotes', qid), f['status'])
+ db().commit()
+ if f.get('then') == 'print':
+ return redirect(url_for('quote_print', qid=qid))
+ flash('Offerte opgeslagen.')
+ return redirect(url_for('quote_edit', qid=qid))
+ return render_template(
+ 'quote_edit.html', qt=qt,
+ 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'),
+ prices={r['id']: r['price'] for r in q('SELECT id, price FROM products')})
+
+
+def render_quote(qt, portal=False):
+ lines = q('SELECT * FROM quote_lines WHERE quote_id=? ORDER BY position', qt['id'])
+ 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)
+
+
+@app.route('/quotes//print')
+def quote_print(qid):
+ return render_quote(get_or_404('quotes', qid))
+
+
+@app.post('/quotes//delete')
+def quote_delete(qid):
+ get_or_404('quotes', qid)
+ db().execute('DELETE FROM quotes WHERE id=?', (qid,))
+ db().commit()
+ flash('Offerte verwijderd.')
+ return redirect(url_for('quotes'))
+
+
+# ---------------------------------------------------------------- instellingen
+
+@app.route('/settings', methods=['GET', 'POST'])
+def settings():
+ if request.method == 'POST':
+ if request.form.get('action') == 'apikey':
+ set_setting('api_key', secrets.token_urlsafe(24))
+ flash('Nieuwe API-sleutel aangemaakt. Pas je formulieren aan.')
+ else:
+ for key in SETTING_KEYS:
+ set_setting(key, request.form.get(key, '').strip())
+ for key in SECRET_KEYS:
+ if request.form.get(key):
+ set_setting(key, request.form[key])
+ flash('Instellingen opgeslagen.')
+ db().commit()
+ return redirect(url_for('settings'))
+ return render_template('settings.html', s=settings_dict(), api_url=url_for('api_leads', _external=True),
+ portal_url=url_for('portal_login', _external=True))
+
+
+# ---------------------------------------------------------------- klantportaal
+
+@app.route('/portal/login', methods=['GET', 'POST'])
+def portal_login():
+ if request.method == 'POST':
+ c = q1('SELECT * FROM contacts WHERE email=? COLLATE NOCASE AND portal_password_hash IS NOT NULL',
+ request.form.get('email', '').strip())
+ if c and check_password_hash(c['portal_password_hash'], request.form.get('password', '')):
+ session['portal_cid'] = c['id']
+ session.permanent = True
+ db().execute('UPDATE contacts SET portal_last_login=? WHERE id=?', (now(), c['id']))
+ db().commit()
+ return redirect(url_for('portal_home'))
+ flash('Onjuist e-mailadres of wachtwoord.', 'error')
+ return render_template('auth.html', mode='portal_login')
+
+
+@app.route('/portal/logout')
+def portal_logout():
+ session.pop('portal_cid', None)
+ return redirect(url_for('portal_login'))
+
+
+@app.route('/portal/invite/', methods=['GET', 'POST'])
+def portal_invite(token):
+ c = q1('SELECT * FROM contacts WHERE portal_token=?', token)
+ if not c:
+ flash('Deze uitnodiging is niet (meer) geldig. Vraag een nieuwe aan.', 'error')
+ return redirect(url_for('portal_login'))
+ if request.method == 'POST':
+ pw = request.form.get('password', '')
+ if len(pw) < 8 or pw != request.form.get('password2'):
+ flash('Het wachtwoord moet minimaal 8 tekens hebben en twee keer hetzelfde zijn.', 'error')
+ else:
+ db().execute('UPDATE contacts SET portal_password_hash=?, portal_token=NULL, portal_last_login=? '
+ 'WHERE id=?', (generate_password_hash(pw), now(), c['id']))
+ log(c['id'], 'portal', 'Klant heeft het portaal geactiveerd')
+ db().commit()
+ session['portal_cid'] = c['id']
+ session.permanent = True
+ return redirect(url_for('portal_home'))
+ return render_template('auth.html', mode='invite', c=c)
+
+
+@app.route('/portal')
+def portal_home():
+ cid = g.portal['id']
+ 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),
+ 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())
+
+
+def portal_quote_or_404(qid):
+ qt = q1("SELECT * FROM quotes WHERE id=? AND contact_id=? AND status<>'concept'", qid, g.portal['id'])
+ if qt is None:
+ abort(404)
+ return qt
+
+
+@app.route('/portal/quotes/')
+def portal_quote(qid):
+ return render_quote(portal_quote_or_404(qid), portal=True)
+
+
+@app.post('/portal/quotes//')
+def portal_quote_answer(qid, choice):
+ qt = portal_quote_or_404(qid)
+ status = {'accept': 'geaccepteerd', 'decline': 'afgewezen'}.get(choice)
+ if status and qt['status'] == 'verzonden':
+ change_quote_status(qt, status)
+ log(qt['contact_id'], 'portal', f"Klant heeft offerte {qt['number']} {'geaccepteerd' if status == 'geaccepteerd' else 'afgewezen'} via het portaal")
+ db().commit()
+ flash('Bedankt, we hebben uw reactie ontvangen.')
+ return redirect(url_for('portal_home'))
+
+
+@app.post('/portal/upload')
+def portal_upload():
+ cid = g.portal['id']
+ count = sum(1 for fs in request.files.getlist('files') if save_upload(fs, cid))
+ if count:
+ log(cid, 'portal', f'Klant heeft {count} bestand(en) geüpload')
+ db().commit()
+ flash('Bestanden ontvangen.')
+ return redirect(url_for('portal_home'))
+
+
+@app.post('/portal/message')
+def portal_message():
+ body = request.form.get('body', '').strip()
+ if body:
+ log(g.portal['id'], 'portal', f'Bericht: {body}')
+ db().commit()
+ flash('Bericht verstuurd. We nemen zo snel mogelijk contact op.')
+ return redirect(url_for('portal_home'))
+
+
+@app.route('/portal/files/')
+def portal_file(name):
+ f = q1('SELECT * FROM files WHERE stored=? AND contact_id=?', name, g.portal['id'])
+ if f is None:
+ abort(404)
+ return serve_upload(name, f['original'])
+
+
+# ---------------------------------------------------------------- API
+
+def allowed_origins():
+ return [o.strip().rstrip('/') for o in get_setting('allowed_origins').split(',') if o.strip()]
+
+
+def cors(resp):
+ origin = request.headers.get('Origin')
+ allowed = allowed_origins()
+ if origin and (not allowed or origin in allowed):
+ resp.headers['Access-Control-Allow-Origin'] = origin
+ resp.headers['Access-Control-Allow-Headers'] = 'Content-Type, X-API-Key'
+ resp.headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS'
+ resp.headers['Vary'] = 'Origin'
+ return resp
+
+
+def api_error(message, code):
+ return cors(jsonify(ok=False, error=message)), code
+
+
+@app.route('/api/leads', methods=['POST', 'OPTIONS'])
+def api_leads():
+ """Nieuwe lead vanuit een formulier. Accepteert JSON, gewone formulieren en multipart (met bestanden)."""
+ if request.method == 'OPTIONS':
+ return cors(make_response('', 204))
+ data = (request.get_json(silent=True) or {}) if request.is_json else request.form.to_dict()
+ if not isinstance(data, dict):
+ return api_error('Verwacht een JSON-object', 400)
+ key = request.headers.get('X-API-Key') or request.args.get('key') or str(data.get('api_key') or '')
+ if not key or not secrets.compare_digest(key, get_setting('api_key')):
+ return api_error('Ongeldige API-sleutel', 401)
+
+ goto = str(data.get('redirect') or '')
+ allowed = allowed_origins()
+ goto_ok = goto.startswith(('https://', 'http://')) and (
+ not allowed or any(goto == o or goto.startswith(o + '/') for o in allowed))
+
+ def done(contact_id=None):
+ if goto_ok and not request.is_json:
+ return redirect(goto, 303)
+ return cors(jsonify(ok=True, id=contact_id))
+
+ if data.get('_gotcha') or data.get('website_hp'): # honeypot: spam stil negeren
+ return done()
+
+ fields, extra, designs = {}, {}, []
+ for k, v in data.items():
+ if k in API_SKIP:
+ continue
+ if isinstance(v, dict) and v.get('type') == 'house-design':
+ designs.append(v)
+ continue
+ field = FIELD_LOOKUP.get(k.strip().lower())
+ if field and isinstance(v, (str, int, float)):
+ fields.setdefault(field, v)
+ else:
+ extra[k] = v
+ message = str(fields.pop('message', '')).strip()
+ referer = request.headers.get('Origin') or request.headers.get('Referer') or ''
+ assign = get_setting('api_assign_user')
+ defaults = {'source': urlparse(referer).netloc or 'API', 'assigned': int(assign) if assign.isdigit() else None}
+ cid, outcome = upsert_contact(fields, extra, True, defaults)
+ if not cid:
+ return api_error('Geef minimaal een naam, e-mailadres of telefoonnummer mee', 400)
+
+ source = q1('SELECT source FROM contacts WHERE id=?', cid)['source']
+ body = [f'Formulier ontvangen via {source}' + (' (bestaand contact)' if outcome == 'updated' else '')]
+ if message:
+ body.append(message)
+ body += [f'{k}: {v if isinstance(v, str) else json.dumps(v, ensure_ascii=False)}' for k, v in extra.items()]
+ log(cid, 'form', '\n'.join(body))
+ if outcome == 'updated':
+ db().execute("UPDATE contacts SET status='nieuw' WHERE id=? AND status='verloren'", (cid,))
+
+ for design in designs:
+ save_design_json(design, cid)
+ count = sum(1 for name in request.files for fs in request.files.getlist(name) if save_upload(fs, cid))
+ if count:
+ log(cid, 'file', f'{count} bestand(en) meegestuurd met formulier')
+ db().commit()
+ return done(cid)
+
+
+@app.route('/api/products', methods=['POST'])
+def api_products():
+ """Producten aanmaken of bijwerken op SKU, bijv. vanuit prijsscripts van leveranciers.
+
+ Body: één object, een lijst, of {"products": [...]}; veldnamen zoals bij de import
+ (sku/code, naam/omschrijving, prijs, eenheid, btw, leverancier, actief).
+ """
+ key = request.headers.get('X-API-Key') or request.args.get('key') or ''
+ if not key or not secrets.compare_digest(key, get_setting('api_key')):
+ return jsonify(ok=False, error='Ongeldige API-sleutel'), 401
+ data = request.get_json(silent=True)
+ if isinstance(data, dict):
+ lists = [v for v in data.values() if isinstance(v, list)]
+ data = lists[0] if lists else [data]
+ if not isinstance(data, list):
+ return jsonify(ok=False, error='Verwacht JSON: een object of een lijst met producten'), 400
+ table = importer.lookup(importer.PRODUCT_ALIASES)
+ records = []
+ for item in data:
+ if isinstance(item, dict):
+ record = {}
+ for k, v in item.items():
+ field = table.get(str(k).strip().lower().replace(' ', '_'))
+ if field and isinstance(v, (str, int, float)):
+ record.setdefault(field, v)
+ records.append(record)
+ result = import_products(records, request.args.get('source') or 'API',
+ deactivate_missing=request.args.get('deactivate_missing') == '1')
+ db().commit()
+ return jsonify(ok=True, **result)
+
+
+if __name__ == '__main__':
+ if len(sys.argv) > 1 and sys.argv[1] == 'sync':
+ with app.app_context():
+ n, err = do_sync()
+ print(err or f'{n} nieuwe e-mail(s) opgehaald')
+ sys.exit(1 if err else 0)
+ app.run(host=os.environ.get('HOST', '127.0.0.1'), port=int(os.environ.get('PORT', 5000)),
+ debug=bool(os.environ.get('DEBUG')))
diff --git a/importer.py b/importer.py
new file mode 100644
index 0000000..b2551bf
--- /dev/null
+++ b/importer.py
@@ -0,0 +1,130 @@
+"""CSV/JSON inlezen voor de import, en het herkennen van kolomnamen.
+
+CSV: scheidingsteken `;`, `,` of tab (automatisch), UTF-8 met of zonder BOM (of Windows-1252),
+regels die met `#` beginnen worden overgeslagen — zoals de CSV's van de configurator.
+JSON: een lijst met objecten, een object met daarin een lijst (bijv. {"contacten": [...]}) of één object.
+"""
+import csv
+import json
+
+FIELD_ALIASES = {
+ 'name': ['name', 'naam', 'full_name', 'fullname', 'volledige_naam', 'your-name', 'klant', 'klantnaam'],
+ 'first_name': ['first_name', 'firstname', 'voornaam'],
+ 'last_name': ['last_name', 'lastname', 'achternaam'],
+ 'email': ['email', 'e-mail', 'e_mail', 'emailadres', 'e-mailadres', 'mail', 'your-email'],
+ 'phone': ['phone', 'telefoon', 'tel', 'telefoonnummer', 'phone_number', 'mobiel', 'your-phone'],
+ 'company': ['company', 'bedrijf', 'bedrijfsnaam', 'organisatie'],
+ 'relation': ['relatie', 'relation', 'account', 'huishouden', 'familie', 'account_name'],
+ 'relation_role': ['rol', 'role', 'relatie_rol', 'functie'],
+ 'address': ['address', 'adres', 'straat', 'street'],
+ 'postcode': ['postcode', 'zip', 'zipcode', 'postal_code'],
+ 'city': ['city', 'plaats', 'woonplaats', 'stad'],
+ 'message': ['message', 'bericht', 'opmerking', 'opmerkingen', 'vraag', 'comments', 'your-message'],
+ 'source': ['source', 'bron', 'formulier', 'form', 'form_name', 'lead_source'],
+ 'status': ['status', 'fase'],
+ 'value': ['value', 'waarde', 'bedrag', 'budget', 'verwachte_waarde'],
+ 'notes': ['notes', 'notities', 'notitie'],
+}
+
+PRODUCT_ALIASES = {
+ 'name': ['name', 'naam', 'product', 'productnaam', 'titel', 'omschrijving'],
+ 'sku': ['sku', 'code', 'artikelnummer', 'artikelnr', 'artikel'],
+ 'description': ['description', 'beschrijving', 'toelichting', 'details'],
+ 'unit': ['unit', 'eenheid'],
+ 'price': ['price', 'prijs', 'waarde', 'verkoopprijs', 'prijs_excl'],
+ 'vat': ['vat', 'btw', 'btw%', 'btw_percentage'],
+ 'supplier': ['supplier', 'leverancier'],
+ 'active': ['active', 'actief'],
+}
+
+RELATION_ALIASES = {
+ 'name': ['name', 'naam', 'bedrijf', 'bedrijfsnaam', 'relatie', 'organisatie', 'leverancier', 'familie'],
+ 'kind': ['kind', 'soort'],
+ 'type': ['type', 'categorie', 'branche'],
+ 'email': ['email', 'e-mail', 'emailadres', 'e-mailadres', 'mail'],
+ 'phone': ['phone', 'telefoon', 'tel', 'telefoonnummer'],
+ 'website': ['website', 'url', 'site', 'web'],
+ 'address': ['address', 'adres', 'straat'],
+ 'postcode': ['postcode', 'zip', 'zipcode'],
+ 'city': ['city', 'plaats', 'woonplaats', 'vestigingsplaats'],
+ 'kvk': ['kvk', 'kvk-nummer', 'kvk_nummer', 'kvknummer', 'coc'],
+ 'notes': ['notes', 'notities', 'opmerking', 'opmerkingen'],
+}
+
+TARGETS = {
+ 'contacts': [('', '— overslaan —'), ('name', 'Naam'), ('first_name', 'Voornaam'), ('last_name', 'Achternaam'),
+ ('email', 'E-mail'), ('phone', 'Telefoon'), ('company', 'Bedrijf'), ('address', 'Adres'),
+ ('postcode', 'Postcode'), ('city', 'Plaats'), ('source', 'Bron'), ('status', 'Status'),
+ ('value', 'Verwachte waarde'), ('notes', 'Notities'), ('message', 'Bericht (in tijdlijn)'),
+ ('relation', 'Relatie (naam)'), ('relation_role', 'Rol binnen relatie'),
+ ('extra', 'Extra veld (bewaren)')],
+ 'relations': [('', '— overslaan —'), ('name', 'Naam'), ('kind', 'Soort (klant/partner)'), ('type', 'Type'),
+ ('email', 'E-mail'), ('phone', 'Telefoon'), ('website', 'Website'), ('address', 'Adres'),
+ ('postcode', 'Postcode'), ('city', 'Plaats'), ('kvk', 'KvK-nummer'), ('notes', 'Notities')],
+ 'products': [('', '— overslaan —'), ('name', 'Naam'), ('sku', 'Artikelnummer / code'),
+ ('description', 'Omschrijving'), ('unit', 'Eenheid'), ('price', 'Prijs excl. btw'),
+ ('vat', 'Btw %'), ('supplier', 'Leverancier'), ('active', 'Actief (ja/nee)')],
+}
+
+
+class ImportFout(ValueError):
+ pass
+
+
+def _key(name):
+ return str(name).strip().lower().replace(' ', '_')
+
+
+def lookup(aliases):
+ return {alias: field for field, names in aliases.items() for alias in names}
+
+
+def guess(cols, kind):
+ """Stel per kolom een doelveld voor; elk doelveld maar één keer (de eerste kolom wint)."""
+ table = lookup({'contacts': FIELD_ALIASES, 'products': PRODUCT_ALIASES, 'relations': RELATION_ALIASES}[kind])
+ used, mapping = set(), []
+ for col in cols:
+ target = table.get(_key(col), '')
+ if target in used:
+ target = ''
+ if target:
+ used.add(target)
+ mapping.append(target or ('extra' if kind == 'contacts' else ''))
+ return mapping
+
+
+def read(path):
+ """Geef (kolommen, rijen) terug; rijen zijn dicts."""
+ with open(path, 'rb') as f:
+ raw = f.read()
+ try:
+ text = raw.decode('utf-8-sig')
+ except UnicodeDecodeError:
+ text = raw.decode('cp1252')
+
+ if path.endswith('.json'):
+ try:
+ data = json.loads(text)
+ except json.JSONDecodeError as e:
+ raise ImportFout(f'Geen geldige JSON: {e}') from e
+ if isinstance(data, dict):
+ if data.get('type') == 'house-design':
+ raise ImportFout('Dit is een ontwerp uit de configurator. Upload het bij een contact onder Bestanden.')
+ lists = [v for v in data.values() if isinstance(v, list)]
+ data = lists[0] if lists else [data]
+ if not isinstance(data, list):
+ raise ImportFout('Verwacht een lijst met objecten.')
+ rows = [r for r in data if isinstance(r, dict)]
+ cols = []
+ for row in rows:
+ cols += [k for k in row if k not in cols]
+ return cols, rows
+
+ lines = [line for line in text.splitlines() if line.strip() and not line.lstrip().startswith('#')]
+ if not lines:
+ raise ImportFout('Het bestand is leeg.')
+ delimiter = max([';', ',', '\t'], key=lines[0].count)
+ reader = csv.reader(lines, delimiter=delimiter)
+ cols = [c.strip() for c in next(reader)]
+ rows = [dict(zip(cols, (v.strip() for v in r))) for r in reader if any(v.strip() for v in r)]
+ return cols, rows
diff --git a/mail.py b/mail.py
new file mode 100644
index 0000000..83ccea4
--- /dev/null
+++ b/mail.py
@@ -0,0 +1,116 @@
+"""IMAP ophalen en SMTP versturen, alleen met de standaardbibliotheek."""
+import email
+import html
+import imaplib
+import re
+import smtplib
+from datetime import datetime, timedelta
+from email.message import EmailMessage
+from email.policy import default as policy
+from email.utils import formatdate, make_msgid, parseaddr, parsedate_to_datetime
+
+MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
+
+
+def _text(msg):
+ part = msg.get_body(preferencelist=('plain', 'html'))
+ if part is None:
+ return ''
+ try:
+ content = part.get_content()
+ except Exception:
+ content = (part.get_payload(decode=True) or b'').decode('utf-8', 'replace')
+ if part.get_content_type() == 'text/html':
+ content = re.sub(r'(?is)<(script|style|head).*?\1>', '', content)
+ content = re.sub(r'(?i) |
||||', '\n', content)
+ content = html.unescape(re.sub(r'<[^>]+>', '', content))
+ content = re.sub(r'[ \t]+\n', '\n', content)
+ return re.sub(r'\n{3,}', '\n\n', content).strip()
+
+
+def _connect_imap(cfg):
+ host, port = cfg['imap_host'], int(cfg.get('imap_port') or 993)
+ if port == 993:
+ conn = imaplib.IMAP4_SSL(host, port, timeout=30)
+ else:
+ conn = imaplib.IMAP4(host, port, timeout=30)
+ conn.starttls()
+ conn.login(cfg['imap_user'], cfg.get('imap_pass', ''))
+ return conn
+
+
+def fetch(cfg, known, limit=200):
+ """Haal berichten van de laatste `imap_days` dagen op die nog niet in `known` staan."""
+ folder = cfg.get('imap_folder') or 'INBOX'
+ days = int(cfg.get('imap_days') or 30)
+ since = datetime.now() - timedelta(days=days)
+ since_str = f'{since.day:02d}-{MONTHS[since.month - 1]}-{since.year}'
+
+ conn = _connect_imap(cfg)
+ try:
+ typ, _ = conn.select(f'"{folder}"', readonly=True)
+ if typ != 'OK':
+ raise RuntimeError(f'Map "{folder}" niet gevonden')
+ _, data = conn.uid('search', None, 'SINCE', since_str)
+ uids = data[0].split()[-limit:]
+ out = []
+ for uid in uids:
+ key = f'{folder}:{uid.decode()}'
+ if key in known:
+ continue
+ _, msgdata = conn.uid('fetch', uid, '(BODY.PEEK[])')
+ if not msgdata or not isinstance(msgdata[0], tuple):
+ continue
+ msg = email.message_from_bytes(msgdata[0][1], policy=policy)
+ name, addr = parseaddr(str(msg.get('From', '')))
+ try:
+ date = parsedate_to_datetime(msg['Date']).astimezone().strftime('%Y-%m-%d %H:%M:%S')
+ except Exception:
+ date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+ out.append({
+ 'uid': key,
+ 'message_id': str(msg.get('Message-ID', '')),
+ 'from_name': name,
+ 'from_addr': addr.lower(),
+ 'to_addr': str(msg.get('To', '')),
+ 'subject': str(msg.get('Subject', '')) or '(geen onderwerp)',
+ 'body': _text(msg),
+ 'date': date,
+ })
+ return out
+ finally:
+ try:
+ conn.logout()
+ except Exception:
+ pass
+
+
+def send(cfg, to, subject, body, in_reply_to=None):
+ host = cfg.get('smtp_host')
+ if not host:
+ raise RuntimeError('Geen SMTP-server ingesteld')
+ user = cfg.get('smtp_user') or cfg.get('imap_user', '')
+ password = cfg.get('smtp_pass') or cfg.get('imap_pass', '')
+ port = int(cfg.get('smtp_port') or 587)
+
+ msg = EmailMessage()
+ msg['From'] = cfg.get('smtp_from') or user
+ msg['To'] = to
+ msg['Subject'] = subject
+ msg['Date'] = formatdate(localtime=True)
+ msg['Message-ID'] = make_msgid()
+ if in_reply_to:
+ msg['In-Reply-To'] = in_reply_to
+ msg['References'] = in_reply_to
+ msg.set_content(body)
+
+ if port == 465:
+ server = smtplib.SMTP_SSL(host, port, timeout=30)
+ else:
+ server = smtplib.SMTP(host, port, timeout=30)
+ server.starttls()
+ with server:
+ if user:
+ server.login(user, password)
+ server.send_message(msg)
+ return msg['Message-ID']
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..001e7c4
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1 @@
+flask>=3.0
diff --git a/schema.sql b/schema.sql
new file mode 100644
index 0000000..a3e967a
--- /dev/null
+++ b/schema.sql
@@ -0,0 +1,160 @@
+CREATE TABLE IF NOT EXISTS settings (
+ key TEXT PRIMARY KEY,
+ value TEXT
+);
+
+CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ email TEXT NOT NULL UNIQUE COLLATE NOCASE,
+ password_hash TEXT NOT NULL,
+ role TEXT NOT NULL DEFAULT 'user',
+ active INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL
+);
+
+-- Relaties (zoals accounts in EspoCRM): klantbundels (huishouden, familie, bedrijf) en partners
+-- (leverancier, aannemer, ...). Contactpersonen hangen eronder met een rol.
+CREATE TABLE IF NOT EXISTS relations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ kind TEXT NOT NULL DEFAULT 'klant',
+ type TEXT DEFAULT '',
+ name TEXT NOT NULL,
+ email TEXT DEFAULT '',
+ phone TEXT DEFAULT '',
+ website TEXT DEFAULT '',
+ address TEXT DEFAULT '',
+ postcode TEXT DEFAULT '',
+ city TEXT DEFAULT '',
+ kvk TEXT DEFAULT '',
+ notes TEXT DEFAULT '',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_relations_name ON relations(kind, name COLLATE NOCASE);
+
+CREATE TABLE IF NOT EXISTS contacts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL DEFAULT '',
+ email TEXT DEFAULT '',
+ phone TEXT DEFAULT '',
+ company TEXT DEFAULT '',
+ address TEXT DEFAULT '',
+ postcode TEXT DEFAULT '',
+ city TEXT DEFAULT '',
+ type TEXT NOT NULL DEFAULT 'lead',
+ status TEXT NOT NULL DEFAULT 'nieuw',
+ source TEXT DEFAULT '',
+ value REAL NOT NULL DEFAULT 0,
+ assigned_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ relation_id INTEGER REFERENCES relations(id) ON DELETE SET NULL,
+ relation_role TEXT DEFAULT '',
+ portal_token TEXT,
+ portal_password_hash TEXT,
+ portal_last_login TEXT,
+ notes TEXT DEFAULT '',
+ extra TEXT DEFAULT '{}',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email COLLATE NOCASE);
+
+CREATE TABLE IF NOT EXISTS activities (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ contact_id INTEGER NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
+ type TEXT NOT NULL,
+ body TEXT DEFAULT '',
+ user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ created_at TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS files (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE,
+ stored TEXT NOT NULL,
+ original TEXT NOT NULL,
+ mime TEXT DEFAULT '',
+ size INTEGER DEFAULT 0,
+ created_at TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS products (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ sku TEXT DEFAULT '',
+ description TEXT DEFAULT '',
+ unit TEXT DEFAULT 'stuk',
+ supplier TEXT DEFAULT '',
+ supplier_id INTEGER REFERENCES relations(id) ON DELETE SET NULL,
+ price REAL NOT NULL DEFAULT 0,
+ vat REAL NOT NULL DEFAULT 21,
+ image TEXT DEFAULT '',
+ active INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL,
+ updated_at TEXT
+);
+
+-- Elke wijziging aan een product (handmatig, import of API), zodat prijsmutaties terug te zien zijn.
+CREATE TABLE IF NOT EXISTS product_history (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
+ field TEXT NOT NULL,
+ old_value TEXT,
+ new_value TEXT,
+ source TEXT DEFAULT '',
+ user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ created_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_product_history ON product_history(product_id, created_at);
+
+CREATE TABLE IF NOT EXISTS quotes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ number TEXT UNIQUE NOT NULL,
+ contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
+ status TEXT NOT NULL DEFAULT 'concept',
+ title TEXT DEFAULT '',
+ date TEXT NOT NULL,
+ valid_until TEXT,
+ intro TEXT DEFAULT '',
+ notes TEXT DEFAULT '',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS quote_lines (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ quote_id INTEGER NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
+ position INTEGER NOT NULL DEFAULT 0,
+ product_id INTEGER,
+ description TEXT NOT NULL DEFAULT '',
+ qty REAL NOT NULL DEFAULT 1,
+ unit TEXT DEFAULT '',
+ price REAL NOT NULL DEFAULT 0,
+ vat REAL NOT NULL DEFAULT 21
+);
+
+CREATE TABLE IF NOT EXISTS emails (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ uid TEXT UNIQUE,
+ message_id TEXT DEFAULT '',
+ direction TEXT NOT NULL DEFAULT 'in',
+ contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
+ from_name TEXT DEFAULT '',
+ from_addr TEXT DEFAULT '',
+ to_addr TEXT DEFAULT '',
+ subject TEXT DEFAULT '',
+ body TEXT DEFAULT '',
+ date TEXT NOT NULL,
+ is_read INTEGER NOT NULL DEFAULT 0
+);
+CREATE INDEX IF NOT EXISTS idx_emails_from ON emails(from_addr);
+
+CREATE TABLE IF NOT EXISTS tasks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE,
+ user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ title TEXT NOT NULL,
+ due_date TEXT,
+ done INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL
+);
diff --git a/static/fx.js b/static/fx.js
new file mode 100644
index 0000000..c99e1ba
--- /dev/null
+++ b/static/fx.js
@@ -0,0 +1,62 @@
+/* Effecten met GSAP, volgens de motion-regels van de Van Emous-huisstijl:
+ tekst fadet 28px omhoog, uitvoer cubic-bezier(0.22, 1, 0.36, 1), alles uit bij prefers-reduced-motion,
+ en na 3 s is alles hoe dan ook zichtbaar (zie de fx-wait-klasse in base.html). */
+(() => {
+ const root = document.documentElement;
+ const reveal = () => root.classList.remove('fx-wait');
+ if (!window.gsap || matchMedia('(prefers-reduced-motion: reduce)').matches) return reveal();
+
+ gsap.registerEase?.('vanEmous', p => {
+ // cubic-bezier(0.22, 1, 0.36, 1) benaderd: zachte, lange uitloop
+ return 1 - Math.pow(1 - p, 4);
+ });
+ const ease = 'vanEmous';
+ const $$ = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel));
+ const tl = gsap.timeline({ defaults: { ease, duration: 0.7 } });
+
+ // paginakop en meldingen
+ const head = document.querySelector('.head');
+ if (head) tl.from(head.children, { y: 28, autoAlpha: 0, stagger: 0.08 }, 0);
+ tl.from($$('.flash'), { y: -12, autoAlpha: 0, duration: 0.5 }, 0.05);
+
+ // tegels, kaarten en filters in een golf
+ const blocks = $$('.kpi, .filters, main .card, .portal-main .card, .auth .card, .auth > img');
+ tl.from(blocks.slice(0, 18), { y: 24, autoAlpha: 0, stagger: 0.05, clearProps: 'transform' }, 0.1);
+
+ // tabelrijen kort na hun kaart (alleen de eerste, de rest staat er meteen)
+ $$('.card tbody').forEach(tbody => {
+ const rows = $$(':scope > tr', tbody).slice(0, 20);
+ if (rows.length) tl.from(rows, { x: -8, autoAlpha: 0, duration: 0.45, stagger: 0.025 }, 0.3);
+ });
+ tl.from($$('.timeline li').slice(0, 12), { y: 12, autoAlpha: 0, duration: 0.5, stagger: 0.04 }, 0.3);
+ tl.from($$('.file, .member, .task').slice(0, 24), { y: 12, autoAlpha: 0, duration: 0.5, stagger: 0.03 }, 0.3);
+
+ // balkjes groeien vanaf links / onder
+ tl.from($$('.bar .fill'), { scaleX: 0, transformOrigin: '0 50%', duration: 1.1, stagger: 0.06 }, 0.35);
+ tl.from($$('.cols .c'), { scaleY: 0, transformOrigin: '50% 100%', duration: 0.9, stagger: 0.04 }, 0.35);
+
+ // kerncijfers tellen op (gehele getallen en eurobedragen)
+ const nl = (n, dec) => n.toLocaleString('nl-NL', { minimumFractionDigits: dec, maximumFractionDigits: dec });
+ $$('.kpi .value').forEach(el => {
+ const text = el.textContent.trim();
+ const euro = /^€\s?[\d.]+,\d{2}$/.test(text);
+ if (!euro && !/^\d+$/.test(text)) return;
+ const target = parseFloat(text.replace(/[€\s.]/g, '').replace(',', '.'));
+ if (!target) return;
+ const counter = { v: 0 };
+ tl.to(counter, {
+ v: target, duration: 1.2, ease: 'power2.out',
+ onUpdate: () => { el.textContent = euro ? '€ ' + nl(counter.v, 2) : nl(Math.round(counter.v), 0); },
+ onComplete: () => { el.textContent = text; },
+ }, 0.25);
+ });
+
+ reveal();
+ setTimeout(() => tl.progress(1), 3000);
+
+ // nieuwe offerteregel en geopende dialoog laten invliegen
+ document.addEventListener('crm:row', e => gsap.from(e.detail, { y: -8, autoAlpha: 0, duration: 0.4, ease }));
+ $$('dialog').forEach(d => new MutationObserver(() => {
+ if (d.open) gsap.fromTo(d, { y: 16, autoAlpha: 0, scale: 0.98 }, { y: 0, autoAlpha: 1, scale: 1, duration: 0.45, ease });
+ }).observe(d, { attributes: true, attributeFilter: ['open'] }));
+})();
diff --git a/static/logo.svg b/static/logo.svg
new file mode 100644
index 0000000..323f8c4
--- /dev/null
+++ b/static/logo.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..c7cd546
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,215 @@
+/* Van Emous huisstijl — tokens uit het design system */
+:root {
+ --orange: #b95913;
+ --orange-dark: #a24e11;
+ --orange-tint: rgba(185, 89, 19, 0.1);
+ --orange-tint-soft: rgba(185, 89, 19, 0.06);
+ --orange-line: rgba(185, 89, 19, 0.35);
+ --orange-focus: rgba(185, 89, 19, 0.25);
+ --ink: #212121;
+ --ink-muted: #5c6670;
+ --white: #ffffff;
+ --ultra-light: #f9faf5;
+ --light: #f0f1ec;
+ --blue: #dce4ee;
+ --blue-light: #f1f4f8;
+ --blue-dark: #a8bbd5;
+ --line: rgba(0, 0, 0, 0.08);
+ --control: #8f969c;
+ --font-body: "Mukta", system-ui, -apple-system, sans-serif;
+ --font-heading: "Kantumruy Pro", sans-serif;
+ --radius-sm: 0.5rem;
+ --radius-md: 1rem;
+ --radius-lg: 1.25rem;
+ --radius-pill: 50px;
+}
+
+* { 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; }
+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:hover { text-decoration: underline; }
+a:focus-visible, button:focus-visible, .btn:focus-visible { outline: 3px solid var(--orange); outline-offset: 2px; }
+
+/* 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; }
+.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:hover { background: var(--orange-tint-soft); text-decoration: none; }
+.side nav a.active { background: var(--orange); color: var(--white); }
+.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; }
+.head { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
+.head h1 { margin: 0; }
+.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; }
+.hint { font-size: 0.875rem; color: var(--ink-muted); margin: 0.3rem 0 0; }
+
+/* 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; }
+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; }
+
+.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); }
+.chip.gewonnen, .chip.geaccepteerd { background: var(--orange); color: var(--white); }
+.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: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; }
+
+.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 */
+.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; }
+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 .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; }
+.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; }
+.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 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; }
+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; }
+}
+
+/* taken */
+.task { display: flex; gap: 0.75rem; align-items: flex-start; padding: 0.6rem 0; border-bottom: 1px solid var(--line); }
+.task:last-of-type { border-bottom: 0; }
+.task.done > div:first-of-type, .task.done .title { text-decoration: line-through; color: var(--ink-muted); }
+.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; }
+.portal-top img { width: 160px; display: block; }
+.portal-main { padding: 2rem 16px; width: 100%; }
+body.portal { flex-direction: column; }
+
+/* dialoog */
+.dialog { border: 0; border-radius: var(--radius-lg); padding: 1.75rem; width: min(480px, calc(100vw - 32px)); box-shadow: var(--shadow-menu, 0 10px 30px rgba(0,0,0,.1)); }
+.dialog::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; }
+.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); }
+.file:hover img { transform: scale(1.05); }
+.btn:hover { transform: translateY(-1px); }
+.btn:active { transform: translateY(0); }
+.side nav a:not(.active):hover { transform: translateX(4px); }
+a.bar:hover .fill { background: var(--orange-dark); }
+tr.link td { transition: background-color 0.2s; }
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { transition: none !important; animation: none !important; }
+}
diff --git a/templates/account.html b/templates/account.html
new file mode 100644
index 0000000..6884d47
--- /dev/null
+++ b/templates/account.html
@@ -0,0 +1,13 @@
+{% extends 'base.html' %}
+{% block title %}Mijn account{% endblock %}
+{% block content %}
+{{ ROLE.get(g.user.role) }}
{{ g.user.name }} {{ g.user.email }}
+
+{% endblock %}
diff --git a/templates/auth.html b/templates/auth.html
new file mode 100644
index 0000000..54da680
--- /dev/null
+++ b/templates/auth.html
@@ -0,0 +1,33 @@
+{% extends 'base.html' %}
+{% set titles = {'setup': 'Welkom', 'login': 'Inloggen', 'portal_login': 'Klantportaal', 'invite': 'Wachtwoord instellen'} %}
+{% block title %}{{ titles[mode] }}{% endblock %}
+{% block body %}
+
+
+ {% with msgs = get_flashed_messages(with_categories=true) %}
+ {% for cat, m in msgs %}
{{ m }}
{% endfor %}
+ {% endwith %}
+
+
+{% endblock %}
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 0000000..02aac00
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,54 @@
+
+
+
+
+
+ {% block title %}CRM{% endblock %} · Van Emous CRM
+
+
+
+
+
+
+
+
+
+{% block body %}
+{% set ep = request.endpoint or '' %}
+
+
+ {% with msgs = get_flashed_messages(with_categories=true) %}
+ {% for cat, m in msgs %}{{ m }}
{% endfor %}
+ {% endwith %}
+ {% block content %}{% endblock %}
+
+{% endblock %}
+
+
diff --git a/templates/contact.html b/templates/contact.html
new file mode 100644
index 0000000..6d971cf
--- /dev/null
+++ b/templates/contact.html
@@ -0,0 +1,232 @@
+{% extends 'base.html' %}
+{% block title %}{{ c.name }}{% if c.type == 'lead' %}
+
+
+
+{% endif %}
+{% endblock %}
+{% block content %}
+
+
+
← Contacten
+
{{ c.name }}
+
{{ TYPE.get(c.type) }}
+ {% if c.relation_id %}
{{ c.relation_name }} {% if c.relation_role %} ({{ c.relation_role }}){% endif %} · {% endif %}{{ [c.company, c.city]|select|join(' · ') }}
+
+
+
+ {% if c.type == 'lead' %}
+
Omzetten naar klant
+ {% endif %}
+
+
Bewerken
+
+
+
+
+
+
+
Taken
+ {% for t in tasks %}
+
+ {% endfor %}
+
+
+
+
+
+ {% if c.email %}
+
+
+ E-mail sturen
+
+
+
+
Aan
+
Onderwerp
+
Bericht
+
+ Versturen
+
+
+
+ {% endif %}
+
+
+
+ {% if emails %}
+
+
E-mail
+
+
+ {% for e in emails %}
+
+ {{ 'Ontvangen' if e.direction == 'in' else 'Verstuurd' }}
+ {{ e.subject }}
+ {{ e.date|dt }}
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+
+
+
+
Gegevens
+
+ Status {{ STATUS.get(c.status, c.status) }}
+ Toegewezen {{ c.user_name or '—' }}
+ {% if c.relation_id %}Relatie {{ c.relation_name }} {{ RKIND.get(c.relation_kind) }}{% if c.relation_type %} · {{ c.relation_type }}{% endif %}
+ {% for h in housemates %}{{ h.name }} {% if h.relation_role %} ({{ h.relation_role }}) {% endif %}{% endfor %} {% endif %}
+ {% if c.value %}Verwachte waarde {{ c.value|euro }} {% endif %}
+ {% if c.email %}E-mail {{ c.email }} {% endif %}
+ {% if c.phone %}Telefoon {{ c.phone }} {% endif %}
+ {% if c.company %}Bedrijf {{ c.company }} {% endif %}
+ {% if c.address or c.city %}Adres {{ c.address }}{% if c.address %}
+{% endif %}{{ c.postcode }} {{ c.city }} {% endif %}
+ {% if c.source %}Bron {{ c.source }} {% endif %}
+ Aangemaakt {{ c.created_at|dt }}
+
+ {% if c.notes %}
Notities {{ c.notes }}
{% endif %}
+ {% if extra %}
+
Formuliervelden
+
+ {% for k, v in extra.items() %}{{ k }} {{ v if v is string else v|tojson }} {% endfor %}
+
+ {% endif %}
+
+
+
+
Bestanden
+ {% if files %}
+
+ {% for f in files %}
+
+ {% endfor %}
+
+ {% endif %}
+
+
+ Uploaden
+
+
+
+
+
Offertes
+ {% for qt in quotes %}
+
{{ qt.number }} {{ QSTATUS.get(qt.status) }}
+ {{ qt.date|dt('%d-%m-%Y') }} · {{ qt.total|euro }} incl. btw
+ {% else %}
+
Nog geen offertes.
+ {% endfor %}
+
+
+
+
Klantportaal
+ {% if c.portal_password_hash %}
+
Actief{% if c.portal_last_login %} · laatst ingelogd {{ c.portal_last_login|dt }}{% endif %}
+ {% elif portal_link %}
+
Uitnodiging klaar, nog niet geactiveerd:
+
{{ portal_link }}
+ {% else %}
+
De klant kan via het portaal offertes bekijken en accepteren, en bestanden en berichten sturen.
+ {% endif %}
+
+ {% if portal_link and c.email %}Mail uitnodiging {% endif %}
+ {% if not c.portal_password_hash %}{{ 'Nieuwe link' if portal_link else 'Uitnodiging maken' }} {% endif %}
+ {% if c.portal_password_hash or portal_link %}Toegang intrekken {% endif %}
+
+
+
+
+ Contact verwijderen
+
+
+
+{% if c.type == 'lead' %}
+
+
+ Omzetten naar klant
+ De contactpersoon komt in een klantrelatie, bijvoorbeeld een huishouden of familie.
+ Nieuwe klantrelatie
+
+ {% if klant_relations %}
+ Bestaande klantrelatie
+
+ {% for r in klant_relations %}{{ r.name }}{% if r.type %} · {{ r.type }}{% endif %} {% endfor %}
+
+ {% endif %}
+
+ Omzetten
+ Annuleren
+
+
+
+{% endif %}
+{% endblock %}
diff --git a/templates/contact_form.html b/templates/contact_form.html
new file mode 100644
index 0000000..f5ea870
--- /dev/null
+++ b/templates/contact_form.html
@@ -0,0 +1,56 @@
+{% extends 'base.html' %}
+{% block title %}{{ 'Nieuw contact' if new else c.name }}{% endblock %}
+{% block content %}
+
+
+
← Terug
+
{{ ('Nieuwe ' ~ TYPE.get(c.type, 'lead')|lower) if new else 'Contact bewerken' }}
+
+
+
+
+
Naam *
+
Bedrijf
+
E-mail
+
Telefoon
+
Adres
+
Postcode
+
Plaats
+
Status
+
+ {% for k, l in STATUSES %}{{ l }} {% endfor %}
+
+
+
Soort
+
+ {% for k, l in TYPES %}{{ l }} {% endfor %}
+
+
+
Toegewezen aan
+
+ — Niemand —
+ {% for u in USERS %}{{ u.name }} {% endfor %}
+
+
+
Relatie
+
+ — Geen —
+ {% for kind, label in RELATION_KINDS %}
+
+ {% for r in relations if r.kind == kind %}{{ r.name }}{% if r.type %} · {{ r.type }}{% endif %} {% endfor %}
+
+ {% endfor %}
+
+
Huishouden, familie of bedrijf van de klant, of de partner waar deze persoon werkt.
+
+
Rol binnen relatie
+
Verwachte waarde (€)
+
Bron
+
Vaste notities {{ c.notes }}
+
+
+
+{% endblock %}
diff --git a/templates/contacts.html b/templates/contacts.html
new file mode 100644
index 0000000..0f9bc1d
--- /dev/null
+++ b/templates/contacts.html
@@ -0,0 +1,54 @@
+{% extends 'base.html' %}
+{% block title %}Contacten{% endblock %}
+{% block content %}
+
+
{{ 'Verkoopkansen' if a.type == 'lead' else 'Relaties' }}
{{ {'lead': 'Leads', 'klant': 'Klantcontacten', 'partner': 'Partnercontacten'}.get(a.type, 'Contactpersonen') }}
+
+
+
+{% if a.type != 'lead' %}
+
+{% endif %}
+
+
+
+ {% if rows %}
+
+ Naam Relatie Telefoon Plaats Bron Toegewezen Status Aangemaakt
+
+ {% for c in rows %}
+
+ {{ c.name }} {{ c.email }}
+ {% if c.relation_id %}{{ c.relation_name }} {% if c.relation_role %}{{ c.relation_role }}
{% endif %}{% else %}{{ c.company }}{% endif %}
+ {{ c.phone }}
+ {{ c.city }}
+ {{ c.source }}
+ {{ c.user_name or '—' }}
+ {% if not a.type %}{{ TYPE.get(c.type) }} {% endif %}{{ STATUS.get(c.status, c.status) }}
+ {{ c.created_at|dt('%d-%m-%Y') }}
+
+ {% endfor %}
+
+
+ {% else %}
Geen contacten gevonden.
{% endif %}
+
+{% endblock %}
diff --git a/templates/dashboard.html b/templates/dashboard.html
new file mode 100644
index 0000000..b72c24b
--- /dev/null
+++ b/templates/dashboard.html
@@ -0,0 +1,103 @@
+{% extends 'base.html' %}
+{% block title %}Dashboard{% endblock %}
+{% block content %}
+
+
+
+
Nieuwe leads · 30 dagen
{{ stats.new30 }}
{{ stats.customers }} klantrelaties · {{ stats.partners }} partners
+
Open leads
{{ stats.open_leads }}
+
+
Open offertes
{{ stats.open_quotes }}
{{ stats.open_value|euro }} excl. btw
+
Gewonnen · 30 dagen
{{ stats.won_value|euro }}
excl. btw
+
+
+
+
+
+
Leads per week
+
+ {% for wk, n in weekly %}
+
+ {{ n or '' }}
+
+ {{ wk }}
+
+ {% endfor %}
+
+
+
+
Bronnen
+ {% if sources %}
+
+ {% for s in sources %}
+
+ {{ s.s }}
+
+ {{ s.n }}
+
+ {% endfor %}
+
+ {% else %}
Nog geen leads.
{% endif %}
+
+
+
+
+
+
Laatste leads
+ {% if recent %}
+
+ Naam Bron Toegewezen Status Binnen
+
+ {% for c in recent %}
+
+ {{ c.name }} {{ c.email }}
+ {{ c.source }}
+ {{ c.user_name or '—' }}
+ {{ STATUS.get(c.status, c.status) }}
+ {{ c.created_at|dt }}
+
+ {% endfor %}
+
+
+ {% else %}
Nog geen contacten. Voeg er een toe of koppel een formulier via Instellingen.
{% endif %}
+
+
+
+
Mijn taken
+ {% for t in tasks %}
+
+
+ {{ t.title }}
+
{% if t.contact_id %}
{{ t.contact_name }} {% endif %}
+ {% if t.due_date %}·
{{ t.due_date|dt('%d-%m-%Y') }} {% endif %}
+
+ {% else %}
+
Geen openstaande taken.
+ {% endfor %}
+
+
+
Ongelezen e-mail
+ {% for e in mails %}
+
{{ e.subject }} {{ e.from_name or e.from_addr }} · {{ e.date|dt }}
+ {% else %}
+
Geen ongelezen berichten.
+ {% endfor %}
+
Naar inbox
+
+
+
+{% endblock %}
diff --git a/templates/email.html b/templates/email.html
new file mode 100644
index 0000000..a4ba2f5
--- /dev/null
+++ b/templates/email.html
@@ -0,0 +1,39 @@
+{% extends 'base.html' %}
+{% block title %}{{ e.subject }}{% endblock %}
+{% block content %}
+
+
+
← Inbox
+
{{ e.subject }}
+
+ {% if e.direction == 'in' %}Van {{ e.from_name }} <{{ e.from_addr }}>{% else %}Aan {{ e.to_addr }}{% endif %}
+ · {{ e.date|dt }}
+
+
+
+
+
+
+
+{% if e.direction == 'in' %}
+
+
Beantwoorden
+
+
+ {% if contact %} {% endif %}
+
+
Aan
+
Onderwerp
+
Bericht
+
+ Versturen
+
+
+{% endif %}
+{% endblock %}
diff --git a/templates/import.html b/templates/import.html
new file mode 100644
index 0000000..4321d8c
--- /dev/null
+++ b/templates/import.html
@@ -0,0 +1,28 @@
+{% extends 'base.html' %}
+{% block title %}Importeren{% endblock %}
+{% block content %}
+
+
+
+ Bestand kiezen
+
+
Wat importeer je?
+
+ Leads / klanten
+ Relaties (klanten of partners)
+ Producten
+
+
+
CSV- of JSON-bestand
+
+ Volgende: kolommen controleren
+
+
+
Formaten
+
CSV : scheidingsteken ;, , of tab, de eerste regel met kolomnamen. Regels die beginnen met # worden overgeslagen. Bedragen als 6,00, 1.234,50 of € 6.
+
JSON : een lijst met objecten, of een object met een lijst erin, zoals {"leads": [{"naam": "…", "email": "…"}]}.
+
Configurator : kosten/leveranciers.csv en intern.csv worden als producten herkend (code, omschrijving, eenheid, prijs, leverancier). Een woningontwerp (house-design-JSON) upload je bij een contact onder Bestanden.
+
Je ziet eerst een voorbeeld en kiest per kolom waar die heen moet. Bestaande contacten (zelfde e-mail) en producten (zelfde SKU) worden bijgewerkt in plaats van dubbel aangemaakt. Elke productwijziging komt in de mutatiehistorie; offertes houden hun eigen prijs.
+
+
+{% endblock %}
diff --git a/templates/import_preview.html b/templates/import_preview.html
new file mode 100644
index 0000000..4504f7a
--- /dev/null
+++ b/templates/import_preview.html
@@ -0,0 +1,59 @@
+{% extends 'base.html' %}
+{% block title %}Import controleren{% endblock %}
+{% block content %}
+
+
+
+
← Ander bestand
+
{{ filename }}
+
{{ total }} rij(en) · {{ {'contacts': 'leads / contactpersonen', 'relations': 'relaties', 'products': 'producten'}[kind] }}
+
{{ total }} rij(en) importeren
+
+
+
+
Kolommen koppelen
+
+
+ {% for col in cols %}
+
+ {{ col }}
+
+ {% for k, l in targets %}{{ l }} {% endfor %}
+
+
+ {% endfor %}
+
+
+ {% for row in rows %}
+ {% for col in cols %}{% set v = row.get(col) %}{{ (v if v is string else (v|tojson if v is not none else ''))|truncate(60) }} {% endfor %}
+ {% endfor %}
+
+
+ {% if total > rows|length %}
Eerste {{ rows|length }} van {{ total }} rijen.
{% endif %}
+
+
+
+
Opties
+
+ {% if kind == 'contacts' %}
+
Importeren als {% for k, l in TYPES %}{{ l }} {% endfor %}
+
Status (als de kolom ontbreekt) {% for k, l in STATUSES %}{{ l }} {% endfor %}
+
Toewijzen aan — Niemand — {% for u in USERS %}{{ u.name }} {% endfor %}
+
Bron (als de kolom ontbreekt)
+ {% endif %}
+ {% if kind == 'relations' %}
+
Soort {% for k, l in RELATION_KINDS %}{{ l }} {% endfor %}
+
Type (als de kolom ontbreekt)
+ —
+ {% for k, l in RELATION_KINDS %}{% for t in relation_types[k] %}{{ t }} {% endfor %} {% endfor %}
+
+ {% endif %}
+ {% if kind == 'products' %}
+
+ {% endif %}
+
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
+
+
+
+{% endblock %}
diff --git a/templates/inbox.html b/templates/inbox.html
new file mode 100644
index 0000000..24c7f42
--- /dev/null
+++ b/templates/inbox.html
@@ -0,0 +1,39 @@
+{% extends 'base.html' %}
+{% block title %}Inbox{% endblock %}
+{% block content %}
+
+
+
+ {% if last_sync %}Laatst opgehaald: {{ last_sync|dt }} {% endif %}
+
Nieuwe e-mail ophalen
+
+
+
+{% if not configured %}
+ Koppel eerst je mailbox bij
Instellingen (IMAP voor ontvangen, SMTP voor versturen).
+{% endif %}
+
+
+
+
+ {% if rows %}
+
+ Van Onderwerp Contact Datum
+
+ {% for e in rows %}
+
+ {% if not e.is_read %} {% endif %}{{ e.from_name or e.from_addr }}
+ {{ e.subject }}
+ {{ e.contact_name or '' }}
+ {{ e.date|dt }}
+
+ {% endfor %}
+
+
+ {% else %}
Geen berichten.
{% endif %}
+
+{% endblock %}
diff --git a/templates/portal.html b/templates/portal.html
new file mode 100644
index 0000000..a799fbe
--- /dev/null
+++ b/templates/portal.html
@@ -0,0 +1,74 @@
+{% extends 'base.html' %}
+{% block title %}Klantportaal{% endblock %}
+{% block body %}
+
+
+
+ {% with msgs = get_flashed_messages(with_categories=true) %}
+ {% for cat, m in msgs %}
{{ m }}
{% endfor %}
+ {% endwith %}
+
Klantportaal
Welkom, {{ c.name }}
+
+
+
Uw offertes
+ {% if quotes %}
+
+ Nummer Onderwerp Datum Status Totaal incl. btw
+
+ {% for qt in quotes %}
+
+ {{ qt.number }}
+ {{ qt.title }}
+ {{ qt.date|dt('%d-%m-%Y') }}
+ {{ 'Wacht op uw reactie' if qt.status == 'verzonden' else QSTATUS.get(qt.status) }}
+ {{ qt.total|euro }}
+
+ {% endfor %}
+
+
+ {% else %}
Er staan nog geen offertes voor u klaar.
{% endif %}
+
+
+
+
+
Bestanden
+ {% if files %}
+
+ {% for f in files %}
+
+ {% endfor %}
+
+ {% endif %}
+
+ Foto's of documenten delen (bijv. van uw tuin of tekeningen)
+
+ Uploaden
+
+
+
+
Bericht sturen
+
+
+ Versturen
+
+ {% for m in messages %}
+
{{ m.created_at|dt }}{{ m.body[8:] }}
+ {% endfor %}
+
+
+ {% if s.company_phone or s.company_email %}
+
Vragen? Neem contact op: {{ [s.company_phone, s.company_email]|select|join(' · ') }}
+ {% endif %}
+
+{% endblock %}
diff --git a/templates/product_form.html b/templates/product_form.html
new file mode 100644
index 0000000..628e294
--- /dev/null
+++ b/templates/product_form.html
@@ -0,0 +1,64 @@
+{% extends 'base.html' %}
+{% block title %}{{ p.name or 'Nieuw product' }}{% endblock %}
+{% block content %}
+
+
← Producten {{ 'Product bewerken' if p.id else 'Nieuw product' }}
+
+
+
+
Naam *
+
+
Leverancier
+
{% for r in suppliers %}{% endfor %}
+
{% if p.supplier_id %}Naar partner · {% endif %}Een nieuwe naam wordt een partner van het type Leverancier.
+
Eenheid
+
Prijs excl. btw (€)
+
Btw %
+
+ {% for v in [21, 9, 0] %}{{ v }}% {% endfor %}
+
+
+
Omschrijving {{ p.description }}
+
Wordt standaard onder de productnaam op de offerte gezet.
+
+
Afbeelding
+ {% if p.image %}
{% endif %}
+
+
+
Actief (beschikbaar in offertes)
+
+
+
+{% if p.id %}
+
+
Mutaties
+
Bestaande offertes houden hun eigen prijs; wijzigingen gelden voor nieuwe offerteregels.{% if used %} Gebruikt in {{ used }} offerte(s).{% endif %}
+ {% if history %}
+
+ Datum Veld Oud Nieuw Bron
+
+ {% for h in history %}
+
+ {{ h.created_at|dt }}
+ {{ LABELS.get(h.field, h.field) }}
+ {% if h.field == 'price' %}{{ h.old_value|float|euro }} {{ h.new_value|float|euro }}
+ {% set diff = h.new_value|float - h.old_value|float %}{% if h.old_value|float %}({{ '+' if diff > 0 }}{{ (diff / h.old_value|float * 100)|round(1)|nr }}%) {% endif %}
+ {% elif h.field == 'created' %}{{ h.new_value|float|euro }}
+ {% elif h.field == 'active' %}{{ 'ja' if h.old_value == '1' else 'nee' }} {{ 'ja' if h.new_value == '1' else 'nee' }}
+ {% else %}{{ h.old_value|truncate(60) }} {{ h.new_value|truncate(60) }} {% endif %}
+ {{ h.source }}{% if h.user_name %} · {{ h.user_name }}{% endif %}
+
+ {% endfor %}
+
+
+ {% else %}
Nog geen mutaties.
{% endif %}
+
+
+ {{ 'Archiveren' if used else 'Product verwijderen' }}
+
+{% endif %}
+{% endblock %}
diff --git a/templates/products.html b/templates/products.html
new file mode 100644
index 0000000..40bd24a
--- /dev/null
+++ b/templates/products.html
@@ -0,0 +1,44 @@
+{% extends 'base.html' %}
+{% block title %}Producten{% endblock %}
+{% block content %}
+
+
+ {% for k, l in [('active', 'Actief'), ('inactive', 'Inactief'), ('all', 'Alle')] %}
+
{{ l }}
+ {% endfor %}
+
{{ rows|length }} product(en)
+
+
+ {% if rows %}
+
+ Naam SKU Leverancier Eenheid Prijs excl. Btw Bijgewerkt Status
+
+ {% for p in rows %}
+
+ {% if p.image %} {% else %} {% endif %}
+ {{ p.name }} {{ p.description|truncate(90) }}
+ {{ p.sku }}
+ {% if p.supplier_id %}{{ p.supplier }} {% else %}{{ p.supplier }}{% endif %}
+ {{ p.unit }}
+ {{ p.price|euro }}
+ {{ p.vat|nr }}%
+ {{ (p.updated_at or p.created_at)|dt('%d-%m-%Y') }}
+ {% if not p.active %}Inactief {% endif %}
+
+ {% endfor %}
+
+
+ {% else %}
{{ 'Geen producten gevonden.' if term or show != 'active' else 'Nog geen producten. Voeg ze toe of importeer een CSV; daarna kies je ze in offertes.' }}
{% endif %}
+
+{% endblock %}
diff --git a/templates/quote_edit.html b/templates/quote_edit.html
new file mode 100644
index 0000000..5973e46
--- /dev/null
+++ b/templates/quote_edit.html
@@ -0,0 +1,125 @@
+{% extends 'base.html' %}
+{% block title %}Offerte {{ qt.number }}{% endblock %}
+{% macro row(l) %}
+
+
+
+ Vrije regel
+ {% for p in products %}
+ {{ p.name }}
+ {% endfor %}
+
+
+ {{ l.description }}
+
+
+
+ {% if l.product_id and prices.get(l.product_id) is not none and prices[l.product_id] != l.price %}Actueel: {{ prices[l.product_id]|euro }}
{% endif %}
+ {% for v in [21, 9, 0] %}{{ v }}% {% endfor %}
+
+ ✕
+
+{% endmacro %}
+{% block content %}
+
+
+
+
+ Opslaan en afdrukken
+ Opslaan
+
+
+
+
+
+
Klant
+
+ — Kies een contact —
+ {% for c in contacts %}{{ c.name }}{% if c.company %} ({{ c.company }}){% endif %} {% endfor %}
+
+
+
Status
+
{% for k, l in QUOTE_STATUSES %}{{ l }} {% endfor %}
+
Op "Verzonden" zetten = zichtbaar in het klantportaal.
+
+
Datum
+
Geldig tot
+
Onderwerp
+
Inleiding {{ qt.intro }}
+
+
+
+
+
Regels
+
+ Product Omschrijving Aantal Eenheid Prijs excl. Btw Totaal
+ {% for l in lines %}{{ row(l) }}{% endfor %}
+
+
Regel toevoegen
+
+
+
+
+ Opmerkingen en voorwaarden
+ {{ qt.notes }}
+
+
+
+
+ Offerte verwijderen
+
+
+{{ row({}) }}
+
+{% endblock %}
diff --git a/templates/quote_print.html b/templates/quote_print.html
new file mode 100644
index 0000000..e5e4627
--- /dev/null
+++ b/templates/quote_print.html
@@ -0,0 +1,129 @@
+
+
+
+
+
+ Offerte {{ qt.number }}{% if c %} · {{ c.name }}{% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ s.company_name }}
+ {{ [s.company_address, s.company_postcode_city, s.company_phone, s.company_email, s.company_website]|select|join(' '|safe) }}
+
+
+
+
+
+
Offerte{% if qt.title %}: {{ qt.title }}{% endif %}
+ {% if qt.intro %}
{{ qt.intro }}
{% endif %}
+
+
+ Omschrijving Aantal Prijs Btw Totaal
+
+ {% for l in lines %}
+
+ {{ l.description }}
+ {{ l.qty|nr }} {{ l.unit }}
+ {{ l.price|euro }}
+ {{ l.vat|nr }}%
+ {{ (l.qty * l.price)|euro }}
+
+ {% endfor %}
+
+
+
+
+ Subtotaal excl. btw {{ t.subtotal|euro }}
+ {% for rate, amount in t.vat %}Btw {{ rate|nr }}% {{ amount|euro }} {% endfor %}
+ Totaal {{ t.total|euro }}
+
+
+ {% if qt.notes %}
Opmerkingen en voorwaarden
{{ qt.notes }}
{% endif %}
+
+
Datum en handtekening opdrachtgever
Namens {{ s.company_name }}
+
+
+ {% if s.quote_footer %}{{ s.quote_footer }}
{% endif %}
+ {{ [('KvK ' ~ s.company_kvk) if s.company_kvk, ('Btw ' ~ s.company_btw) if s.company_btw, ('IBAN ' ~ s.company_iban) if s.company_iban]|select|join(' · ') }}
+
+
+
+
diff --git a/templates/quotes.html b/templates/quotes.html
new file mode 100644
index 0000000..468c9f1
--- /dev/null
+++ b/templates/quotes.html
@@ -0,0 +1,31 @@
+{% extends 'base.html' %}
+{% block title %}Offertes{% endblock %}
+{% block content %}
+
+
+
Alle
+ {% for k, l in QUOTE_STATUSES %}
{{ l }} {% endfor %}
+
+
+ {% if rows %}
+
+ Nummer Klant Onderwerp Datum Status Totaal incl.
+
+ {% for qt in rows %}
+
+ {{ qt.number }}
+ {{ qt.contact_name or '—' }}
+ {{ qt.title }}
+ {{ qt.date|dt('%d-%m-%Y') }}
+ {{ QSTATUS.get(qt.status, qt.status) }}
+ {{ qt.total|euro }}
+
+ {% endfor %}
+
+
+ {% else %}
Nog geen offertes.
{% endif %}
+
+{% endblock %}
diff --git a/templates/relation.html b/templates/relation.html
new file mode 100644
index 0000000..671e77b
--- /dev/null
+++ b/templates/relation.html
@@ -0,0 +1,133 @@
+{% extends 'base.html' %}
+{% block title %}{{ r.name }}{% endblock %}
+{% block content %}
+
+
+
+ {% if r.kind == 'klant' and members %}
+
+
+ Nieuwe offerte
+
+ {% endif %}
+
Bewerken
+
+
+
+
+
+
+
Contactpersonen
+
+ {% for m in members %}
+
+
+
{{ m.name }}
+ {% if m.relation_role %}
· {{ m.relation_role }} {% endif %}
+
{{ [m.email, m.phone]|select|join(' · ') }}
+
+
+ {% if m.type != 'partner' %}{{ STATUS.get(m.status) }} {% endif %}
+
+ Loskoppelen
+
+
+
+ {% else %}
+
Nog geen contactpersonen.
+ {% endfor %}
+
+
+ {% if candidates %}
+
+ Bestaand contact toevoegen…
+ {% for c in candidates %}{{ c.name }}{% if c.email %} ({{ c.email }}){% endif %} {% endfor %}
+
+
+ Toevoegen
+ {% endif %}
+ Nieuwe contactpersoon
+
+
+
+ {% if r.kind == 'partner' %}
+
+
Producten van deze leverancier
+ {% if products %}
+
+ SKU Naam Prijs excl. Bijgewerkt
+
+ {% for p in products %}
+
+ {{ p.sku }}
+ {{ p.name }} {% if not p.active %} Inactief {% endif %}
+ {{ p.price|euro }}
+ {{ (p.updated_at or p.created_at)|dt('%d-%m-%Y') }}
+
+ {% endfor %}
+
+
+ {% else %}
Nog geen producten gekoppeld. Vul deze naam in als leverancier bij een product of in een import.
{% endif %}
+
+ {% endif %}
+
+
+
Recente activiteit
+
+ {% for a in activities %}
+
+
+ {{ a.body }}
+
+ {% else %}
+ Nog geen activiteit.
+ {% endfor %}
+
+
+
+
+
+
+
Gegevens
+
+ {% if r.type %}Type {{ r.type }} {% endif %}
+ {% if r.email %}E-mail {{ r.email }} {% endif %}
+ {% if r.phone %}Telefoon {{ r.phone }} {% endif %}
+ {% if r.address or r.city %}Adres {{ [r.address, (r.postcode ~ ' ' ~ r.city)|trim]|select|join('\n') }} {% endif %}
+ {% if r.website %}Website {{ r.website }} {% endif %}
+ {% if r.kvk %}KvK {{ r.kvk }} {% endif %}
+ Aangemaakt {{ r.created_at|dt('%d-%m-%Y') }}
+
+ {% if r.notes %}
Notities {{ r.notes }}
{% endif %}
+
+
+ {% if tasks %}
+
+
Open taken
+ {% for t in tasks %}
+
+
+ {{ t.title }}
{{ t.contact_name }}{% if t.due_date %} · {{ t.due_date|dt('%d-%m-%Y') }} {% endif %}
+
+ {% endfor %}
+
+ {% endif %}
+
+ {% if r.kind == 'klant' %}
+
+
Offertes
+ {% for qt in quotes %}
+
{{ qt.number }} {{ QSTATUS.get(qt.status) }}
+ {{ qt.contact_name }} · {{ qt.date|dt('%d-%m-%Y') }} · {{ qt.total|euro }}
+ {% else %}
+
Nog geen offertes.
+ {% endfor %}
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/templates/relation_form.html b/templates/relation_form.html
new file mode 100644
index 0000000..03eec2e
--- /dev/null
+++ b/templates/relation_form.html
@@ -0,0 +1,39 @@
+{% extends 'base.html' %}
+{% block title %}{{ r.name or ('Nieuwe ' ~ RKIND.get(r.kind, 'relatie')|lower) }}{% endblock %}
+{% block content %}
+
+
+
← Terug
+
{{ 'Relatie bewerken' if r.id else ('Nieuwe ' ~ RKIND.get(r.kind, 'relatie')|lower) }}
+
+
+
+
+
+
+{% if r.id %}
+
+ Relatie verwijderen
+
+{% endif %}
+{% endblock %}
diff --git a/templates/relations.html b/templates/relations.html
new file mode 100644
index 0000000..7b47eb8
--- /dev/null
+++ b/templates/relations.html
@@ -0,0 +1,47 @@
+{% extends 'base.html' %}
+{% block title %}{{ 'Klanten' if kind == 'klant' else 'Partners' }}{% endblock %}
+{% block content %}
+
+
+
Relaties
+
{{ 'Klanten' if kind == 'klant' else 'Partners' }}
+
{{ 'Huishoudens, families en bedrijven met hun contactpersonen.' if kind == 'klant' else 'Leveranciers, aannemers en andere partners.' }}
+
+
+
+
+
+
+
+ {% if rows %}
+
+ Naam Type Contactpersonen Plaats Contact {% if kind == 'partner' %}Producten {% endif %}
+
+ {% for r in rows %}
+
+ {{ r.name }}
+ {% if r.type %}{{ r.type }} {% endif %}
+ {{ r.member_names or '—' }}{% if r.members > 4 %} +{{ r.members - 4 }} {% endif %}
+ {{ r.city }}
+ {{ [r.phone, r.email]|select|join(' · ') }}
+ {% if kind == 'partner' %}{{ r.products or '' }} {% endif %}
+
+ {% endfor %}
+
+
+ {% else %}
Nog geen {{ 'klantrelaties. Een lead die je omzet naar klant komt hier vanzelf.' if kind == 'klant' else 'partners. Leveranciers uit een productimport komen hier vanzelf.' }}
{% endif %}
+
+{% endblock %}
diff --git a/templates/settings.html b/templates/settings.html
new file mode 100644
index 0000000..fb5cdac
--- /dev/null
+++ b/templates/settings.html
@@ -0,0 +1,129 @@
+{% extends 'base.html' %}
+{% block title %}Instellingen{% endblock %}
+{% macro field(key, label, type='text', placeholder='', full=false) %}
+ {{ label }}
+{% endmacro %}
+{% block content %}
+
+
+
+
+
Bedrijfsgegevens
+
Deze gegevens staan op de offertes.
+
+ {{ field('company_name', 'Bedrijfsnaam', full=true) }}
+ {{ field('company_address', 'Adres') }}
+ {{ field('company_postcode_city', 'Postcode en plaats') }}
+ {{ field('company_phone', 'Telefoon') }}
+ {{ field('company_email', 'E-mail', 'email') }}
+ {{ field('company_website', 'Website') }}
+ {{ field('company_kvk', 'KvK-nummer') }}
+ {{ field('company_btw', 'Btw-nummer') }}
+ {{ field('company_iban', 'IBAN') }}
+
+
+
+
+
Offertes
+
+ {{ field('quote_prefix', 'Voorvoegsel nummer', placeholder='OFF') }}
+ {{ field('quote_valid_days', 'Standaard geldig (dagen)', 'number') }}
+
Standaard inleiding {{ s.quote_intro }}
+
Voettekst (bijv. betalingsvoorwaarden)
+
+
+
+
+
E-mail koppelen
+
Gebruik voor Gmail of Microsoft 365 een app-wachtwoord. Wachtwoorden staan in de database: houd de map data/ privé.
+
Ontvangen (IMAP)
+
+ {{ field('imap_host', 'Server', placeholder='imap.gmail.com') }}
+ {{ field('imap_port', 'Poort', 'number', '993') }}
+ {{ field('imap_user', 'Gebruikersnaam', placeholder='info@vanemous.nl') }}
+
Wachtwoord
+ {{ field('imap_folder', 'Map', placeholder='INBOX') }}
+ {{ field('imap_days', 'Aantal dagen terug ophalen', 'number', '30') }}
+
+
Versturen (SMTP)
+
+ {{ field('smtp_host', 'Server', placeholder='smtp.gmail.com') }}
+ {{ field('smtp_port', 'Poort', 'number', '587') }}
+ {{ field('smtp_user', 'Gebruikersnaam', placeholder='leeg = zelfde als IMAP') }}
+
Wachtwoord
+ {{ field('smtp_from', 'Afzender', placeholder='Van Emous
', full=true) }}
+
+
+
+
+
Formulieren en API
+
+ {{ field('allowed_origins', 'Toegestane websites (komma-gescheiden)', placeholder='https://www.vanemous.nl', full=true) }}
+
Nieuwe leads toewijzen aan
+
+ — Niemand —
+ {% for u in USERS %}{{ u.name }} {% endfor %}
+
+
+
+
+
+
+
Relatietypes
+
Komma-gescheiden. Bestaande relaties houden hun type als je er een weghaalt.
+
+
+
+ Instellingen opslaan
+
+
+
+
API-koppeling
+
+ Endpoint POST {{ api_url }}
+ API-sleutel {{ s.api_key }}
+ Klantportaal {{ portal_url }}
+
+
Stuur de sleutel mee als header X-API-Key of als ?key= in de URL. Herkende velden (ook in het Nederlands): naam, voornaam, achternaam, email, telefoon, bedrijf, adres, postcode, plaats, bericht, bron, waarde. Andere velden worden bewaard als extra gegevens. Bestanden (bijv. foto's) gaan mee als multipart/form-data. Een ontwerp uit de configurator (een object met "type": "house-design") wordt als bestand bij de lead opgeslagen. Leads met een bekend e-mailadres worden bij het bestaande contact gezet.
+
+
Gewoon HTML-formulier
+
{% filter forceescape %}
+
+
+
+
+
+
+
+
+ Versturen
+ {% endfilter %}
+
+
JavaScript (fetch)
+
{% filter forceescape %}const res = await fetch('{{ api_url }}', {
+ method: 'POST',
+ headers: { 'X-API-Key': '{{ s.api_key }}' },
+ body: new FormData(document.querySelector('form'))
+});
+const { ok, id } = await res.json();{% endfilter %}
+
+
Vanaf een server (aanbevolen: de sleutel blijft geheim)
+
{% filter forceescape %}curl -X POST {{ api_url }} \
+ -H 'X-API-Key: {{ s.api_key }}' -H 'Content-Type: application/json' \
+ -d '{"naam": "Jan Jansen", "email": "jan@example.nl", "bericht": "Graag info", "model": "Amstel"}'{% endfilter %}
+
+
Producten bijwerken (prijsmutaties)
+
Werkt producten bij op SKU, bijvoorbeeld vanuit een prijsscript van een leverancier. Veldnamen zoals bij de import. ?deactivate_missing=1 zet producten die niet in de lijst staan op inactief.
+
{% filter forceescape %}curl -X POST '{{ url_for('api_products', _external=True) }}?source=Leverancier%20X' \
+ -H 'X-API-Key: {{ s.api_key }}' -H 'Content-Type: application/json' \
+ -d '[{"sku": "plaat.osbM2", "naam": "OSB", "prijs": "12,50", "eenheid": "m²", "leverancier": "Houthandel Y"}]'{% endfilter %}
+
+
+
+ Nieuwe API-sleutel maken
+
+
+{% endblock %}
diff --git a/templates/user_form.html b/templates/user_form.html
new file mode 100644
index 0000000..7566abd
--- /dev/null
+++ b/templates/user_form.html
@@ -0,0 +1,24 @@
+{% extends 'base.html' %}
+{% block title %}{{ u.name or 'Nieuwe gebruiker' }}{% endblock %}
+{% block content %}
+
+
← Gebruikers {{ 'Gebruiker bewerken' if u.id else 'Nieuwe gebruiker' }}
+
+
+
+
Naam *
+
E-mailadres (inlognaam) *
+
Rol
+ {% for k, l in ROLES %}{{ l }} {% endfor %}
+
+
{{ 'Nieuw wachtwoord' if u.id else 'Wachtwoord *' }}
+
+ {% if u.id %}
Leeg laten om het huidige wachtwoord te houden.
{% endif %}
+
Actief (mag inloggen)
+
+
+
+{% endblock %}
diff --git a/templates/users.html b/templates/users.html
new file mode 100644
index 0000000..7a82592
--- /dev/null
+++ b/templates/users.html
@@ -0,0 +1,25 @@
+{% extends 'base.html' %}
+{% block title %}Gebruikers{% endblock %}
+{% block content %}
+
+
+
+ Naam E-mail Rol Contacten Status
+
+ {% for u in rows %}
+
+ {{ u.name }}
+ {{ u.email }}
+ {{ ROLE.get(u.role, u.role) }}
+ {{ u.n }}
+ {% if not u.active %}Inactief {% endif %}
+
+ {% endfor %}
+
+
+
+Beheerders kunnen ook gebruikers en instellingen beheren. Klanten krijgen toegang via het klantportaal (op de pagina van het contact).
+{% endblock %}