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 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-14 21:56:55 +02:00
co-authored by Claude Opus 5.5
commit bffa053c05
33 changed files with 4111 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
data/
.venv/
__pycache__/
+83
View File
@@ -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
```
+1758
View File
File diff suppressed because it is too large Load Diff
+130
View File
@@ -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
+116
View File
@@ -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)<br\s*/?>|</p>|</div>|</tr>|</h\d>|</li>', '\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']
+1
View File
@@ -0,0 +1 @@
flask>=3.0
+160
View File
@@ -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
);
+62
View File
@@ -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'] }));
})();
+9
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 27 KiB

+215
View File
@@ -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; }
}
+13
View File
@@ -0,0 +1,13 @@
{% extends 'base.html' %}
{% block title %}Mijn account{% endblock %}
{% block content %}
<div class="head"><div><div class="kicker">{{ ROLE.get(g.user.role) }}</div><h1>{{ g.user.name }}</h1><div class="muted">{{ g.user.email }}</div></div></div>
<form method="post" class="card" style="max-width:560px">
<h2>Wachtwoord wijzigen</h2>
<div class="fields">
<div class="full"><label for="old">Huidig wachtwoord</label><input id="old" type="password" name="old_password" required></div>
<div class="full"><label for="new">Nieuw wachtwoord</label><input id="new" type="password" name="new_password" minlength="8" required autocomplete="new-password"></div>
</div>
<div class="actions"><button class="btn">Opslaan</button></div>
</form>
{% endblock %}
+33
View File
@@ -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 %}
<div class="auth">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous">
{% with msgs = get_flashed_messages(with_categories=true) %}
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
{% endwith %}
<div class="card">
<form method="post">
<h1>{{ titles[mode] }}</h1>
{% if mode == 'setup' %}
<p class="muted">Maak het eerste beheerdersaccount aan.</p>
<label for="name">Naam</label><input id="name" name="name" required autofocus>
<label for="email" style="margin-top:1rem">E-mailadres</label><input id="email" type="email" name="email" required>
{% elif mode == 'invite' %}
<p class="muted">Welkom {{ c.name }}. Kies een wachtwoord voor het klantportaal. Daarna logt u in met {{ c.email }}.</p>
{% else %}
{% if mode == 'portal_login' %}<p class="muted">Bekijk uw offertes en deel bestanden met ons.</p>{% endif %}
<label for="email">E-mailadres</label><input id="email" type="email" name="email" required autofocus>
{% endif %}
<label for="pw" style="margin-top:1rem">Wachtwoord</label>
<input id="pw" type="password" name="password" {{ 'minlength=8' if mode in ('setup', 'invite') }} required>
{% if mode in ('setup', 'invite') %}
<label for="pw2" style="margin-top:1rem">Herhaal wachtwoord</label>
<input id="pw2" type="password" name="password2" minlength="8" required>
{% endif %}
<div class="actions"><button class="btn">{{ 'Inloggen' if mode in ('login', 'portal_login') else 'Opslaan en doorgaan' }}</button></div>
</form>
</div>
</div>
{% endblock %}
+54
View File
@@ -0,0 +1,54 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}CRM{% endblock %} · Van Emous CRM</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Kantumruy+Pro:wght@500;600&family=Mukta:wght@400;600;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<script>
/* inhoud verbergen tot de GSAP-intro klaar staat; na 3 s altijd zichtbaar */
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
document.documentElement.classList.add('fx-wait');
setTimeout(() => document.documentElement.classList.remove('fx-wait'), 3000);
}
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js" defer></script>
<script src="{{ url_for('static', filename='fx.js') }}" defer></script>
</head>
<body>
{% block body %}
{% set ep = request.endpoint or '' %}
<aside class="side">
<a href="{{ url_for('dashboard') }}" class="brand"><img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous"></a>
{% set ctype = request.args.get('type') or (c.type if c is defined and c and c.type is defined else '') %}
{% set rkind = request.args.get('kind') or (r.kind if r is defined and r and r.kind is defined else '') %}
<nav>
<a href="{{ url_for('dashboard') }}" class="{{ 'active' if ep == 'dashboard' }}">Dashboard</a>
<a href="{{ url_for('contacts', type='lead') }}" class="{{ 'active' if ep.startswith('contact') and ctype == 'lead' }}">Leads</a>
<a href="{{ url_for('relations', kind='klant') }}" class="{{ 'active' if ep.startswith('relation') and rkind == 'klant' }}">Klanten</a>
<a href="{{ url_for('relations', kind='partner') }}" class="{{ 'active' if ep.startswith('relation') and rkind == 'partner' }}">Partners</a>
<a href="{{ url_for('contacts') }}" class="{{ 'active' if ep.startswith('contact') and ctype != 'lead' }}">Contactpersonen</a>
<a href="{{ url_for('inbox') }}" class="{{ 'active' if ep.startswith('inbox') }}">Inbox {% if unread %}<span class="count">{{ unread }}</span>{% endif %}</a>
<a href="{{ url_for('products') }}" class="{{ 'active' if ep.startswith('product') }}">Producten</a>
<a href="{{ url_for('quotes') }}" class="{{ 'active' if ep.startswith('quote') }}">Offertes</a>
<a href="{{ url_for('import_start') }}" class="{{ 'active' if ep.startswith('import') }}">Importeren</a>
{% if g.user.role == 'admin' %}
<a href="{{ url_for('users') }}" class="{{ 'active' if ep.startswith('user') }}">Gebruikers</a>
<a href="{{ url_for('settings') }}" class="{{ 'active' if ep == 'settings' }}">Instellingen</a>
{% endif %}
</nav>
<a href="{{ url_for('account') }}" class="logout">{{ g.user.name }}</a>
<a href="{{ url_for('logout') }}" class="logout">Uitloggen</a>
</aside>
<main>
{% with msgs = get_flashed_messages(with_categories=true) %}
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</main>
{% endblock %}
</body>
</html>
+232
View File
@@ -0,0 +1,232 @@
{% extends 'base.html' %}
{% block title %}{{ c.name }}{% if c.type == 'lead' %}
<dialog id="convert" class="dialog">
<form method="post" action="{{ url_for('contact_convert', cid=c.id) }}">
<h2>Omzetten naar klant</h2>
<p class="muted">De contactpersoon komt in een klantrelatie, bijvoorbeeld een huishouden of familie.</p>
<label><input type="radio" name="mode" value="new" checked onchange="this.form.relation_id.disabled=true;this.form.new_relation.disabled=false"> Nieuwe klantrelatie</label>
<input name="new_relation" value="{{ c.company or ('Familie ' ~ c.name.split(' ')[-1]) }}" aria-label="Naam relatie" style="margin:.4rem 0 1rem">
{% if klant_relations %}
<label><input type="radio" name="mode" value="existing" onchange="this.form.relation_id.disabled=false;this.form.new_relation.disabled=true"> Bestaande klantrelatie</label>
<select name="relation_id" disabled aria-label="Bestaande relatie" style="margin-top:.4rem">
{% for r in klant_relations %}<option value="{{ r.id }}">{{ r.name }}{% if r.type %} · {{ r.type }}{% endif %}</option>{% endfor %}
</select>
{% endif %}
<div class="actions">
<button class="btn">Omzetten</button>
<button class="btn quiet" type="button" onclick="this.closest('dialog').close()">Annuleren</button>
</div>
</form>
</dialog>
{% endif %}
{% endblock %}
{% block content %}
<div class="head">
<div>
<a class="label" href="{{ url_for('contacts') }}">← Contacten</a>
<h1>{{ c.name }}</h1>
<div class="muted"><span class="chip">{{ TYPE.get(c.type) }}</span>
{% if c.relation_id %}<a href="{{ url_for('relation_view', rid=c.relation_id) }}">{{ c.relation_name }}</a>{% if c.relation_role %} ({{ c.relation_role }}){% endif %} · {% endif %}{{ [c.company, c.city]|select|join(' · ') }}</div>
</div>
<div class="actions">
<form method="post" action="{{ url_for('contact_status', cid=c.id) }}" class="inline">
<select name="status" onchange="this.form.submit()" aria-label="Status" style="width:auto">
{% for k, l in STATUSES %}<option value="{{ k }}" {{ 'selected' if c.status == k }}>{{ l }}</option>{% endfor %}
</select>
</form>
{% if c.type == 'lead' %}
<button class="btn ghost" type="button" onclick="document.getElementById('convert').showModal()">Omzetten naar klant</button>
{% endif %}
<form method="post" action="{{ url_for('quote_new') }}" class="inline">
<input type="hidden" name="contact_id" value="{{ c.id }}">
<button class="btn ghost">Nieuwe offerte</button>
</form>
<a class="btn" href="{{ url_for('contact_edit', cid=c.id) }}">Bewerken</a>
</div>
</div>
<div class="two">
<div>
<div class="card">
<h2>Taken</h2>
{% for t in tasks %}
<form method="post" action="{{ url_for('task_done', tid=t.id) }}" class="task {{ 'done' if t.done }}">
<button class="check" aria-label="{{ 'Heropenen' if t.done else 'Afronden' }}"></button>
<div><div class="title">{{ t.title }}</div>
<div class="muted">{{ t.user_name or '' }}{% if t.due_date %} · <span class="{{ 'overdue' if not t.done and t.due_date < today }}">{{ t.due_date|dt('%d-%m-%Y') }}</span>{% endif %}</div></div>
</form>
{% endfor %}
<form method="post" action="{{ url_for('contact_task', cid=c.id) }}" style="margin-top:1rem">
<div class="inline-fields">
<input name="title" placeholder="Bijv. terugbellen, offerte nabellen" required aria-label="Taak">
<input type="date" name="due_date" aria-label="Datum">
</div>
<div class="actions" style="margin-top:.5rem">
<select name="user_id" style="width:auto" aria-label="Voor">
{% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if u.id == g.user.id }}>{{ u.name }}</option>{% endfor %}
</select>
<button class="btn ghost small">Taak toevoegen</button>
</div>
</form>
</div>
<div class="card">
<h2>Notitie toevoegen</h2>
<form method="post" action="{{ url_for('contact_note', cid=c.id) }}">
<textarea name="body" required placeholder="Gebeld, afspraak gemaakt, wensen…" aria-label="Notitie"></textarea>
<div class="actions"><button class="btn">Opslaan</button></div>
</form>
</div>
{% if c.email %}
<div class="card">
<details>
<summary>E-mail sturen</summary>
<form method="post" action="{{ url_for('mail_send') }}">
<input type="hidden" name="contact_id" value="{{ c.id }}">
<div class="fields">
<div class="full"><label>Aan</label><input type="email" name="to" value="{{ c.email }}" required></div>
<div class="full"><label>Onderwerp</label><input name="subject" required></div>
<div class="full"><label>Bericht</label><textarea name="body" rows="8" required></textarea></div>
</div>
<div class="actions"><button class="btn">Versturen</button></div>
</form>
</details>
</div>
{% endif %}
<div class="card">
<h2>Tijdlijn</h2>
<ul class="timeline">
{% for a in activities %}
<li>
<div class="meta">{{ ACT.get(a.type, a.type) }} · {{ a.created_at|dt }}{% if a.user_name %} · {{ a.user_name }}{% endif %}</div>
<div class="body">{{ a.body }}</div>
</li>
{% else %}
<li class="empty">Nog geen activiteit.</li>
{% endfor %}
</ul>
</div>
{% if emails %}
<div class="card">
<h2>E-mail</h2>
<div class="table-wrap"><table>
<tbody>
{% for e in emails %}
<tr class="link" onclick="location='{{ url_for('inbox_view', eid=e.id) }}'">
<td><span class="chip">{{ 'Ontvangen' if e.direction == 'in' else 'Verstuurd' }}</span></td>
<td><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td>
<td class="muted num">{{ e.date|dt }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
</div>
{% endif %}
</div>
<div>
<div class="card">
<h2>Gegevens</h2>
<dl class="props">
<dt>Status</dt><dd><span class="chip {{ c.status }}">{{ STATUS.get(c.status, c.status) }}</span></dd>
<dt>Toegewezen</dt><dd>{{ c.user_name or '—' }}</dd>
{% if c.relation_id %}<dt>Relatie</dt><dd><a href="{{ url_for('relation_view', rid=c.relation_id) }}">{{ c.relation_name }}</a><br><span class="muted">{{ RKIND.get(c.relation_kind) }}{% if c.relation_type %} · {{ c.relation_type }}{% endif %}</span>
{% for h in housemates %}<br><a href="{{ url_for('contact_view', cid=h.id) }}">{{ h.name }}</a>{% if h.relation_role %} <span class="muted">({{ h.relation_role }})</span>{% endif %}{% endfor %}</dd>{% endif %}
{% if c.value %}<dt>Verwachte waarde</dt><dd>{{ c.value|euro }}</dd>{% endif %}
{% if c.email %}<dt>E-mail</dt><dd><a href="mailto:{{ c.email }}">{{ c.email }}</a></dd>{% endif %}
{% if c.phone %}<dt>Telefoon</dt><dd><a href="tel:{{ c.phone }}">{{ c.phone }}</a></dd>{% endif %}
{% if c.company %}<dt>Bedrijf</dt><dd>{{ c.company }}</dd>{% endif %}
{% if c.address or c.city %}<dt>Adres</dt><dd>{{ c.address }}{% if c.address %}
{% endif %}{{ c.postcode }} {{ c.city }}</dd>{% endif %}
{% if c.source %}<dt>Bron</dt><dd>{{ c.source }}</dd>{% endif %}
<dt>Aangemaakt</dt><dd>{{ c.created_at|dt }}</dd>
</dl>
{% if c.notes %}<h3>Notities</h3><p style="white-space:pre-line;margin:0">{{ c.notes }}</p>{% endif %}
{% if extra %}
<h3>Formuliervelden</h3>
<dl class="props">
{% for k, v in extra.items() %}<dt>{{ k }}</dt><dd>{{ v if v is string else v|tojson }}</dd>{% endfor %}
</dl>
{% endif %}
</div>
<div class="card">
<h2>Bestanden</h2>
{% if files %}
<div class="files">
{% for f in files %}
<div class="file">
<a href="{{ url_for('upload_file', name=f.stored) }}" target="_blank" class="thumb">
{% if f.mime.startswith('image/') %}<img src="{{ url_for('upload_file', name=f.stored) }}" alt="{{ f.original }}" loading="lazy">
{% else %}{{ f.original.rsplit('.', 1)[-1][:4] if '.' in f.original else 'bestand' }}{% endif %}
</a>
<div class="name" title="{{ f.original }}">{{ f.original }}</div>
<form method="post" action="{{ url_for('file_delete', fid=f.id) }}" onsubmit="return confirm('Bestand verwijderen?')">
<button class="linkbtn">Verwijderen</button>
</form>
</div>
{% endfor %}
</div>
{% endif %}
<form method="post" action="{{ url_for('contact_upload', cid=c.id) }}" enctype="multipart/form-data">
<input type="file" name="files" multiple required aria-label="Bestanden kiezen">
<div class="actions"><button class="btn ghost small">Uploaden</button></div>
</form>
</div>
<div class="card">
<h2>Offertes</h2>
{% for qt in quotes %}
<p style="margin:0 0 .6rem"><a href="{{ url_for('quote_edit', qid=qt.id) }}">{{ qt.number }}</a> <span class="chip {{ qt.status }}">{{ QSTATUS.get(qt.status) }}</span><br>
<span class="muted">{{ qt.date|dt('%d-%m-%Y') }} · {{ qt.total|euro }} incl. btw</span></p>
{% else %}
<p class="empty">Nog geen offertes.</p>
{% endfor %}
</div>
<div class="card">
<h2>Klantportaal</h2>
{% if c.portal_password_hash %}
<p class="muted" style="margin-top:0">Actief{% if c.portal_last_login %} · laatst ingelogd {{ c.portal_last_login|dt }}{% endif %}</p>
{% elif portal_link %}
<p class="muted" style="margin-top:0">Uitnodiging klaar, nog niet geactiveerd:</p>
<p><code>{{ portal_link }}</code></p>
{% else %}
<p class="muted" style="margin-top:0">De klant kan via het portaal offertes bekijken en accepteren, en bestanden en berichten sturen.</p>
{% endif %}
<form method="post" action="{{ url_for('contact_portal', cid=c.id) }}" class="actions">
{% if portal_link and c.email %}<button class="btn small" name="action" value="mail">Mail uitnodiging</button>{% endif %}
{% if not c.portal_password_hash %}<button class="btn ghost small" name="action" value="invite">{{ 'Nieuwe link' if portal_link else 'Uitnodiging maken' }}</button>{% endif %}
{% if c.portal_password_hash or portal_link %}<button class="btn quiet small" name="action" value="revoke">Toegang intrekken</button>{% endif %}
</form>
</div>
<form method="post" action="{{ url_for('contact_delete', cid=c.id) }}" onsubmit="return confirm('Contact met alle notities en bestanden verwijderen?')">
<button class="btn quiet small">Contact verwijderen</button>
</form>
</div>
</div>
{% if c.type == 'lead' %}
<dialog id="convert" class="dialog">
<form method="post" action="{{ url_for('contact_convert', cid=c.id) }}">
<h2>Omzetten naar klant</h2>
<p class="muted">De contactpersoon komt in een klantrelatie, bijvoorbeeld een huishouden of familie.</p>
<label><input type="radio" name="mode" value="new" checked onchange="this.form.relation_id.disabled=true;this.form.new_relation.disabled=false"> Nieuwe klantrelatie</label>
<input name="new_relation" value="{{ c.company or ('Familie ' ~ c.name.split(' ')[-1]) }}" aria-label="Naam relatie" style="margin:.4rem 0 1rem">
{% if klant_relations %}
<label><input type="radio" name="mode" value="existing" onchange="this.form.relation_id.disabled=false;this.form.new_relation.disabled=true"> Bestaande klantrelatie</label>
<select name="relation_id" disabled aria-label="Bestaande relatie" style="margin-top:.4rem">
{% for r in klant_relations %}<option value="{{ r.id }}">{{ r.name }}{% if r.type %} · {{ r.type }}{% endif %}</option>{% endfor %}
</select>
{% endif %}
<div class="actions">
<button class="btn">Omzetten</button>
<button class="btn quiet" type="button" onclick="this.closest('dialog').close()">Annuleren</button>
</div>
</form>
</dialog>
{% endif %}
{% endblock %}
+56
View File
@@ -0,0 +1,56 @@
{% extends 'base.html' %}
{% block title %}{{ 'Nieuw contact' if new else c.name }}{% endblock %}
{% block content %}
<div class="head">
<div>
<a class="label" href="{{ url_for('contacts') if new else url_for('contact_view', cid=c.id) }}">← Terug</a>
<h1>{{ ('Nieuwe ' ~ TYPE.get(c.type, 'lead')|lower) if new else 'Contact bewerken' }}</h1>
</div>
</div>
<form method="post" class="card">
<div class="fields">
<div><label for="name">Naam *</label><input id="name" name="name" value="{{ c.name }}" required></div>
<div><label for="company">Bedrijf</label><input id="company" name="company" value="{{ c.company }}"></div>
<div><label for="email">E-mail</label><input id="email" type="email" name="email" value="{{ c.email }}"></div>
<div><label for="phone">Telefoon</label><input id="phone" name="phone" value="{{ c.phone }}"></div>
<div class="full"><label for="address">Adres</label><input id="address" name="address" value="{{ c.address }}"></div>
<div><label for="postcode">Postcode</label><input id="postcode" name="postcode" value="{{ c.postcode }}"></div>
<div><label for="city">Plaats</label><input id="city" name="city" value="{{ c.city }}"></div>
<div><label for="status">Status</label>
<select id="status" name="status">
{% for k, l in STATUSES %}<option value="{{ k }}" {{ 'selected' if c.status == k }}>{{ l }}</option>{% endfor %}
</select>
</div>
<div><label for="type">Soort</label>
<select id="type" name="type">
{% for k, l in TYPES %}<option value="{{ k }}" {{ 'selected' if c.type == k }}>{{ l }}</option>{% endfor %}
</select>
</div>
<div><label for="assigned">Toegewezen aan</label>
<select id="assigned" name="assigned_user_id">
<option value="">— Niemand —</option>
{% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if c.assigned_user_id|string == u.id|string }}>{{ u.name }}</option>{% endfor %}
</select>
</div>
<div><label for="relation">Relatie</label>
<select id="relation" name="relation_id">
<option value="">— Geen —</option>
{% for kind, label in RELATION_KINDS %}
<optgroup label="{{ label }}s">
{% for r in relations if r.kind == kind %}<option value="{{ r.id }}" {{ 'selected' if c.relation_id|string == r.id|string }}>{{ r.name }}{% if r.type %} · {{ r.type }}{% endif %}</option>{% endfor %}
</optgroup>
{% endfor %}
</select>
<p class="hint">Huishouden, familie of bedrijf van de klant, of de partner waar deze persoon werkt.</p>
</div>
<div><label for="role">Rol binnen relatie</label><input id="role" name="relation_role" value="{{ c.relation_role }}" placeholder="Bijv. bewoner, mantelzorger, inkoper"></div>
<div><label for="value">Verwachte waarde (€)</label><input id="value" type="number" step="0.01" name="value" value="{{ c.value or '' }}"></div>
<div><label for="source">Bron</label><input id="source" name="source" value="{{ c.source }}" placeholder="Bijv. website, beurs, telefoon"></div>
<div class="full"><label for="notes">Vaste notities</label><textarea id="notes" name="notes">{{ c.notes }}</textarea></div>
</div>
<div class="actions">
<button class="btn">Opslaan</button>
<a class="btn quiet" href="{{ url_for('contacts') if new else url_for('contact_view', cid=c.id) }}">Annuleren</a>
</div>
</form>
{% endblock %}
+54
View File
@@ -0,0 +1,54 @@
{% extends 'base.html' %}
{% block title %}Contacten{% endblock %}
{% block content %}
<div class="head">
<div><div class="kicker">{{ 'Verkoopkansen' if a.type == 'lead' else 'Relaties' }}</div><h1>{{ {'lead': 'Leads', 'klant': 'Klantcontacten', 'partner': 'Partnercontacten'}.get(a.type, 'Contactpersonen') }}</h1></div>
<div class="actions">
<form class="search" method="get">
{% for k in ('type', 'status', 'owner') %}{% if a[k] %}<input type="hidden" name="{{ k }}" value="{{ a[k] }}">{% endif %}{% endfor %}
<input type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, e-mail, plaats…" aria-label="Zoeken">
</form>
<a class="btn ghost" href="{{ url_for('contacts_export') }}">Exporteer CSV</a>
<a class="btn" href="{{ url_for('contact_new', type=a.type or 'lead') }}">{{ 'Nieuwe klant' if a.type == 'klant' else 'Nieuwe lead' }}</a>
</div>
</div>
{% if a.type != 'lead' %}
<div class="filters">
<a href="{{ url_for('contacts', q=term or None) }}" class="{{ 'on' if not a.type }}">Alle</a>
{% for k, l in TYPES %}<a href="{{ url_for('contacts', type=k, q=term or None) }}" class="{{ 'on' if a.type == k }}">{{ l }}</a>{% endfor %}
</div>
{% endif %}
<div class="filters">
{% set base = {'type': a.type, 'q': term or None} %}
<a href="{{ url_for('contacts', **base) }}" class="{{ 'on' if not a.status and not a.owner }}">Alle</a>
{% for k, l in STATUSES %}
<a href="{{ url_for('contacts', status=k, **base) }}" class="{{ 'on' if a.status == k }}">{{ l }}</a>
{% endfor %}
<span class="muted">·</span>
<a href="{{ url_for('contacts', owner='me', **base) }}" class="{{ 'on' if a.owner == 'me' }}">Mijn {{ 'leads' if a.type == 'lead' else 'contacten' }}</a>
<a href="{{ url_for('contacts', owner='none', **base) }}" class="{{ 'on' if a.owner == 'none' }}">Niet toegewezen</a>
</div>
<div class="card">
{% if rows %}
<div class="table-wrap"><table>
<thead><tr><th>Naam</th><th>Relatie</th><th>Telefoon</th><th>Plaats</th><th>Bron</th><th>Toegewezen</th><th>Status</th><th>Aangemaakt</th></tr></thead>
<tbody>
{% for c in rows %}
<tr class="link" onclick="location='{{ url_for('contact_view', cid=c.id) }}'">
<td><a href="{{ url_for('contact_view', cid=c.id) }}">{{ c.name }}</a><div class="muted">{{ c.email }}</div></td>
<td>{% if c.relation_id %}<a href="{{ url_for('relation_view', rid=c.relation_id) }}" onclick="event.stopPropagation()">{{ c.relation_name }}</a>{% if c.relation_role %}<div class="muted">{{ c.relation_role }}</div>{% endif %}{% else %}{{ c.company }}{% endif %}</td>
<td>{{ c.phone }}</td>
<td>{{ c.city }}</td>
<td>{{ c.source }}</td>
<td>{{ c.user_name or '—' }}</td>
<td>{% if not a.type %}<span class="chip">{{ TYPE.get(c.type) }}</span> {% endif %}<span class="chip {{ c.status }}">{{ STATUS.get(c.status, c.status) }}</span></td>
<td class="muted">{{ c.created_at|dt('%d-%m-%Y') }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Geen contacten gevonden.</p>{% endif %}
</div>
{% endblock %}
+103
View File
@@ -0,0 +1,103 @@
{% extends 'base.html' %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<div class="head">
<div><div class="kicker">Overzicht</div><h1>Dashboard</h1></div>
<a class="btn" href="{{ url_for('contact_new') }}">Nieuw contact</a>
</div>
<div class="kpis">
<div class="kpi"><div class="label">Nieuwe leads · 30 dagen</div><div class="value">{{ stats.new30 }}</div><div class="muted">{{ stats.customers }} klantrelaties · {{ stats.partners }} partners</div></div>
<div class="kpi"><div class="label">Open leads</div><div class="value">{{ stats.open_leads }}</div>
<div class="muted">{{ stats.pipeline_value|euro }} verwacht{% if stats.unassigned %} · <a href="{{ url_for('contacts', type='lead', owner='none') }}">{{ stats.unassigned }} niet toegewezen</a>{% endif %}</div></div>
<div class="kpi"><div class="label">Open offertes</div><div class="value">{{ stats.open_quotes }}</div><div class="muted">{{ stats.open_value|euro }} excl. btw</div></div>
<div class="kpi"><div class="label">Gewonnen · 30 dagen</div><div class="value">{{ stats.won_value|euro }}</div><div class="muted">excl. btw</div></div>
</div>
<div class="grid">
<div class="card">
<h2>Leads per status</h2>
<div class="bars">
{% for key, label, n in pipeline %}
<a class="bar" href="{{ url_for('contacts', type='lead', status=key) }}">
<span>{{ label }}</span>
<span class="track"><span class="fill" style="width: {{ (n / pipe_max * 100)|round(1) }}%"></span></span>
<span class="num">{{ n }}</span>
</a>
{% endfor %}
</div>
</div>
<div class="card">
<h2>Leads per week</h2>
<div class="cols">
{% for wk, n in weekly %}
<div title="Week {{ wk }}: {{ n }} lead(s)">
<span>{{ n or '' }}</span>
<span class="c" style="height: {{ (n / week_max * 75)|round(1) }}%"></span>
<span>{{ wk }}</span>
</div>
{% endfor %}
</div>
</div>
<div class="card">
<h2>Bronnen</h2>
{% if sources %}
<div class="bars">
{% for s in sources %}
<div class="bar">
<span title="{{ s.s }}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ s.s }}</span>
<span class="track"><span class="fill" style="width: {{ (s.n / source_max * 100)|round(1) }}%"></span></span>
<span class="num">{{ s.n }}</span>
</div>
{% endfor %}
</div>
{% else %}<p class="empty">Nog geen leads.</p>{% endif %}
</div>
</div>
<div class="two">
<div class="card">
<h2>Laatste leads</h2>
{% if recent %}
<div class="table-wrap"><table>
<thead><tr><th>Naam</th><th>Bron</th><th>Toegewezen</th><th>Status</th><th>Binnen</th></tr></thead>
<tbody>
{% for c in recent %}
<tr class="link" onclick="location='{{ url_for('contact_view', cid=c.id) }}'">
<td><a href="{{ url_for('contact_view', cid=c.id) }}">{{ c.name }}</a><div class="muted">{{ c.email }}</div></td>
<td>{{ c.source }}</td>
<td>{{ c.user_name or '—' }}</td>
<td><span class="chip {{ c.status }}">{{ STATUS.get(c.status, c.status) }}</span></td>
<td class="muted">{{ c.created_at|dt }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Nog geen contacten. Voeg er een toe of koppel een formulier via Instellingen.</p>{% endif %}
</div>
<div>
<div class="card">
<h2>Mijn taken</h2>
{% for t in tasks %}
<form method="post" action="{{ url_for('task_done', tid=t.id) }}" class="task">
<button class="check" aria-label="Afronden"></button>
<div><div>{{ t.title }}</div>
<div class="muted">{% if t.contact_id %}<a href="{{ url_for('contact_view', cid=t.contact_id) }}">{{ t.contact_name }}</a>{% endif %}
{% if t.due_date %}· <span class="{{ 'overdue' if t.due_date < today }}">{{ t.due_date|dt('%d-%m-%Y') }}</span>{% endif %}</div></div>
</form>
{% else %}
<p class="empty">Geen openstaande taken.</p>
{% endfor %}
</div>
<div class="card">
<h2>Ongelezen e-mail</h2>
{% for e in mails %}
<p><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a><br><span class="muted">{{ e.from_name or e.from_addr }} · {{ e.date|dt }}</span></p>
{% else %}
<p class="empty">Geen ongelezen berichten.</p>
{% endfor %}
<a href="{{ url_for('inbox') }}">Naar inbox</a>
</div>
</div>
</div>
{% endblock %}
+39
View File
@@ -0,0 +1,39 @@
{% extends 'base.html' %}
{% block title %}{{ e.subject }}{% endblock %}
{% block content %}
<div class="head">
<div>
<a class="label" href="{{ url_for('inbox') }}">← Inbox</a>
<h1>{{ e.subject }}</h1>
<div class="muted">
{% if e.direction == 'in' %}Van {{ e.from_name }} &lt;{{ e.from_addr }}&gt;{% else %}Aan {{ e.to_addr }}{% endif %}
· {{ e.date|dt }}
</div>
</div>
<div class="actions">
{% if contact %}
<a class="btn ghost" href="{{ url_for('contact_view', cid=contact.id) }}">Naar {{ contact.name }}</a>
{% elif e.direction == 'in' %}
<form method="post" action="{{ url_for('inbox_contact', eid=e.id) }}" class="inline"><button class="btn ghost">Maak contact aan</button></form>
{% endif %}
</div>
</div>
<div class="card"><div class="mailbody">{{ e.body }}</div></div>
{% if e.direction == 'in' %}
<div class="card">
<h2>Beantwoorden</h2>
<form method="post" action="{{ url_for('mail_send') }}">
<input type="hidden" name="reply_to" value="{{ e.id }}">
{% if contact %}<input type="hidden" name="contact_id" value="{{ contact.id }}">{% endif %}
<div class="fields">
<div><label>Aan</label><input type="email" name="to" value="{{ e.from_addr }}" required></div>
<div><label>Onderwerp</label><input name="subject" value="{{ e.subject if e.subject.lower().startswith('re:') else 'Re: ' ~ e.subject }}" required></div>
<div class="full"><label>Bericht</label><textarea name="body" rows="8" required></textarea></div>
</div>
<div class="actions"><button class="btn">Versturen</button></div>
</form>
</div>
{% endif %}
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% block title %}Importeren{% endblock %}
{% block content %}
<div class="head"><div><div class="kicker">Gegevens</div><h1>Importeren</h1></div></div>
<div class="two">
<form method="post" enctype="multipart/form-data" class="card">
<h2>Bestand kiezen</h2>
<div class="fields">
<div class="full"><label for="kind">Wat importeer je?</label>
<select id="kind" name="kind">
<option value="contacts">Leads / klanten</option>
<option value="relations" {{ 'selected' if request.args.get('kind') == 'relations' }}>Relaties (klanten of partners)</option>
<option value="products" {{ 'selected' if request.args.get('kind') == 'products' }}>Producten</option>
</select>
</div>
<div class="full"><label for="file">CSV- of JSON-bestand</label><input id="file" type="file" name="file" accept=".csv,.json,.txt" required></div>
</div>
<div class="actions"><button class="btn">Volgende: kolommen controleren</button></div>
</form>
<div class="card">
<h2>Formaten</h2>
<p><strong>CSV</strong>: scheidingsteken <code>;</code>, <code>,</code> of tab, de eerste regel met kolomnamen. Regels die beginnen met <code>#</code> worden overgeslagen. Bedragen als <code>6,00</code>, <code>1.234,50</code> of <code>€ 6</code>.</p>
<p><strong>JSON</strong>: een lijst met objecten, of een object met een lijst erin, zoals <code>{"leads": [{"naam": "…", "email": "…"}]}</code>.</p>
<p><strong>Configurator</strong>: <code>kosten/leveranciers.csv</code> en <code>intern.csv</code> worden als producten herkend (code, omschrijving, eenheid, prijs, leverancier). Een woningontwerp (<code>house-design</code>-JSON) upload je bij een contact onder Bestanden.</p>
<p class="muted" style="margin-bottom:0">Je ziet eerst een voorbeeld en kiest per kolom waar die heen moet. Bestaande contacten (zelfde e-mail) en producten (zelfde SKU) worden bijgewerkt in plaats van dubbel aangemaakt. Elke productwijziging komt in de mutatiehistorie; offertes houden hun eigen prijs.</p>
</div>
</div>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends 'base.html' %}
{% block title %}Import controleren{% endblock %}
{% block content %}
<form method="post">
<input type="hidden" name="kind" value="{{ kind }}">
<div class="head">
<div><a class="label" href="{{ url_for('import_start') }}">← Ander bestand</a>
<h1>{{ filename }}</h1>
<div class="muted">{{ total }} rij(en) · {{ {'contacts': 'leads / contactpersonen', 'relations': 'relaties', 'products': 'producten'}[kind] }}</div></div>
<div class="actions"><button class="btn">{{ total }} rij(en) importeren</button></div>
</div>
<div class="card">
<h2>Kolommen koppelen</h2>
<div class="table-wrap"><table>
<thead><tr>
{% for col in cols %}
<th style="min-width:170px">
<div style="margin-bottom:.4rem">{{ col }}</div>
<select name="map_{{ loop.index0 }}" aria-label="Doelveld voor {{ col }}">
{% for k, l in targets %}<option value="{{ k }}" {{ 'selected' if mapping[loop.index0] == k }}>{{ l }}</option>{% endfor %}
</select>
</th>
{% endfor %}
</tr></thead>
<tbody>
{% for row in rows %}
<tr>{% for col in cols %}{% set v = row.get(col) %}<td>{{ (v if v is string else (v|tojson if v is not none else ''))|truncate(60) }}</td>{% endfor %}</tr>
{% endfor %}
</tbody>
</table></div>
{% if total > rows|length %}<p class="hint">Eerste {{ rows|length }} van {{ total }} rijen.</p>{% endif %}
</div>
<div class="card">
<h2>Opties</h2>
<div class="fields">
{% if kind == 'contacts' %}
<div><label>Importeren als</label><select name="type">{% for k, l in TYPES %}<option value="{{ k }}">{{ l }}</option>{% endfor %}</select></div>
<div><label>Status (als de kolom ontbreekt)</label><select name="status">{% for k, l in STATUSES %}<option value="{{ k }}">{{ l }}</option>{% endfor %}</select></div>
<div><label>Toewijzen aan</label><select name="assigned_user_id"><option value="">— Niemand —</option>{% for u in USERS %}<option value="{{ u.id }}">{{ u.name }}</option>{% endfor %}</select></div>
<div><label>Bron (als de kolom ontbreekt)</label><input name="source" value="Import"></div>
{% endif %}
{% if kind == 'relations' %}
<div><label>Soort</label><select name="relation_kind" id="rkind">{% for k, l in RELATION_KINDS %}<option value="{{ k }}" {{ 'selected' if request.args.get('rkind') == k }}>{{ l }}</option>{% endfor %}</select></div>
<div><label>Type (als de kolom ontbreekt)</label>
<select name="relation_type"><option value="">—</option>
{% for k, l in RELATION_KINDS %}<optgroup label="{{ l }}">{% for t in relation_types[k] %}<option>{{ t }}</option>{% endfor %}</optgroup>{% endfor %}
</select></div>
{% endif %}
{% if kind == 'products' %}
<div class="full"><label><input type="checkbox" name="deactivate_missing" value="1">Producten met een SKU die niet in dit bestand staan op inactief zetten (volledige prijslijst)</label>
<p class="hint">Alleen als er een kolom aan SKU gekoppeld is. Inactieve producten blijven bestaan, met hun mutaties en offertes.</p></div>
{% endif %}
<div class="full"><label><input type="checkbox" name="update" value="1" checked>Bestaande {{ 'contacten (zelfde e-mail) aanvullen' if kind == 'contacts' else 'relaties (zelfde naam of KvK) aanvullen' if kind == 'relations' else 'producten bijwerken (zelfde SKU, of zelfde naam als er geen SKU is); alleen gekoppelde kolommen worden gewijzigd' }}; uit = overslaan</label></div>
</div>
</div>
</form>
{% endblock %}
+39
View File
@@ -0,0 +1,39 @@
{% extends 'base.html' %}
{% block title %}Inbox{% endblock %}
{% block content %}
<div class="head">
<div><div class="kicker">E-mail</div><h1>Inbox</h1></div>
<div class="actions">
{% if last_sync %}<span class="muted">Laatst opgehaald: {{ last_sync|dt }}</span>{% endif %}
<form method="post" action="{{ url_for('inbox_sync') }}" class="inline"><button class="btn">Nieuwe e-mail ophalen</button></form>
</div>
</div>
{% if not configured %}
<div class="flash">Koppel eerst je mailbox bij <a href="{{ url_for('settings') }}#mail">Instellingen</a> (IMAP voor ontvangen, SMTP voor versturen).</div>
{% endif %}
<div class="filters">
<a href="{{ url_for('inbox') }}" class="{{ 'on' if f == 'all' }}">Alle</a>
<a href="{{ url_for('inbox', f='unread') }}" class="{{ 'on' if f == 'unread' }}">Ongelezen</a>
<a href="{{ url_for('inbox', f='unlinked') }}" class="{{ 'on' if f == 'unlinked' }}">Niet gekoppeld aan contact</a>
</div>
<div class="card">
{% if rows %}
<div class="table-wrap"><table>
<thead><tr><th>Van</th><th>Onderwerp</th><th>Contact</th><th class="num">Datum</th></tr></thead>
<tbody>
{% for e in rows %}
<tr class="link {{ 'unread' if not e.is_read }}" onclick="location='{{ url_for('inbox_view', eid=e.id) }}'">
<td>{% if not e.is_read %}<span class="dot" aria-label="Ongelezen"></span>{% endif %}{{ e.from_name or e.from_addr }}</td>
<td><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td>
<td>{{ e.contact_name or '' }}</td>
<td class="muted num">{{ e.date|dt }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Geen berichten.</p>{% endif %}
</div>
{% endblock %}
+74
View File
@@ -0,0 +1,74 @@
{% extends 'base.html' %}
{% block title %}Klantportaal{% endblock %}
{% block body %}
<script>document.body.classList.add('portal')</script>
<header class="portal-top">
<div class="wrap">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="{{ s.company_name or 'Van Emous' }}">
<a href="{{ url_for('portal_logout') }}">Uitloggen</a>
</div>
</header>
<div class="portal-main">
{% with msgs = get_flashed_messages(with_categories=true) %}
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
{% endwith %}
<div class="head"><div><div class="kicker">Klantportaal</div><h1>Welkom, {{ c.name }}</h1></div></div>
<div class="card">
<h2>Uw offertes</h2>
{% if quotes %}
<div class="table-wrap"><table>
<thead><tr><th>Nummer</th><th>Onderwerp</th><th>Datum</th><th>Status</th><th class="num">Totaal incl. btw</th></tr></thead>
<tbody>
{% for qt in quotes %}
<tr class="link" onclick="location='{{ url_for('portal_quote', qid=qt.id) }}'">
<td><a href="{{ url_for('portal_quote', qid=qt.id) }}">{{ qt.number }}</a></td>
<td>{{ qt.title }}</td>
<td>{{ qt.date|dt('%d-%m-%Y') }}</td>
<td><span class="chip {{ qt.status }}">{{ 'Wacht op uw reactie' if qt.status == 'verzonden' else QSTATUS.get(qt.status) }}</span></td>
<td class="num">{{ qt.total|euro }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Er staan nog geen offertes voor u klaar.</p>{% endif %}
</div>
<div class="grid">
<div class="card">
<h2>Bestanden</h2>
{% if files %}
<div class="files">
{% for f in files %}
<div class="file">
<a href="{{ url_for('portal_file', name=f.stored) }}" target="_blank" class="thumb">
{% if f.mime.startswith('image/') %}<img src="{{ url_for('portal_file', name=f.stored) }}" alt="{{ f.original }}" loading="lazy">
{% else %}{{ f.original.rsplit('.', 1)[-1][:4] if '.' in f.original else 'bestand' }}{% endif %}
</a>
<div class="name" title="{{ f.original }}">{{ f.original }}</div>
</div>
{% endfor %}
</div>
{% endif %}
<form method="post" action="{{ url_for('portal_upload') }}" enctype="multipart/form-data">
<label for="files">Foto's of documenten delen (bijv. van uw tuin of tekeningen)</label>
<input id="files" type="file" name="files" multiple required>
<div class="actions"><button class="btn ghost small">Uploaden</button></div>
</form>
</div>
<div class="card">
<h2>Bericht sturen</h2>
<form method="post" action="{{ url_for('portal_message') }}">
<textarea name="body" required aria-label="Bericht" placeholder="Uw vraag of opmerking"></textarea>
<div class="actions"><button class="btn">Versturen</button></div>
</form>
{% for m in messages %}
<p class="muted" style="margin:.75rem 0 0">{{ m.created_at|dt }}<br><span style="color:var(--ink);white-space:pre-line">{{ m.body[8:] }}</span></p>
{% endfor %}
</div>
</div>
{% if s.company_phone or s.company_email %}
<p class="muted">Vragen? Neem contact op: {{ [s.company_phone, s.company_email]|select|join(' · ') }}</p>
{% endif %}
</div>
{% endblock %}
+64
View File
@@ -0,0 +1,64 @@
{% extends 'base.html' %}
{% block title %}{{ p.name or 'Nieuw product' }}{% endblock %}
{% block content %}
<div class="head">
<div><a class="label" href="{{ url_for('products') }}">← Producten</a><h1>{{ 'Product bewerken' if p.id else 'Nieuw product' }}</h1></div>
</div>
<form method="post" enctype="multipart/form-data" class="card">
<div class="fields">
<div class="full"><label for="name">Naam *</label><input id="name" name="name" value="{{ p.name }}" required></div>
<div><label for="sku">SKU / artikelnummer</label><input id="sku" name="sku" value="{{ p.sku }}">
<p class="hint">Vaste sleutel: imports en de API werken het product met deze SKU bij.</p></div>
<div><label for="supplier">Leverancier</label><input id="supplier" name="supplier" value="{{ p.supplier }}" list="suppliers" autocomplete="off">
<datalist id="suppliers">{% for r in suppliers %}<option value="{{ r.name }}">{% endfor %}</datalist>
<p class="hint">{% if p.supplier_id %}<a href="{{ url_for('relation_view', rid=p.supplier_id) }}">Naar partner</a> · {% endif %}Een nieuwe naam wordt een partner van het type Leverancier.</p></div>
<div><label for="unit">Eenheid</label><input id="unit" name="unit" value="{{ p.unit }}" placeholder="stuk, m², uur"></div>
<div><label for="price">Prijs excl. btw (€)</label><input id="price" type="number" step="0.01" name="price" value="{{ p.price }}" required></div>
<div><label for="vat">Btw %</label>
<select id="vat" name="vat">
{% for v in [21, 9, 0] %}<option value="{{ v }}" {{ 'selected' if p.vat == v }}>{{ v }}%</option>{% endfor %}
</select>
</div>
<div class="full"><label for="description">Omschrijving</label><textarea id="description" name="description">{{ p.description }}</textarea>
<p class="hint">Wordt standaard onder de productnaam op de offerte gezet.</p></div>
<div class="full">
<label for="image">Afbeelding</label>
{% if p.image %}<img src="{{ url_for('upload_file', name=p.image) }}" alt="" style="max-width:220px;border-radius:var(--radius-md);display:block;margin-bottom:.75rem">{% endif %}
<input id="image" type="file" name="image" accept="image/*">
</div>
<div class="full"><label><input type="checkbox" name="active" value="1" {{ 'checked' if p.active }}>Actief (beschikbaar in offertes)</label></div>
</div>
<div class="actions">
<button class="btn">Opslaan</button>
<a class="btn quiet" href="{{ url_for('products') }}">Annuleren</a>
</div>
</form>
{% if p.id %}
<div class="card">
<h2>Mutaties</h2>
<p class="hint" style="margin-top:-.4rem">Bestaande offertes houden hun eigen prijs; wijzigingen gelden voor nieuwe offerteregels.{% if used %} Gebruikt in {{ used }} offerte(s).{% endif %}</p>
{% if history %}
<div class="table-wrap"><table>
<thead><tr><th>Datum</th><th>Veld</th><th>Oud</th><th>Nieuw</th><th>Bron</th></tr></thead>
<tbody>
{% for h in history %}
<tr>
<td class="muted">{{ h.created_at|dt }}</td>
<td>{{ LABELS.get(h.field, h.field) }}</td>
{% if h.field == 'price' %}<td class="num">{{ h.old_value|float|euro }}</td><td class="num">{{ h.new_value|float|euro }}
{% set diff = h.new_value|float - h.old_value|float %}{% if h.old_value|float %}<span class="muted">({{ '+' if diff > 0 }}{{ (diff / h.old_value|float * 100)|round(1)|nr }}%)</span>{% endif %}</td>
{% elif h.field == 'created' %}<td></td><td class="num">{{ h.new_value|float|euro }}</td>
{% elif h.field == 'active' %}<td>{{ 'ja' if h.old_value == '1' else 'nee' }}</td><td>{{ 'ja' if h.new_value == '1' else 'nee' }}</td>
{% else %}<td>{{ h.old_value|truncate(60) }}</td><td>{{ h.new_value|truncate(60) }}</td>{% endif %}
<td class="muted">{{ h.source }}{% if h.user_name %} · {{ h.user_name }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Nog geen mutaties.</p>{% endif %}
</div>
<form method="post" action="{{ url_for('product_delete', pid=p.id) }}" onsubmit="return confirm('{{ 'Dit product staat in offertes en wordt op inactief gezet.' if used else 'Product verwijderen?' }}')">
<button class="btn quiet small">{{ 'Archiveren' if used else 'Product verwijderen' }}</button>
</form>
{% endif %}
{% endblock %}
+44
View File
@@ -0,0 +1,44 @@
{% extends 'base.html' %}
{% block title %}Producten{% endblock %}
{% block content %}
<div class="head">
<div><div class="kicker">Assortiment</div><h1>Producten</h1></div>
<div class="actions">
<form class="search" method="get">
<input type="hidden" name="show" value="{{ show }}">
<input type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, SKU, leverancier…" aria-label="Zoeken">
</form>
<a class="btn ghost" href="{{ url_for('products_export') }}">Exporteer CSV</a>
<a class="btn ghost" href="{{ url_for('import_start', kind='products') }}">Importeren</a>
<a class="btn" href="{{ url_for('product_edit') }}">Nieuw product</a>
</div>
</div>
<div class="filters">
{% for k, l in [('active', 'Actief'), ('inactive', 'Inactief'), ('all', 'Alle')] %}
<a href="{{ url_for('products', show=k, q=term or None) }}" class="{{ 'on' if show == k }}">{{ l }}</a>
{% endfor %}
<span class="muted">{{ rows|length }} product(en)</span>
</div>
<div class="card">
{% if rows %}
<div class="table-wrap"><table>
<thead><tr><th></th><th>Naam</th><th>SKU</th><th>Leverancier</th><th>Eenheid</th><th class="num">Prijs excl.</th><th class="num">Btw</th><th>Bijgewerkt</th><th>Status</th></tr></thead>
<tbody>
{% for p in rows %}
<tr class="link" onclick="location='{{ url_for('product_edit', pid=p.id) }}'">
<td style="width:64px">{% if p.image %}<img class="thumb-sm" src="{{ url_for('upload_file', name=p.image) }}" alt="">{% else %}<span class="thumb-sm"></span>{% endif %}</td>
<td><a href="{{ url_for('product_edit', pid=p.id) }}">{{ p.name }}</a><div class="muted">{{ p.description|truncate(90) }}</div></td>
<td><code>{{ p.sku }}</code></td>
<td>{% if p.supplier_id %}<a href="{{ url_for('relation_view', rid=p.supplier_id) }}" onclick="event.stopPropagation()">{{ p.supplier }}</a>{% else %}{{ p.supplier }}{% endif %}</td>
<td>{{ p.unit }}</td>
<td class="num">{{ p.price|euro }}</td>
<td class="num">{{ p.vat|nr }}%</td>
<td class="muted">{{ (p.updated_at or p.created_at)|dt('%d-%m-%Y') }}</td>
<td>{% if not p.active %}<span class="chip verloren">Inactief</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">{{ 'Geen producten gevonden.' if term or show != 'active' else 'Nog geen producten. Voeg ze toe of importeer een CSV; daarna kies je ze in offertes.' }}</p>{% endif %}
</div>
{% endblock %}
+125
View File
@@ -0,0 +1,125 @@
{% extends 'base.html' %}
{% block title %}Offerte {{ qt.number }}{% endblock %}
{% macro row(l) %}
<tr>
<td>
<select name="l_product" class="prod" aria-label="Product">
<option value="">Vrije regel</option>
{% for p in products %}
<option value="{{ p.id }}" {{ 'selected' if l.product_id == p.id }}
data-desc="{{ p.name }}{% if p.description %}&#10;{{ p.description }}{% endif %}"
data-price="{{ p.price }}" data-vat="{{ p.vat|nr|replace(',', '.') }}" data-unit="{{ p.unit }}">{{ p.name }}</option>
{% endfor %}
</select>
</td>
<td><textarea name="l_desc" rows="1" aria-label="Omschrijving">{{ l.description }}</textarea></td>
<td><input type="number" step="any" name="l_qty" value="{{ l.qty|default(1) }}" aria-label="Aantal"></td>
<td><input name="l_unit" value="{{ l.unit }}" aria-label="Eenheid"></td>
<td><input type="number" step="0.01" name="l_price" value="{{ l.price }}" aria-label="Prijs">
{% if l.product_id and prices.get(l.product_id) is not none and prices[l.product_id] != l.price %}<p class="hint" style="margin:.2rem 0 0">Actueel: {{ prices[l.product_id]|euro }}</p>{% endif %}</td>
<td><select name="l_vat" aria-label="Btw">{% for v in [21, 9, 0] %}<option value="{{ v }}" {{ 'selected' if l.vat|default(21) == v }}>{{ v }}%</option>{% endfor %}</select></td>
<td class="num amt"></td>
<td><button type="button" class="linkbtn del" aria-label="Regel verwijderen">✕</button></td>
</tr>
{% endmacro %}
{% block content %}
<form method="post">
<div class="head">
<div><a class="label" href="{{ url_for('quotes') }}">← Offertes</a><h1>Offerte {{ qt.number }}</h1></div>
<div class="actions">
<button class="btn ghost" name="then" value="print">Opslaan en afdrukken</button>
<button class="btn">Opslaan</button>
</div>
</div>
<div class="card">
<div class="fields">
<div><label for="contact">Klant</label>
<select id="contact" name="contact_id">
<option value="">— Kies een contact —</option>
{% for c in contacts %}<option value="{{ c.id }}" {{ 'selected' if c.id == qt.contact_id }}>{{ c.name }}{% if c.company %} ({{ c.company }}){% endif %}</option>{% endfor %}
</select>
</div>
<div><label for="status">Status</label>
<select id="status" name="status">{% for k, l in QUOTE_STATUSES %}<option value="{{ k }}" {{ 'selected' if qt.status == k }}>{{ l }}</option>{% endfor %}</select>
<p class="hint">Op "Verzonden" zetten = zichtbaar in het klantportaal.</p>
</div>
<div><label for="date">Datum</label><input id="date" type="date" name="date" value="{{ qt.date }}"></div>
<div><label for="valid">Geldig tot</label><input id="valid" type="date" name="valid_until" value="{{ qt.valid_until or '' }}"></div>
<div class="full"><label for="title">Onderwerp</label><input id="title" name="title" value="{{ qt.title }}" placeholder="Bijv. Mantelzorgwoning model Amstel"></div>
<div class="full"><label for="intro">Inleiding</label><textarea id="intro" name="intro">{{ qt.intro }}</textarea></div>
</div>
</div>
<div class="card">
<h2>Regels</h2>
<div class="table-wrap"><table class="lines">
<thead><tr><th style="min-width:170px">Product</th><th style="min-width:240px">Omschrijving</th><th style="width:90px">Aantal</th><th style="width:100px">Eenheid</th><th style="width:130px">Prijs excl.</th><th style="width:90px">Btw</th><th class="num" style="width:120px">Totaal</th><th></th></tr></thead>
<tbody id="rows">{% for l in lines %}{{ row(l) }}{% endfor %}</tbody>
</table></div>
<div class="actions"><button type="button" class="btn ghost small" id="add">Regel toevoegen</button></div>
<table class="totals">
<tr><td>Subtotaal</td><td class="num" id="t-sub"></td></tr>
<tbody id="t-vat"></tbody>
<tr class="grand"><td>Totaal</td><td class="num" id="t-total"></td></tr>
</table>
</div>
<div class="card">
<label for="notes">Opmerkingen en voorwaarden</label>
<textarea id="notes" name="notes">{{ qt.notes }}</textarea>
</div>
</form>
<form method="post" action="{{ url_for('quote_delete', qid=qt.id) }}" onsubmit="return confirm('Offerte verwijderen?')">
<button class="btn quiet small">Offerte verwijderen</button>
</form>
<template id="tpl">{{ row({}) }}</template>
<script>
const rows = document.getElementById('rows'), tpl = document.getElementById('tpl');
const fmt = n => '€ ' + n.toLocaleString('nl-NL', {minimumFractionDigits: 2, maximumFractionDigits: 2});
const val = (tr, name) => parseFloat(tr.querySelector(`[name=${name}]`).value) || 0;
function recalc() {
let sub = 0; const vat = {};
rows.querySelectorAll('tr').forEach(tr => {
const amount = val(tr, 'l_qty') * val(tr, 'l_price'), rate = val(tr, 'l_vat');
sub += amount;
vat[rate] = (vat[rate] || 0) + amount * rate / 100;
tr.querySelector('.amt').textContent = fmt(amount);
});
document.getElementById('t-sub').textContent = fmt(sub);
const tv = document.getElementById('t-vat');
tv.innerHTML = '';
let total = sub;
Object.keys(vat).sort((a, b) => b - a).forEach(rate => {
total += vat[rate];
const r = tv.insertRow();
r.insertCell().textContent = `Btw ${rate}%`;
const c = r.insertCell(); c.className = 'num'; c.textContent = fmt(vat[rate]);
});
document.getElementById('t-total').textContent = fmt(total);
}
rows.addEventListener('input', recalc);
rows.addEventListener('change', e => {
if (e.target.classList.contains('prod') && e.target.value) {
const o = e.target.selectedOptions[0], tr = e.target.closest('tr');
tr.querySelector('[name=l_desc]').value = o.dataset.desc;
tr.querySelector('[name=l_price]').value = o.dataset.price;
tr.querySelector('[name=l_vat]').value = o.dataset.vat;
tr.querySelector('[name=l_unit]').value = o.dataset.unit;
}
recalc();
});
rows.addEventListener('click', e => {
if (e.target.closest('.del')) { e.target.closest('tr').remove(); recalc(); }
});
document.getElementById('add').addEventListener('click', () => {
const tr = tpl.content.firstElementChild.cloneNode(true);
rows.appendChild(tr);
document.dispatchEvent(new CustomEvent('crm:row', { detail: tr }));
recalc();
});
if (!rows.children.length) document.getElementById('add').click(); else recalc();
</script>
{% endblock %}
+129
View File
@@ -0,0 +1,129 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Offerte {{ qt.number }}{% if c %} · {{ c.name }}{% endif %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Kantumruy+Pro:wght@500;600&family=Mukta:wght@400;600&display=swap" rel="stylesheet">
<style>
@page { size: A4; margin: 16mm 15mm; }
* { box-sizing: border-box; }
body { margin: 0; font-family: "Mukta", system-ui, sans-serif; color: #212121; font-size: 14px; line-height: 1.55; background: #f0f1ec; font-variant-numeric: lining-nums tabular-nums; }
.bar { position: sticky; top: 0; z-index: 1; background: #fff; border-bottom: 1px solid rgba(0,0,0,.08); padding: 12px 16px; display: flex; gap: 12px; justify-content: center; align-items: center; flex-wrap: wrap; }
.bar a { color: #b95913; font-weight: 600; text-decoration: none; }
.btn { font: 600 15px/1 "Mukta", sans-serif; padding: 12px 22px; border-radius: 50px; border: 1px solid #b95913; background: #b95913; color: #fff; cursor: pointer; }
.btn.ghost { background: #fff; color: #b95913; border-color: rgba(185,89,19,.35); }
.page { background: #fff; max-width: 210mm; margin: 24px auto; padding: 16mm 15mm; border-radius: 20px; }
header { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; }
header img { width: 210px; max-width: 45%; }
.company { text-align: right; color: #5c6670; font-size: 13px; }
.company strong { color: #212121; }
.meta { display: flex; justify-content: space-between; gap: 24px; margin: 40px 0 28px; }
.label { font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: #5c6670; }
.kv td { padding: 1px 0 1px 16px; }
.kv td:first-child { padding-left: 0; }
h1 { font-family: "Kantumruy Pro", sans-serif; font-weight: 500; font-size: 26px; line-height: 1.15; margin: 0 0 12px; }
h1::before { content: ""; display: block; width: 3rem; height: 3px; background: #b95913; margin-bottom: 14px; }
.intro, .notes { white-space: pre-line; }
table.lines { width: 100%; border-collapse: collapse; margin: 24px 0 8px; }
.lines th { text-align: left; font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: #5c6670; padding: 8px; border-bottom: 1px solid rgba(0,0,0,.15); }
.lines td { padding: 10px 8px; border-bottom: 1px solid rgba(0,0,0,.08); vertical-align: top; }
.lines tr { break-inside: avoid; }
.desc { white-space: pre-line; }
.desc::first-line { font-weight: 600; }
.num { text-align: right; white-space: nowrap; }
.totals { margin-left: auto; width: 300px; border-collapse: collapse; }
.totals td { padding: 4px 8px; }
.totals .grand td { font-family: "Kantumruy Pro", sans-serif; font-weight: 600; font-size: 18px; border-top: 2px solid #212121; padding-top: 8px; }
.notes { margin-top: 32px; }
.sign { margin-top: 40px; display: flex; gap: 48px; }
.sign div { flex: 1; border-top: 1px solid rgba(0,0,0,.3); padding-top: 6px; color: #5c6670; font-size: 12px; }
footer { margin-top: 40px; padding-top: 12px; border-top: 1px solid rgba(0,0,0,.08); color: #5c6670; font-size: 12px; text-align: center; }
@media print {
body { background: #fff; }
.bar { display: none; }
.page { margin: 0; padding: 0; max-width: none; border-radius: 0; }
}
@media (max-width: 640px) {
.page { padding: 20px 16px; margin: 0; border-radius: 0; }
header, .meta { flex-direction: column; }
.company { text-align: left; }
.totals { width: 100%; }
}
</style>
</head>
<body>
<div class="bar">
{% if portal %}
<a href="{{ url_for('portal_home') }}">← Terug naar het portaal</a>
{% if qt.status == 'verzonden' %}
<form method="post" action="{{ url_for('portal_quote_answer', qid=qt.id, choice='accept') }}" onsubmit="return confirm('Wilt u deze offerte accepteren?')"><button class="btn">Offerte accepteren</button></form>
<form method="post" action="{{ url_for('portal_quote_answer', qid=qt.id, choice='decline') }}"><button class="btn ghost">Afwijzen</button></form>
{% endif %}
{% else %}
<a href="{{ url_for('quote_edit', qid=qt.id) }}">← Terug naar bewerken</a>
{% endif %}
<button class="btn {{ 'ghost' if portal and qt.status == 'verzonden' }}" onclick="window.print()">Afdrukken / opslaan als PDF</button>
</div>
<div class="page">
<header>
<img src="{{ url_for('static', filename='logo.svg') }}" alt="{{ s.company_name }}">
<div class="company">
<strong>{{ s.company_name }}</strong><br>
{{ [s.company_address, s.company_postcode_city, s.company_phone, s.company_email, s.company_website]|select|join('<br>'|safe) }}
</div>
</header>
<div class="meta">
<div>
<div class="label">Offerte voor</div>
{% if c %}
<strong>{{ c.name }}</strong><br>
{{ [c.relation_name if c.relation_kind == 'klant' and c.relation_name != c.name, c.company, c.address, (c.postcode ~ ' ' ~ c.city)|trim]|select|join('<br>'|safe) }}
{% else %}—{% endif %}
</div>
<table class="kv">
<tr><td class="label">Offertenummer</td><td>{{ qt.number }}</td></tr>
<tr><td class="label">Datum</td><td>{{ qt.date|dt('%d-%m-%Y') }}</td></tr>
{% if qt.valid_until %}<tr><td class="label">Geldig tot</td><td>{{ qt.valid_until|dt('%d-%m-%Y') }}</td></tr>{% endif %}
</table>
</div>
<h1>Offerte{% if qt.title %}: {{ qt.title }}{% endif %}</h1>
{% if qt.intro %}<div class="intro">{{ qt.intro }}</div>{% endif %}
<table class="lines">
<thead><tr><th>Omschrijving</th><th class="num">Aantal</th><th class="num">Prijs</th><th class="num">Btw</th><th class="num">Totaal</th></tr></thead>
<tbody>
{% for l in lines %}
<tr>
<td class="desc">{{ l.description }}</td>
<td class="num">{{ l.qty|nr }} {{ l.unit }}</td>
<td class="num">{{ l.price|euro }}</td>
<td class="num">{{ l.vat|nr }}%</td>
<td class="num">{{ (l.qty * l.price)|euro }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<table class="totals">
<tr><td>Subtotaal excl. btw</td><td class="num">{{ t.subtotal|euro }}</td></tr>
{% for rate, amount in t.vat %}<tr><td>Btw {{ rate|nr }}%</td><td class="num">{{ amount|euro }}</td></tr>{% endfor %}
<tr class="grand"><td>Totaal</td><td class="num">{{ t.total|euro }}</td></tr>
</table>
{% if qt.notes %}<div class="notes"><div class="label">Opmerkingen en voorwaarden</div>{{ qt.notes }}</div>{% endif %}
<div class="sign"><div>Datum en handtekening opdrachtgever</div><div>Namens {{ s.company_name }}</div></div>
<footer>
{% if s.quote_footer %}<div style="white-space:pre-line;margin-bottom:6px">{{ s.quote_footer }}</div>{% endif %}
{{ [('KvK ' ~ s.company_kvk) if s.company_kvk, ('Btw ' ~ s.company_btw) if s.company_btw, ('IBAN ' ~ s.company_iban) if s.company_iban]|select|join(' · ') }}
</footer>
</div>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
{% extends 'base.html' %}
{% block title %}Offertes{% endblock %}
{% block content %}
<div class="head">
<div><div class="kicker">Verkoop</div><h1>Offertes</h1></div>
<form method="post" action="{{ url_for('quote_new') }}" class="inline"><button class="btn">Nieuwe offerte</button></form>
</div>
<div class="filters">
<a href="{{ url_for('quotes') }}" class="{{ 'on' if not status }}">Alle</a>
{% for k, l in QUOTE_STATUSES %}<a href="{{ url_for('quotes', status=k) }}" class="{{ 'on' if status == k }}">{{ l }}</a>{% endfor %}
</div>
<div class="card">
{% if rows %}
<div class="table-wrap"><table>
<thead><tr><th>Nummer</th><th>Klant</th><th>Onderwerp</th><th>Datum</th><th>Status</th><th class="num">Totaal incl.</th></tr></thead>
<tbody>
{% for qt in rows %}
<tr class="link" onclick="location='{{ url_for('quote_edit', qid=qt.id) }}'">
<td><a href="{{ url_for('quote_edit', qid=qt.id) }}">{{ qt.number }}</a></td>
<td>{{ qt.contact_name or '—' }}</td>
<td>{{ qt.title }}</td>
<td>{{ qt.date|dt('%d-%m-%Y') }}</td>
<td><span class="chip {{ qt.status }}">{{ QSTATUS.get(qt.status, qt.status) }}</span></td>
<td class="num">{{ qt.total|euro }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Nog geen offertes.</p>{% endif %}
</div>
{% endblock %}
+133
View File
@@ -0,0 +1,133 @@
{% extends 'base.html' %}
{% block title %}{{ r.name }}{% endblock %}
{% block content %}
<div class="head">
<div>
<a class="label" href="{{ url_for('relations', kind=r.kind) }}">← {{ 'Klanten' if r.kind == 'klant' else 'Partners' }}</a>
<h1>{{ r.name }}</h1>
<div class="muted"><span class="chip">{{ RKIND.get(r.kind) }}</span> {{ [r.type, r.city]|select|join(' · ') }}</div>
</div>
<div class="actions">
{% if r.kind == 'klant' and members %}
<form method="post" action="{{ url_for('quote_new') }}" class="inline">
<input type="hidden" name="contact_id" value="{{ members[0].id }}">
<button class="btn ghost">Nieuwe offerte</button>
</form>
{% endif %}
<a class="btn" href="{{ url_for('relation_edit', rid=r.id) }}">Bewerken</a>
</div>
</div>
<div class="two">
<div>
<div class="card">
<h2>Contactpersonen</h2>
<div class="members">
{% for m in members %}
<div class="member">
<div>
<a href="{{ url_for('contact_view', cid=m.id) }}">{{ m.name }}</a>
{% if m.relation_role %}<span class="muted">· {{ m.relation_role }}</span>{% endif %}
<div class="muted">{{ [m.email, m.phone]|select|join(' · ') }}</div>
</div>
<div class="actions" style="margin:0">
{% if m.type != 'partner' %}<span class="chip {{ m.status }}">{{ STATUS.get(m.status) }}</span>{% endif %}
<form method="post" action="{{ url_for('relation_member_remove', rid=r.id, cid=m.id) }}" class="inline" onsubmit="return confirm('{{ m.name }} loskoppelen van {{ r.name }}?')">
<button class="linkbtn">Loskoppelen</button>
</form>
</div>
</div>
{% else %}
<p class="empty">Nog geen contactpersonen.</p>
{% endfor %}
</div>
<form method="post" action="{{ url_for('relation_member', rid=r.id) }}" class="actions">
{% if candidates %}
<select name="contact_id" style="width:auto;flex:1;min-width:180px" aria-label="Bestaand contact" required>
<option value="">Bestaand contact toevoegen…</option>
{% for c in candidates %}<option value="{{ c.id }}">{{ c.name }}{% if c.email %} ({{ c.email }}){% endif %}</option>{% endfor %}
</select>
<input name="relation_role" placeholder="Rol" style="width:160px" aria-label="Rol">
<button class="btn ghost small">Toevoegen</button>
{% endif %}
<a class="btn small" href="{{ url_for('contact_new', relation_id=r.id, company=r.name if r.kind == 'partner' else None) }}">Nieuwe contactpersoon</a>
</form>
</div>
{% if r.kind == 'partner' %}
<div class="card">
<h2>Producten van deze leverancier</h2>
{% if products %}
<div class="table-wrap"><table>
<thead><tr><th>SKU</th><th>Naam</th><th class="num">Prijs excl.</th><th>Bijgewerkt</th></tr></thead>
<tbody>
{% for p in products %}
<tr class="link" onclick="location='{{ url_for('product_edit', pid=p.id) }}'">
<td><code>{{ p.sku }}</code></td>
<td><a href="{{ url_for('product_edit', pid=p.id) }}">{{ p.name }}</a>{% if not p.active %} <span class="chip verloren">Inactief</span>{% endif %}</td>
<td class="num">{{ p.price|euro }}</td>
<td class="muted">{{ (p.updated_at or p.created_at)|dt('%d-%m-%Y') }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Nog geen producten gekoppeld. Vul deze naam in als leverancier bij een product of in een import.</p>{% endif %}
</div>
{% endif %}
<div class="card">
<h2>Recente activiteit</h2>
<ul class="timeline">
{% for a in activities %}
<li>
<div class="meta">{{ ACT.get(a.type, a.type) }} · <a href="{{ url_for('contact_view', cid=a.contact_id) }}">{{ a.contact_name }}</a> · {{ a.created_at|dt }}{% if a.user_name %} · {{ a.user_name }}{% endif %}</div>
<div class="body">{{ a.body }}</div>
</li>
{% else %}
<li class="empty">Nog geen activiteit.</li>
{% endfor %}
</ul>
</div>
</div>
<div>
<div class="card">
<h2>Gegevens</h2>
<dl class="props">
{% if r.type %}<dt>Type</dt><dd>{{ r.type }}</dd>{% endif %}
{% if r.email %}<dt>E-mail</dt><dd><a href="mailto:{{ r.email }}">{{ r.email }}</a></dd>{% endif %}
{% if r.phone %}<dt>Telefoon</dt><dd><a href="tel:{{ r.phone }}">{{ r.phone }}</a></dd>{% endif %}
{% if r.address or r.city %}<dt>Adres</dt><dd>{{ [r.address, (r.postcode ~ ' ' ~ r.city)|trim]|select|join('\n') }}</dd>{% endif %}
{% if r.website %}<dt>Website</dt><dd><a href="{{ r.website if r.website.startswith('http') else 'https://' ~ r.website }}" target="_blank" rel="noopener">{{ r.website }}</a></dd>{% endif %}
{% if r.kvk %}<dt>KvK</dt><dd>{{ r.kvk }}</dd>{% endif %}
<dt>Aangemaakt</dt><dd>{{ r.created_at|dt('%d-%m-%Y') }}</dd>
</dl>
{% if r.notes %}<h3>Notities</h3><p style="white-space:pre-line;margin:0">{{ r.notes }}</p>{% endif %}
</div>
{% if tasks %}
<div class="card">
<h2>Open taken</h2>
{% for t in tasks %}
<form method="post" action="{{ url_for('task_done', tid=t.id) }}" class="task">
<button class="check" aria-label="Afronden"></button>
<div><div>{{ t.title }}</div><div class="muted">{{ t.contact_name }}{% if t.due_date %} · <span class="{{ 'overdue' if t.due_date < today }}">{{ t.due_date|dt('%d-%m-%Y') }}</span>{% endif %}</div></div>
</form>
{% endfor %}
</div>
{% endif %}
{% if r.kind == 'klant' %}
<div class="card">
<h2>Offertes</h2>
{% for qt in quotes %}
<p style="margin:0 0 .6rem"><a href="{{ url_for('quote_edit', qid=qt.id) }}">{{ qt.number }}</a> <span class="chip {{ qt.status }}">{{ QSTATUS.get(qt.status) }}</span><br>
<span class="muted">{{ qt.contact_name }} · {{ qt.date|dt('%d-%m-%Y') }} · {{ qt.total|euro }}</span></p>
{% else %}
<p class="empty">Nog geen offertes.</p>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endblock %}
+39
View File
@@ -0,0 +1,39 @@
{% extends 'base.html' %}
{% block title %}{{ r.name or ('Nieuwe ' ~ RKIND.get(r.kind, 'relatie')|lower) }}{% endblock %}
{% block content %}
<div class="head">
<div>
<a class="label" href="{{ url_for('relation_view', rid=r.id) if r.id else url_for('relations', kind=r.kind) }}">← Terug</a>
<h1>{{ 'Relatie bewerken' if r.id else ('Nieuwe ' ~ RKIND.get(r.kind, 'relatie')|lower) }}</h1>
</div>
</div>
<form method="post" class="card">
<div class="fields">
<div class="full"><label for="name">Naam *</label><input id="name" name="name" value="{{ r.name }}" required placeholder="{{ 'Bijv. Familie Jansen' if r.kind == 'klant' else 'Bijv. Houthandel De Vries' }}"></div>
<div><label for="kind">Soort</label>
<select id="kind" name="kind">{% for k, l in RELATION_KINDS %}<option value="{{ k }}" {{ 'selected' if r.kind == k }}>{{ l }}</option>{% endfor %}</select>
</div>
<div><label for="type">Type</label>
<input id="type" name="type" value="{{ r.type }}" list="types" autocomplete="off">
<datalist id="types">{% for t in types %}<option value="{{ t }}">{% endfor %}</datalist>
</div>
<div><label for="email">E-mail</label><input id="email" type="email" name="email" value="{{ r.email }}"></div>
<div><label for="phone">Telefoon</label><input id="phone" name="phone" value="{{ r.phone }}"></div>
<div class="full"><label for="address">Adres</label><input id="address" name="address" value="{{ r.address }}"></div>
<div><label for="postcode">Postcode</label><input id="postcode" name="postcode" value="{{ r.postcode }}"></div>
<div><label for="city">Plaats</label><input id="city" name="city" value="{{ r.city }}"></div>
<div><label for="website">Website</label><input id="website" name="website" value="{{ r.website }}"></div>
<div><label for="kvk">KvK-nummer</label><input id="kvk" name="kvk" value="{{ r.kvk }}"></div>
<div class="full"><label for="notes">Notities</label><textarea id="notes" name="notes">{{ r.notes }}</textarea></div>
</div>
<div class="actions">
<button class="btn">Opslaan</button>
<a class="btn quiet" href="{{ url_for('relation_view', rid=r.id) if r.id else url_for('relations', kind=r.kind) }}">Annuleren</a>
</div>
</form>
{% if r.id %}
<form method="post" action="{{ url_for('relation_delete', rid=r.id) }}" onsubmit="return confirm('Relatie verwijderen? Contactpersonen en producten blijven bestaan.')">
<button class="btn quiet small">Relatie verwijderen</button>
</form>
{% endif %}
{% endblock %}
+47
View File
@@ -0,0 +1,47 @@
{% extends 'base.html' %}
{% block title %}{{ 'Klanten' if kind == 'klant' else 'Partners' }}{% endblock %}
{% block content %}
<div class="head">
<div>
<div class="kicker">Relaties</div>
<h1>{{ 'Klanten' if kind == 'klant' else 'Partners' }}</h1>
<div class="muted">{{ 'Huishoudens, families en bedrijven met hun contactpersonen.' if kind == 'klant' else 'Leveranciers, aannemers en andere partners.' }}</div>
</div>
<div class="actions">
<form class="search" method="get">
<input type="hidden" name="kind" value="{{ kind }}">
{% if type %}<input type="hidden" name="type" value="{{ type }}">{% endif %}
<input type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, plaats, persoon…" aria-label="Zoeken">
</form>
<a class="btn ghost" href="{{ url_for('import_start', kind='relations') }}">Importeren</a>
<a class="btn" href="{{ url_for('relation_edit', kind=kind, type=type or None) }}">{{ 'Nieuwe klant' if kind == 'klant' else 'Nieuwe partner' }}</a>
</div>
</div>
<div class="filters">
<a href="{{ url_for('relations', kind=kind, q=term or None) }}" class="{{ 'on' if not type }}">Alle</a>
{% for t in types %}
<a href="{{ url_for('relations', kind=kind, type=t, q=term or None) }}" class="{{ 'on' if type == t }}">{{ t }}{% if counts.get(t) %} <span class="muted">{{ counts[t] }}</span>{% endif %}</a>
{% endfor %}
</div>
<div class="card">
{% if rows %}
<div class="table-wrap"><table>
<thead><tr><th>Naam</th><th>Type</th><th>Contactpersonen</th><th>Plaats</th><th>Contact</th>{% if kind == 'partner' %}<th class="num">Producten</th>{% endif %}</tr></thead>
<tbody>
{% for r in rows %}
<tr class="link" onclick="location='{{ url_for('relation_view', rid=r.id) }}'">
<td><a href="{{ url_for('relation_view', rid=r.id) }}">{{ r.name }}</a></td>
<td>{% if r.type %}<span class="chip">{{ r.type }}</span>{% endif %}</td>
<td>{{ r.member_names or '—' }}{% if r.members > 4 %} <span class="muted">+{{ r.members - 4 }}</span>{% endif %}</td>
<td>{{ r.city }}</td>
<td class="muted">{{ [r.phone, r.email]|select|join(' · ') }}</td>
{% if kind == 'partner' %}<td class="num">{{ r.products or '' }}</td>{% endif %}
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Nog geen {{ 'klantrelaties. Een lead die je omzet naar klant komt hier vanzelf.' if kind == 'klant' else 'partners. Leveranciers uit een productimport komen hier vanzelf.' }}</p>{% endif %}
</div>
{% endblock %}
+129
View File
@@ -0,0 +1,129 @@
{% extends 'base.html' %}
{% block title %}Instellingen{% endblock %}
{% macro field(key, label, type='text', placeholder='', full=false) %}
<div class="{{ 'full' if full }}"><label for="{{ key }}">{{ label }}</label><input id="{{ key }}" type="{{ type }}" name="{{ key }}" value="{{ s.get(key, '') }}" placeholder="{{ placeholder }}"></div>
{% endmacro %}
{% block content %}
<div class="head"><div><div class="kicker">Beheer</div><h1>Instellingen</h1></div></div>
<form method="post">
<div class="card">
<h2>Bedrijfsgegevens</h2>
<p class="hint" style="margin-top:-.4rem;margin-bottom:1rem">Deze gegevens staan op de offertes.</p>
<div class="fields">
{{ 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') }}
</div>
</div>
<div class="card">
<h2>Offertes</h2>
<div class="fields">
{{ field('quote_prefix', 'Voorvoegsel nummer', placeholder='OFF') }}
{{ field('quote_valid_days', 'Standaard geldig (dagen)', 'number') }}
<div class="full"><label for="quote_intro">Standaard inleiding</label><textarea id="quote_intro" name="quote_intro">{{ s.quote_intro }}</textarea></div>
<div class="full"><label for="quote_footer">Voettekst (bijv. betalingsvoorwaarden)</label><textarea id="quote_footer" name="quote_footer">{{ s.quote_footer }}</textarea></div>
</div>
</div>
<div class="card" id="mail">
<h2>E-mail koppelen</h2>
<p class="hint" style="margin-top:-.4rem;margin-bottom:1rem">Gebruik voor Gmail of Microsoft 365 een app-wachtwoord. Wachtwoorden staan in de database: houd de map <code>data/</code> privé.</p>
<h3>Ontvangen (IMAP)</h3>
<div class="fields">
{{ field('imap_host', 'Server', placeholder='imap.gmail.com') }}
{{ field('imap_port', 'Poort', 'number', '993') }}
{{ field('imap_user', 'Gebruikersnaam', placeholder='info@vanemous.nl') }}
<div><label for="imap_pass">Wachtwoord</label><input id="imap_pass" type="password" name="imap_pass" placeholder="{{ '•••••••• (ingesteld)' if s.imap_pass else '' }}" autocomplete="new-password"></div>
{{ field('imap_folder', 'Map', placeholder='INBOX') }}
{{ field('imap_days', 'Aantal dagen terug ophalen', 'number', '30') }}
</div>
<h3>Versturen (SMTP)</h3>
<div class="fields">
{{ field('smtp_host', 'Server', placeholder='smtp.gmail.com') }}
{{ field('smtp_port', 'Poort', 'number', '587') }}
{{ field('smtp_user', 'Gebruikersnaam', placeholder='leeg = zelfde als IMAP') }}
<div><label for="smtp_pass">Wachtwoord</label><input id="smtp_pass" type="password" name="smtp_pass" placeholder="{{ '•••••••• (ingesteld)' if s.smtp_pass else 'leeg = zelfde als IMAP' }}" autocomplete="new-password"></div>
{{ field('smtp_from', 'Afzender', placeholder='Van Emous <info@vanemous.nl>', full=true) }}
</div>
</div>
<div class="card">
<h2>Formulieren en API</h2>
<div class="fields">
{{ field('allowed_origins', 'Toegestane websites (komma-gescheiden)', placeholder='https://www.vanemous.nl', full=true) }}
<div><label for="api_assign_user">Nieuwe leads toewijzen aan</label>
<select id="api_assign_user" name="api_assign_user">
<option value="">— Niemand —</option>
{% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if s.api_assign_user == u.id|string }}>{{ u.name }}</option>{% endfor %}
</select>
</div>
</div>
</div>
<div class="card">
<h2>Relatietypes</h2>
<p class="hint" style="margin-top:-.4rem;margin-bottom:1rem">Komma-gescheiden. Bestaande relaties houden hun type als je er een weghaalt.</p>
<div class="fields">
<div class="full"><label for="klant_types">Klanten (bundels)</label><input id="klant_types" name="klant_types" value="{{ s.klant_types or 'Huishouden / familie, Particulier, Zakelijk, Zorginstelling' }}"></div>
<div class="full"><label for="partner_types">Partners</label><input id="partner_types" name="partner_types" value="{{ s.partner_types or 'Leverancier, Aannemer, Installateur, Architect / adviseur, Gemeente, Overig' }}"></div>
</div>
</div>
<div class="actions" style="margin-bottom:1.5rem"><button class="btn">Instellingen opslaan</button></div>
</form>
<div class="card">
<h2>API-koppeling</h2>
<dl class="props">
<dt>Endpoint</dt><dd><code>POST {{ api_url }}</code></dd>
<dt>API-sleutel</dt><dd><code>{{ s.api_key }}</code></dd>
<dt>Klantportaal</dt><dd><code>{{ portal_url }}</code></dd>
</dl>
<p>Stuur de sleutel mee als header <code>X-API-Key</code> of als <code>?key=</code> 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 <code>multipart/form-data</code>. Een ontwerp uit de configurator (een object met <code>"type": "house-design"</code>) wordt als bestand bij de lead opgeslagen. Leads met een bekend e-mailadres worden bij het bestaande contact gezet.</p>
<h3>Gewoon HTML-formulier</h3>
<pre>{% filter forceescape %}<form action="{{ api_url }}?key={{ s.api_key }}" method="post" enctype="multipart/form-data">
<input name="naam" required>
<input name="email" type="email">
<input name="telefoon">
<textarea name="bericht"></textarea>
<input type="file" name="foto" multiple>
<input type="hidden" name="bron" value="Contactformulier">
<input type="hidden" name="redirect" value="https://www.vanemous.nl/bedankt">
<input name="_gotcha" style="display:none" tabindex="-1" autocomplete="off">
<button>Versturen</button>
</form>{% endfilter %}</pre>
<h3>JavaScript (fetch)</h3>
<pre>{% 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 %}</pre>
<h3>Vanaf een server (aanbevolen: de sleutel blijft geheim)</h3>
<pre>{% 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 %}</pre>
<h3>Producten bijwerken (prijsmutaties)</h3>
<p>Werkt producten bij op SKU, bijvoorbeeld vanuit een prijsscript van een leverancier. Veldnamen zoals bij de import. <code>?deactivate_missing=1</code> zet producten die niet in de lijst staan op inactief.</p>
<pre>{% 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 %}</pre>
<form method="post" onsubmit="return confirm('Nieuwe sleutel maken? Bestaande formulieren werken dan niet meer.')">
<input type="hidden" name="action" value="apikey">
<button class="btn quiet small">Nieuwe API-sleutel maken</button>
</form>
</div>
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% block title %}{{ u.name or 'Nieuwe gebruiker' }}{% endblock %}
{% block content %}
<div class="head">
<div><a class="label" href="{{ url_for('users') }}">← Gebruikers</a><h1>{{ 'Gebruiker bewerken' if u.id else 'Nieuwe gebruiker' }}</h1></div>
</div>
<form method="post" class="card">
<div class="fields">
<div><label for="name">Naam *</label><input id="name" name="name" value="{{ u.name }}" required></div>
<div><label for="email">E-mailadres (inlognaam) *</label><input id="email" type="email" name="email" value="{{ u.email }}" required></div>
<div><label for="role">Rol</label>
<select id="role" name="role">{% for k, l in ROLES %}<option value="{{ k }}" {{ 'selected' if u.role == k }}>{{ l }}</option>{% endfor %}</select>
</div>
<div><label for="password">{{ 'Nieuw wachtwoord' if u.id else 'Wachtwoord *' }}</label>
<input id="password" type="password" name="password" minlength="8" {{ 'required' if not u.id }} autocomplete="new-password">
{% if u.id %}<p class="hint">Leeg laten om het huidige wachtwoord te houden.</p>{% endif %}</div>
<div class="full"><label><input type="checkbox" name="active" value="1" {{ 'checked' if u.active }}>Actief (mag inloggen)</label></div>
</div>
<div class="actions">
<button class="btn">Opslaan</button>
<a class="btn quiet" href="{{ url_for('users') }}">Annuleren</a>
</div>
</form>
{% endblock %}
+25
View File
@@ -0,0 +1,25 @@
{% extends 'base.html' %}
{% block title %}Gebruikers{% endblock %}
{% block content %}
<div class="head">
<div><div class="kicker">Team</div><h1>Gebruikers</h1></div>
<a class="btn" href="{{ url_for('user_edit') }}">Nieuwe gebruiker</a>
</div>
<div class="card">
<div class="table-wrap"><table>
<thead><tr><th>Naam</th><th>E-mail</th><th>Rol</th><th class="num">Contacten</th><th>Status</th></tr></thead>
<tbody>
{% for u in rows %}
<tr class="link" onclick="location='{{ url_for('user_edit', uid=u.id) }}'">
<td><a href="{{ url_for('user_edit', uid=u.id) }}">{{ u.name }}</a></td>
<td>{{ u.email }}</td>
<td>{{ ROLE.get(u.role, u.role) }}</td>
<td class="num">{{ u.n }}</td>
<td>{% if not u.active %}<span class="chip verloren">Inactief</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
</div>
<p class="muted">Beheerders kunnen ook gebruikers en instellingen beheren. Klanten krijgen toegang via het klantportaal (op de pagina van het contact).</p>
{% endblock %}