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:
@@ -0,0 +1,3 @@
|
|||||||
|
data/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
@@ -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
|
||||||
|
```
|
||||||
+130
@@ -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
|
||||||
@@ -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']
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
flask>=3.0
|
||||||
+160
@@ -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
|
||||||
|
);
|
||||||
@@ -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'] }));
|
||||||
|
})();
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 27 KiB |
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 }} <{{ e.from_addr }}>{% 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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %} {{ 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 %}
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
Reference in New Issue
Block a user