v0.0.4: e-mailtemplates met velden ({voornaam}, {model}, {afzender}…), te kiezen bij nieuwe e-mail, antwoord en contact; inbox met nieuwe e-mail, doorsturen, CC en bijlagen, Verzonden, Archief, zoeken, als ongelezen markeren en alles gelezen; agenda per gebruiker met CalDAV (/dav/, app-wachtwoord in Mijn account); zoeken en Nieuw in de bovenbalk; e-mailinstellingen met voorinstellingen (OVHcloud, Gmail), beveiliging per server, kopie in Verzonden en verbindingstest; offerte in het klantportaal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-16 17:29:47 +02:00
co-authored by Claude Opus 5.5
parent 81fc27fa86
commit 366a3f9d17
26 changed files with 2095 additions and 271 deletions
+17 -3
View File
@@ -34,7 +34,20 @@ Eenvoudige CRM in Python (Flask) met SQLite, in de Van Emous-huisstijl. Versie 0
- **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.
contact aanmaken vanuit een mail, beantwoorden. Voorinstellingen voor OVHcloud (Zimbra / MX Plan) en Gmail,
beveiliging per server (SSL/TLS of STARTTLS), een kopie van verstuurde mail in een IMAP-map (`Sent` bij Zimbra)
en een knop om de verbinding te testen (Instellingen › E-mail). Nieuwe e-mail opstellen (ook doorsturen), met CC
en bijlagen; mappen Verzonden en Archief (archiveren raakt de mailbox op de server niet), zoeken, als ongelezen
markeren en alles als gelezen markeren.
- **E-mailtemplates**: onderwerp en tekst met velden als `{voornaam}`, `{model}` en `{afzender}`, te kiezen bij een
nieuwe e-mail, een antwoord en op de pagina van een contact. Velden worden direct ingevuld als het contact bekend
is, anders bij het versturen (contact op het eerste adres in Aan).
- **Agenda**: een agenda per gebruiker, samen in een maandoverzicht met de taken die een datum hebben; afspraken
aan een contact koppelen (ook op de contactpagina en het dashboard), eenvoudige herhaling. Via **CalDAV**
(`/dav/`, ontdekking via `/.well-known/caldav`) zet elke gebruiker zijn agenda op iPhone, Mac, Android (DAVx⁵)
of in Thunderbird; inloggen met e-mailadres en een app-wachtwoord uit Mijn account.
- **Zoeken en Nieuw** in de bovenbalk: één zoekveld voor leads, contacten, relaties, offertes, afspraken,
producten en e-mail (live resultaten, `/` springt erheen), en een knop om direct iets nieuws aan te maken.
- **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
@@ -159,10 +172,11 @@ mailwachtwoorden in.
```
app.py routes en logica
importer.py CSV/JSON inlezen en kolommen herkennen
mail.py IMAP/SMTP
mail.py IMAP/SMTP (voorinstellingen, verbindingstest, kopie in Verzonden)
ical.py iCalendar lezen/schrijven en herhalingen uitvouwen (agenda en CalDAV)
schema.sql database
templates/ pagina's (Jinja)
static/ style.css (huisstijltokens), fx.js (GSAP-effecten), logo.svg, viewer.js (3D-viewer), bestek.js
static/ style.css (huisstijltokens), fx.js (GSAP-effecten), search.js (zoekveld), logo.svg, viewer.js (3D-viewer), bestek.js
static/configurator/ engine van de configurator (tools/configurator-sync.sh, niet in git)
tools/ configurator-sync.sh, export-models.js (standaardmodellen als JSON)
demo/modellen standaardmodellen als house-design-JSON voor de demoleads
+803 -28
View File
@@ -4,9 +4,10 @@ Starten: python app.py
E-mail ophalen: python app.py sync (bijv. elke 10 minuten via cron)
Demoleads: python app.py demo (standaardmodellen uit demo/modellen als gekozen model)
"""
__version__ = '0.0.3'
__version__ = '0.0.4'
import csv
import hashlib
import io
import json
import mimetypes
@@ -17,13 +18,17 @@ import sqlite3
import sys
import uuid
from datetime import date, datetime, timedelta
from urllib.parse import urlparse
from email.utils import getaddresses
from urllib.parse import quote as url_quote, unquote, urlparse
from xml.etree import ElementTree as ET
from xml.sax.saxutils import escape as xml_escape
from flask import (Flask, abort, flash, g, jsonify, make_response, redirect,
render_template, request, send_from_directory, session, url_for)
from werkzeug.security import check_password_hash, generate_password_hash
from werkzeug.utils import secure_filename
import ical
import importer
import mail
@@ -46,7 +51,8 @@ QUOTE_STATUSES = [('concept', 'Concept'), ('verzonden', 'Verzonden'),
('geaccepteerd', 'Geaccepteerd'), ('afgewezen', 'Afgewezen')]
ROLES = [('admin', 'Beheerder'), ('user', 'Medewerker')]
ACTIVITY_TYPES = {'note': 'Notitie', 'form': 'Formulier', 'email': 'E-mail', 'status': 'Status',
'quote': 'Offerte', 'file': 'Bestand', 'task': 'Taak', 'portal': 'Portaal', 'import': 'Import'}
'quote': 'Offerte', 'file': 'Bestand', 'task': 'Taak', 'portal': 'Portaal', 'import': 'Import',
'event': 'Afspraak'}
CONTACT_FIELDS = ['name', 'email', 'phone', 'company', 'address', 'postcode', 'city', 'source', 'notes']
PRODUCT_FIELDS = ['name', 'sku', 'description', 'unit', 'supplier', 'price', 'vat']
PRODUCT_LABELS = {'name': 'Naam', 'sku': 'SKU', 'description': 'Omschrijving', 'unit': 'Eenheid',
@@ -68,15 +74,15 @@ DEMO_MODELS = os.path.join(BASE, 'demo', 'modellen')
SETTING_KEYS = ['company_name', 'company_address', 'company_postcode_city', 'company_phone',
'company_email', 'company_website', 'company_kvk', 'company_btw', 'company_iban',
'quote_prefix', 'quote_valid_days', 'quote_intro', 'quote_footer',
'imap_host', 'imap_port', 'imap_user', 'imap_folder', 'imap_days',
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_from', 'allowed_origins', 'api_assign_user',
'imap_host', 'imap_port', 'imap_user', 'imap_folder', 'imap_days', 'imap_security',
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_from', 'smtp_security', 'smtp_sent_folder', 'allowed_origins', 'api_assign_user',
'klant_types', 'partner_types']
SECRET_KEYS = ['imap_pass', 'smtp_pass']
FIELD_LOOKUP = importer.lookup(importer.FIELD_ALIASES)
API_SKIP = {'api_key', 'redirect', '_gotcha', 'website_hp', 'g-recaptcha-response'}
PUBLIC = {'static', 'login', 'setup', 'api_leads', 'api_products'}
PUBLIC = {'static', 'login', 'setup', 'api_leads', 'api_products', 'dav', 'dav_wellknown'}
PORTAL_PUBLIC = {'portal_login', 'portal_invite'}
ADMIN_ONLY = {'settings', 'users', 'user_edit'}
@@ -132,6 +138,8 @@ MIGRATIONS = {
'design_file_id': 'INTEGER REFERENCES files(id) ON DELETE SET NULL'},
'files': {'title': "TEXT DEFAULT ''", 'kind': "TEXT DEFAULT ''", 'design_id': 'INTEGER'},
'quotes': {'bestek': "TEXT DEFAULT ''", 'design_file_id': 'INTEGER REFERENCES files(id) ON DELETE SET NULL'},
'users': {'dav_password_hash': 'TEXT', 'cal_sync': 'INTEGER NOT NULL DEFAULT 0'},
'emails': {'archived': 'INTEGER NOT NULL DEFAULT 0', 'cc': "TEXT DEFAULT ''", 'attachments': "TEXT DEFAULT ''"},
}
@@ -598,6 +606,19 @@ def logout():
@app.route('/account', methods=['GET', 'POST'])
def account():
dav = {'url': url_for('dav', path='', _external=True), 'host': request.host, 'active': bool(g.user['dav_password_hash'])}
if request.method == 'POST' and request.form.get('action') in ('dav', 'dav-off'):
if request.form['action'] == 'dav-off':
db().execute('UPDATE users SET dav_password_hash=NULL WHERE id=?', (g.user['id'],))
db().commit()
flash('Agendakoppeling uitgezet: apps met het oude app-wachtwoord kunnen niet meer synchroniseren.')
return redirect(url_for('account', _anchor='agenda'))
# leesbaar (geen 0/o, 1/l/i) en alleen nu te zien: niet via flash, want die staat in de sessiecookie
alphabet = 'abcdefghjkmnpqrstuvwxyz23456789'
password = '-'.join(''.join(secrets.choice(alphabet) for _ in range(4)) for _ in range(4))
db().execute('UPDATE users SET dav_password_hash=? WHERE id=?', (generate_password_hash(password), g.user['id']))
db().commit()
return render_template('account.html', dav=dict(dav, active=True, password=password))
if request.method == 'POST':
f = request.form
if not check_password_hash(g.user['password_hash'], f.get('old_password', '')):
@@ -610,7 +631,7 @@ def account():
db().commit()
flash('Wachtwoord gewijzigd.')
return redirect(url_for('account'))
return render_template('account.html')
return render_template('account.html', dav=dav)
@app.route('/users')
@@ -693,6 +714,9 @@ def dashboard():
'WHERE t.done=0 AND t.user_id=? ORDER BY t.due_date IS NULL, t.due_date LIMIT 10', g.user['id'])
return render_template(
'dashboard.html', stats=stats, pipeline=pipeline, weekly=weekly, sources=sources, tasks=tasks,
agenda=calendar_items(datetime.combine(date.today(), datetime.min.time()),
datetime.combine(date.today() + timedelta(days=7), datetime.min.time()),
[g.user['id']], tasks=False)[:8],
pipe_max=max([n for _, _, n in pipeline] + [1]), week_max=max([n for _, n in weekly] + [1]),
source_max=max([r['n'] for r in sources] + [1]),
recent=q("SELECT c.*, u.name AS user_name FROM contacts c LEFT JOIN users u ON u.id=c.assigned_user_id "
@@ -795,6 +819,7 @@ def contact_view(cid):
'note': project.get('opmerking') or '', 'version': d.get('configurator') or ''}
return render_template(
'contact.html', c=c, extra=json.loads(c['extra'] or '{}'),
mail_templates=all_mail_templates(), mail_vars=mail_vars(c),
activities=q('SELECT a.*, u.name AS user_name FROM activities a LEFT JOIN users u ON u.id=a.user_id '
'WHERE a.contact_id=? ORDER BY a.created_at DESC, a.id DESC', cid),
files=q('SELECT * FROM files WHERE contact_id=? ORDER BY created_at DESC', cid),
@@ -804,6 +829,9 @@ def contact_view(cid):
designs=designs, design=design, design_meta=design_meta,
pdfs=design_pdfs([design['id']]).get(design['id'], {}) if design else {}, PDF_KINDS=PDF_KINDS,
quotes=quotes_with_totals('WHERE qu.contact_id=?', cid),
appointments=calendar_items(datetime.combine(date.today(), datetime.min.time()),
datetime.combine(date.today() + timedelta(days=366), datetime.min.time()),
contact_id=cid)[:10],
portal_link=url_for('portal_invite', token=c['portal_token'], _external=True) if c['portal_token'] else '',
klant_relations=q("SELECT id, name, type FROM relations WHERE kind='klant' ORDER BY name"),
housemates=q('SELECT id, name, relation_role FROM contacts WHERE relation_id=? AND id<>? ORDER BY name',
@@ -1287,10 +1315,21 @@ def do_sync():
@app.route('/inbox')
def inbox():
flt = request.args.get('f', 'all')
where = {'unread': 'AND e.is_read=0', 'unlinked': 'AND e.contact_id IS NULL'}.get(flt, '')
rows = q(f"SELECT e.*, c.name AS contact_name FROM emails e LEFT JOIN contacts c ON c.id=e.contact_id "
f"WHERE e.direction='in' {where} ORDER BY e.date DESC LIMIT 300")
return render_template('inbox.html', rows=rows, f=flt, last_sync=get_setting('last_sync'),
term = request.args.get('q', '').strip()
where = {'unread': "e.direction='in' AND e.archived=0 AND e.is_read=0",
'unlinked': "e.direction='in' AND e.archived=0 AND e.contact_id IS NULL",
'sent': "e.direction='out'",
'archived': "e.direction='in' AND e.archived=1"}.get(flt)
if not where:
flt, where = 'all', "e.direction='in' AND e.archived=0"
args = []
if term:
cols = ('subject', 'from_name', 'from_addr', 'to_addr', 'body')
where += ' AND (' + ' OR '.join(f'e.{col} LIKE ?' for col in cols) + ')'
args = [f'%{term}%'] * len(cols)
rows = q(f'SELECT e.*, c.name AS contact_name FROM emails e LEFT JOIN contacts c ON c.id=e.contact_id '
f'WHERE {where} ORDER BY e.date DESC LIMIT 300', *args)
return render_template('inbox.html', rows=rows, f=flt, term=term, last_sync=get_setting('last_sync'),
configured=bool(get_setting('imap_host')))
@@ -1304,6 +1343,14 @@ def inbox_sync():
return redirect(url_for('inbox'))
@app.post('/inbox/read-all')
def inbox_read_all():
db().execute("UPDATE emails SET is_read=1 WHERE direction='in' AND is_read=0")
db().commit()
flash('Alle e-mail is als gelezen gemarkeerd.')
return redirect(back(url_for('inbox')))
@app.route('/inbox/<int:eid>')
def inbox_view(eid):
e = get_or_404('emails', eid)
@@ -1311,7 +1358,26 @@ def inbox_view(eid):
db().execute('UPDATE emails SET is_read=1 WHERE id=?', (eid,))
db().commit()
contact = q1('SELECT * FROM contacts WHERE id=?', e['contact_id']) if e['contact_id'] else None
return render_template('email.html', e=e, contact=contact)
return render_template('email.html', e=e, contact=contact, templates=all_mail_templates(),
vars=mail_vars(contact))
@app.post('/inbox/<int:eid>/unread')
def inbox_unread(eid):
get_or_404('emails', eid)
db().execute('UPDATE emails SET is_read=0 WHERE id=?', (eid,))
db().commit()
return redirect(url_for('inbox'))
# Archiveren haalt een bericht alleen uit de inbox van de CRM; de mailbox op de server blijft zoals hij is.
@app.post('/inbox/<int:eid>/archive')
def inbox_archive(eid):
e = get_or_404('emails', eid)
db().execute('UPDATE emails SET archived=?, is_read=1 WHERE id=?', (0 if e['archived'] else 1, eid))
db().commit()
flash('E-mail teruggezet in de inbox.' if e['archived'] else 'E-mail gearchiveerd.')
return redirect(url_for('inbox'))
@app.post('/inbox/<int:eid>/contact')
@@ -1329,29 +1395,143 @@ def inbox_contact(eid):
return redirect(url_for('contact_view', cid=cid))
# ---------------------------------------------------------------- e-mail opstellen en templates
#
# Velden tussen accolades ({voornaam}) in onderwerp en tekst vult de opsteller (static/compose.js) direct in als het
# contact bekend is; wat dan nog over is, vult mail_send bij het versturen in met het contact van het eerste adres in
# Aan (zonder contact worden die velden leeg). Andere tekst tussen accolades blijft staan.
MAIL_FIELDS = [('voornaam', 'Voornaam van het contact'), ('naam', 'Volledige naam van het contact'),
('email', 'E-mailadres van het contact'), ('telefoon', 'Telefoon van het contact'),
('bedrijf', 'Bedrijf van het contact'), ('adres', 'Straat en huisnummer'), ('postcode', 'Postcode'),
('plaats', 'Plaats'), ('model', 'Gekozen woningontwerp'), ('afzender', 'Jouw naam'),
('bedrijfsnaam', 'Onze bedrijfsnaam'), ('bedrijfstelefoon', 'Ons telefoonnummer'),
('website', 'Onze website')]
def mail_vars(contact=None):
"""Waarden voor de velden: afzender en bedrijf altijd, die van het contact alleen als het contact bekend is."""
values = {'afzender': g.user['name'], 'bedrijfsnaam': get_setting('company_name'),
'bedrijfstelefoon': get_setting('company_phone'), 'website': get_setting('company_website')}
if contact:
name = (contact['name'] or '').strip()
design = q1('SELECT title FROM files WHERE id=?', contact['design_file_id']) if contact['design_file_id'] else None
values.update(voornaam=name.split()[0] if name else '', naam=name, email=contact['email'] or '',
telefoon=contact['phone'] or '', bedrijf=contact['company'] or '', adres=contact['address'] or '',
postcode=contact['postcode'] or '', plaats=contact['city'] or '',
model=design['title'] if design else '')
return values
def fill_mail(text, values):
return re.sub(r'\{(\w+)\}', lambda m: values.get(m.group(1), m.group(0)), text or '')
def all_mail_templates():
return q('SELECT * FROM email_templates ORDER BY name COLLATE NOCASE')
def compose_page(m, contact=None):
return render_template('mail_new.html', m=m, contact=contact, templates=all_mail_templates(),
vars=mail_vars(contact),
contacts=q("SELECT name, email FROM contacts WHERE email<>'' ORDER BY updated_at DESC LIMIT 1000"))
def safe_next(target):
return target if target.startswith('/') and not target.startswith(('//', '/\\')) else ''
@app.route('/mail/new')
def mail_new():
a = request.args
contact = q1('SELECT * FROM contacts WHERE id=?', a.get('contact', type=int)) if a.get('contact') else None
m = {'to': contact['email'] if contact else a.get('to', ''), 'contact_id': contact['id'] if contact else None,
'template': a.get('template', type=int),
'next': url_for('contact_view', cid=contact['id']) if contact else url_for('inbox', f='sent')}
fwd = q1('SELECT * FROM emails WHERE id=?', a.get('forward', type=int)) if a.get('forward') else None
if fwd:
subject = fwd['subject'] or ''
m['subject'] = subject if subject.lower().startswith(('fwd:', 'fw:')) else f'Fwd: {subject}'
sender = f"{fwd['from_name']} <{fwd['from_addr']}>" if fwd['from_name'] else fwd['from_addr']
m['body'] = '\n\n'.join(['', '---------- Doorgestuurd bericht ----------\n' +
f"Van: {sender}\nDatum: {fmt_dt(fwd['date'])}\nOnderwerp: {subject}\nAan: {fwd['to_addr']}",
fwd['body'] or ''])
return compose_page(m, contact)
@app.post('/mail/send')
def mail_send():
f = request.form
to, subject, body = f.get('to', '').strip(), f.get('subject', '').strip(), f.get('body', '')
to, cc = f.get('to', '').strip(), f.get('cc', '').strip()
cid = f.get('contact_id', type=int)
reply = q1('SELECT * FROM emails WHERE id=?', f.get('reply_to', type=int)) if f.get('reply_to') else None
target = back(url_for('inbox'))
first = next((addr for _, addr in getaddresses([to]) if addr), '')
contact = (q1('SELECT * FROM contacts WHERE id=?', cid) if cid else None) or \
(q1('SELECT * FROM contacts WHERE email=? COLLATE NOCASE', first) if first else None)
values = {**dict.fromkeys(k for k, _ in MAIL_FIELDS), **mail_vars(contact)}
values = {k: v or '' for k, v in values.items()}
subject, body = fill_mail(f.get('subject', '').strip(), values), fill_mail(f.get('body', ''), values)
files = [(os.path.basename(u.filename.replace('\\', '/')), u.read(), u.mimetype)
for u in request.files.getlist('attachments') if u and u.filename]
nxt = safe_next(f.get('next', ''))
try:
message_id = mail.send(settings_dict(), to, subject, body, reply['message_id'] if reply else None)
message_id, warning = mail.send(settings_dict(), to, subject, body, reply['message_id'] if reply else None,
cc, files)
except Exception as e:
flash(f'Versturen mislukt: {e}', 'error')
return redirect(target)
if not cid:
c = q1('SELECT id FROM contacts WHERE email=? COLLATE NOCASE', to)
cid = c['id'] if c else None
db().execute("INSERT INTO emails (message_id, direction, contact_id, from_addr, to_addr, subject, body, date, "
"is_read) VALUES (?, 'out', ?, ?, ?, ?, ?, ?, 1)",
(message_id, cid, get_setting('smtp_from') or get_setting('smtp_user'), to, subject, body, now()))
flash(f'Versturen mislukt: {e}' + (' Voeg de bijlagen opnieuw toe.' if files else ''), 'error')
return compose_page({'to': to, 'cc': cc, 'subject': subject, 'body': body, 'next': nxt,
'contact_id': contact['id'] if contact else None,
'reply_to': reply['id'] if reply else None}, contact)
if warning:
flash(warning, 'error')
cid = contact['id'] if contact else None
db().execute("INSERT INTO emails (message_id, direction, contact_id, from_addr, to_addr, cc, subject, body, "
"attachments, date, is_read) VALUES (?, 'out', ?, ?, ?, ?, ?, ?, ?, ?, 1)",
(message_id, cid, get_setting('smtp_from') or get_setting('smtp_user'), to, cc, subject, body,
', '.join(name for name, _, _ in files), now()))
if cid:
log(cid, 'email', f'E-mail verstuurd: {subject}')
db().commit()
flash('E-mail verstuurd.')
return redirect(target)
return redirect(nxt or back(url_for('inbox')))
@app.route('/mail/templates')
def mail_templates():
return render_template('mail_templates.html', rows=all_mail_templates(), FIELDS=MAIL_FIELDS)
@app.route('/mail/templates/new', methods=['GET', 'POST'], defaults={'tid': None})
@app.route('/mail/templates/<int:tid>', methods=['GET', 'POST'])
def mail_template_edit(tid):
t = get_or_404('email_templates', tid) if tid else None
if request.method == 'POST':
f = request.form
data = {'name': f.get('name', '').strip(), 'subject': f.get('subject', '').strip(), 'body': f.get('body', '')}
if not data['name']:
flash('Geef de template een naam.', 'error')
return render_template('mail_template_form.html', t={**(dict(t) if t else {}), **data}, FIELDS=MAIL_FIELDS)
data['updated_at'] = now()
if t:
db().execute(f'UPDATE email_templates SET {", ".join(f"{k}=?" for k in data)} WHERE id=?',
[*data.values(), tid])
else:
data['created_at'] = data['updated_at']
db().execute(f'INSERT INTO email_templates ({", ".join(data)}) VALUES ({", ".join("?" * len(data))})',
list(data.values()))
db().commit()
flash('Template opgeslagen.')
return redirect(url_for('mail_templates'))
return render_template('mail_template_form.html', t=t or {}, FIELDS=MAIL_FIELDS)
@app.post('/mail/templates/<int:tid>/delete')
def mail_template_delete(tid):
get_or_404('email_templates', tid)
db().execute('DELETE FROM email_templates WHERE id=?', (tid,))
db().commit()
flash('Template verwijderd.')
return redirect(url_for('mail_templates'))
# ---------------------------------------------------------------- producten
@@ -1636,7 +1816,7 @@ def render_quote(qt, portal=False):
lines = q('SELECT * FROM quote_lines WHERE quote_id=? ORDER BY position', qt['id'])
contact = q1('SELECT c.*, r.name AS relation_name, r.kind AS relation_kind FROM contacts c '
'LEFT JOIN relations r ON r.id=c.relation_id WHERE c.id=?', qt['contact_id']) if qt['contact_id'] else None
return render_template('quote_print.html', qt=qt, lines=lines, c=contact, t=quote_totals(lines),
return render_template('quote_portal.html' if portal else 'quote_print.html', qt=qt, lines=lines, c=contact, t=quote_totals(lines),
s=settings_dict(), portal=portal, bestek=quote_bestek_data(qt))
@@ -1813,7 +1993,8 @@ def quote_delete(qid):
@app.route('/settings', methods=['GET', 'POST'])
def settings():
if request.method == 'POST':
if request.form.get('action') == 'apikey':
action = request.form.get('action')
if action == 'apikey':
set_setting('api_key', secrets.token_urlsafe(24))
flash('Nieuwe API-sleutel aangemaakt. Pas je formulieren aan.')
else:
@@ -1822,11 +2003,17 @@ def settings():
for key in SECRET_KEYS:
if request.form.get(key):
set_setting(key, request.form[key])
if action == 'mailtest':
db().commit()
for ok, message in mail.test(settings_dict()):
flash(message, 'message' if ok else 'error')
else:
flash('Instellingen opgeslagen.')
db().commit()
return redirect(url_for('settings'))
return redirect(url_for('settings', _anchor='mail' if action == 'mailtest' else None))
return render_template('settings.html', s=settings_dict(), api_url=url_for('api_leads', _external=True),
portal_url=url_for('portal_login', _external=True))
portal_url=url_for('portal_login', _external=True), PROVIDERS=mail.PROVIDERS,
SECURITY=mail.SECURITY)
# ---------------------------------------------------------------- klantportaal
@@ -2054,6 +2241,594 @@ def api_products():
return jsonify(ok=True, **result)
# ---------------------------------------------------------------- zoeken
#
# Eén zoekveld voor alles (bovenbalk): live resultaten via /search/live, de volledige lijst op /search.
def search_groups(term, limit):
like = '%' + term.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_') + '%'
digits = re.sub(r'\D', '', term)
phone = f'%{digits}%' if len(digits) >= 4 else None
phone_sql = "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE({0}, ' ', ''), '-', ''), '+31', '0'), '(', ''), ')', '') LIKE ?"
E = " ESCAPE '\\'"
def match(*cols, phone_col=None):
sql = ' OR '.join(f'{c} LIKE ?{E}' for c in cols)
args = [like] * len(cols)
if phone and phone_col:
sql += ' OR ' + phone_sql.format(phone_col)
args.append(phone)
return f'({sql})', args
groups = []
def add(label, rows, make):
if rows:
groups.append({'label': label, 'items': [make(r) for r in rows[:limit]], 'more': len(rows) > limit})
cond, args = match('c.name', 'c.email', 'c.company', 'c.city', 'c.postcode', 'c.address', 'r.name', phone_col='c.phone')
for label, kind in (('Leads', "c.type='lead'"), ('Contactpersonen', "c.type<>'lead'")):
rows = q(f'SELECT c.id, c.name, c.email, c.city, c.type, c.status, r.name AS relation_name FROM contacts c '
f'LEFT JOIN relations r ON r.id=c.relation_id WHERE {kind} AND {cond} ORDER BY c.updated_at DESC LIMIT ?',
*args, limit + 1)
add(label, rows, lambda r: {'title': r['name'] or r['email'], 'url': url_for('contact_view', cid=r['id']),
'sub': ' · '.join(filter(None, [dict(STATUSES).get(r['status']) if r['type'] == 'lead'
else dict(TYPES).get(r['type']), r['relation_name'],
r['email'], r['city']]))})
cond, args = match('name', 'email', 'city', 'kvk', 'address', phone_col='phone')
add('Klanten en partners', q(f'SELECT * FROM relations WHERE {cond} ORDER BY name LIMIT ?', *args, limit + 1),
lambda r: {'title': r['name'], 'url': url_for('relation_view', rid=r['id']),
'sub': ' · '.join(filter(None, [dict(RELATION_KINDS).get(r['kind']), r['type'], r['city']]))})
cond, args = match('qu.number', 'qu.title', 'c.name')
add('Offertes', q(f'SELECT qu.*, c.name AS contact_name FROM quotes qu LEFT JOIN contacts c ON c.id=qu.contact_id '
f'WHERE {cond} ORDER BY qu.date DESC, qu.id DESC LIMIT ?', *args, limit + 1),
lambda r: {'title': ' · '.join(filter(None, [r['number'], r['title']])), 'url': url_for('quote_edit', qid=r['id']),
'sub': ' · '.join(filter(None, [dict(QUOTE_STATUSES).get(r['status']), r['contact_name'],
fmt_dt(r['date'], '%d-%m-%Y')]))})
cond, args = match('e.title', 'e.location', 'e.description', 'c.name')
add('Afspraken', q(f'SELECT e.*, u.name AS user_name, c.name AS contact_name FROM events e JOIN users u ON u.id=e.user_id '
f'LEFT JOIN contacts c ON c.id=e.contact_id WHERE {cond} ORDER BY e.starts_at DESC LIMIT ?',
*args, limit + 1),
lambda r: {'title': r['title'] or '(geen titel)', 'url': url_for('calendar_edit', eid=r['id']),
'sub': ' · '.join(filter(None, [event_when(r), r['contact_name'], r['user_name']]))})
cond, args = match('name', 'sku', 'supplier', 'description')
add('Producten', q(f'SELECT * FROM products WHERE {cond} ORDER BY active DESC, name LIMIT ?', *args, limit + 1),
lambda r: {'title': r['name'], 'url': url_for('product_edit', pid=r['id']),
'sub': ' · '.join(filter(None, [r['sku'], euro(r['price']), '' if r['active'] else 'inactief']))})
cond, args = match('subject', 'from_addr', 'from_name', 'to_addr')
add('E-mail', q(f'SELECT id, subject, from_name, from_addr, to_addr, direction, date FROM emails WHERE {cond} '
f'ORDER BY date DESC LIMIT ?', *args, limit + 1),
lambda r: {'title': r['subject'], 'url': url_for('inbox_view', eid=r['id']),
'sub': ' · '.join(filter(None, [r['from_name'] or r['from_addr'] if r['direction'] == 'in'
else 'aan ' + (r['to_addr'] or ''), fmt_dt(r['date'], '%d-%m-%Y')]))})
return groups
@app.route('/search')
def search():
term = request.args.get('q', '').strip()[:100]
groups = search_groups(term, 50) if len(term) >= 2 else []
items = [i for grp in groups for i in grp['items']]
if len(items) == 1:
return redirect(items[0]['url'])
return render_template('search.html', term=term, groups=groups)
@app.route('/search/live')
def search_live():
term = request.args.get('q', '').strip()[:100]
return jsonify(q=term, url=url_for('search', q=term), groups=search_groups(term, 5) if len(term) >= 2 else [])
# ---------------------------------------------------------------- agenda
#
# Eén agenda per gebruiker. In de CRM staan ze samen (een kleur per gebruiker) met de open taken die een datum
# hebben; via CalDAV (/dav/, hieronder) koppelt elke gebruiker zijn eigen agenda aan telefoon of computer.
# Afspraken uit een agenda-app worden bewaard zoals de app ze stuurde (kolom ics); in de CRM bewerken schrijft
# een nieuw kalenderobject (herinneringen en uitnodigingen uit de app vervallen dan).
MONTHS_NL = ['januari', 'februari', 'maart', 'april', 'mei', 'juni', 'juli', 'augustus', 'september', 'oktober',
'november', 'december']
DAYS_NL = ['ma', 'di', 'wo', 'do', 'vr', 'za', 'zo']
REPEATS = [('', 'Niet'), ('DAILY', 'Elke dag'), ('WEEKLY', 'Elke week'), ('MONTHLY', 'Elke maand'), ('YEARLY', 'Elk jaar')]
def user_color(user_id):
return f'u{(user_id or 0) % 6}'
def day_label(d, year=False):
return f"{DAYS_NL[d.weekday()]} {d.day} {MONTHS_NL[d.month - 1]}" + (f' {d.year}' if year else '')
def event_when(e, start=None, end=None):
"""'di 20 oktober 10:00–11:00' of 'di 20 oktober (hele dag)'."""
start = start or ical.to_dt(e['starts_at'])
end = end or ical.to_dt(e['ends_at'] or e['starts_at'])
if e['all_day']:
last = (end - timedelta(days=1)).date() if end > start else start.date()
return day_label(start.date()) + (f' t/m {day_label(last)}' if last > start.date() else ' (hele dag)')
if end.date() != start.date():
return f"{day_label(start.date())} {start:%H:%M} – {day_label(end.date())} {end:%H:%M}"
return f"{day_label(start.date())} {start:%H:%M}–{end:%H:%M}"
def bump_calendar(user_id):
"""Nieuwe ctag: agenda-apps zien dan dat er iets veranderd is."""
db().execute('UPDATE users SET cal_sync=cal_sync+1 WHERE id=?', (user_id,))
def save_event(user_id, ev, ics_text, href, contact_id=None, event_id=None):
cols = ['user_id', 'contact_id', 'href', 'uid', 'title', 'location', 'description', 'starts_at', 'ends_at',
'all_day', 'rrule', 'exdates', 'ics', 'etag']
vals = [user_id, contact_id, href, ev['uid'], ev['title'], ev['location'], ev['description'], ev['starts_at'],
ev['ends_at'], ev['all_day'], ev['rrule'], ev['exdates'], ics_text, hashlib.md5(ics_text.encode()).hexdigest()]
if event_id:
old = q1('SELECT user_id FROM events WHERE id=?', event_id)
db().execute(f"UPDATE events SET {', '.join(c + '=?' for c in cols)}, updated_at=? WHERE id=?",
(*vals, now(), event_id))
if old and old['user_id'] != user_id:
bump_calendar(old['user_id'])
else:
event_id = db().execute(f"INSERT INTO events ({', '.join(cols)}, created_at, updated_at) "
f"VALUES ({', '.join('?' * (len(cols) + 2))})", (*vals, now(), now())).lastrowid
bump_calendar(user_id)
return event_id
def calendar_items(start, end, user_ids=None, contact_id=None, tasks=True):
"""Afspraken (herhalingen uitgevouwen) en open taken met een datum in [start, end), op volgorde."""
where = ["(e.rrule<>'' OR (e.starts_at < ? AND e.ends_at >= ?))"]
args = [end.strftime('%Y-%m-%d %H:%M:%S'), start.strftime('%Y-%m-%d')]
if user_ids:
where.append(f"e.user_id IN ({', '.join('?' * len(user_ids))})")
args += user_ids
if contact_id:
where.append('e.contact_id=?')
args.append(contact_id)
out = []
for e in q(f"SELECT e.*, u.name AS user_name, c.name AS contact_name FROM events e JOIN users u ON u.id=e.user_id "
f"LEFT JOIN contacts c ON c.id=e.contact_id WHERE {' AND '.join(where)}", *args):
try:
hits = ical.occurrences(dict(e), start, end)
except (ValueError, KeyError):
continue
for s, en in hits:
out.append({'kind': 'event', 'id': e['id'], 'start': s, 'end': en, 'all_day': bool(e['all_day']),
'title': e['title'] or '(geen titel)', 'color': user_color(e['user_id']), 'user': e['user_name'],
'contact': e['contact_name'], 'contact_id': e['contact_id'], 'location': e['location'],
'repeats': bool(e['rrule']), 'when': event_when(e, s, en),
'url': url_for('calendar_edit', eid=e['id'])})
if tasks and not contact_id:
where = ['t.done=0', 't.due_date >= ?', 't.due_date < ?']
args = [start.strftime('%Y-%m-%d'), end.strftime('%Y-%m-%d')]
if user_ids:
where.append(f"t.user_id IN ({', '.join('?' * len(user_ids))})")
args += user_ids
for t in q(f"SELECT t.*, u.name AS user_name, c.name AS contact_name FROM tasks t "
f"LEFT JOIN users u ON u.id=t.user_id LEFT JOIN contacts c ON c.id=t.contact_id WHERE {' AND '.join(where)}",
*args):
d = datetime.strptime(t['due_date'][:10], '%Y-%m-%d')
out.append({'kind': 'task', 'start': d, 'end': d + timedelta(days=1), 'all_day': True, 'title': t['title'],
'color': user_color(t['user_id']), 'user': t['user_name'], 'contact': t['contact_name'],
'contact_id': t['contact_id'], 'when': day_label(d.date()),
'url': url_for('contact_view', cid=t['contact_id']) if t['contact_id'] else url_for('dashboard')})
out.sort(key=lambda i: (i['start'].date(), not i['all_day'], i['start'], i['title'].lower()))
return out
@app.route('/agenda')
def calendar_view():
try:
month = datetime.strptime(request.args.get('m', ''), '%Y-%m').date()
except ValueError:
month = date.today().replace(day=1)
who = request.args.get('u') or session.get('cal_u') or 'all'
session['cal_u'] = who
user_ids = [g.user['id']] if who == 'me' else ([int(who)] if who.isdigit() else None)
grid_start = month - timedelta(days=month.weekday())
next_month = (month + timedelta(days=32)).replace(day=1)
grid_end = next_month + timedelta(days=(7 - next_month.weekday()) % 7)
by_day = {}
for i in calendar_items(datetime.combine(grid_start, datetime.min.time()),
datetime.combine(grid_end, datetime.min.time()), user_ids):
first = i['start'].date()
last = max(first, (i['end'] - timedelta(seconds=1)).date())
d = max(first, grid_start)
while d <= last and d < grid_end:
by_day.setdefault(d, []).append(i)
d += timedelta(days=1)
weeks = [[grid_start + timedelta(days=w * 7 + k) for k in range(7)] for w in range((grid_end - grid_start).days // 7)]
return render_template('calendar.html', weeks=weeks, by_day=by_day, month=month, who=who, DAYS=DAYS_NL,
title=f'{MONTHS_NL[month.month - 1].capitalize()} {month.year}', day_label=day_label,
prev=(month - timedelta(days=1)).strftime('%Y-%m'), next=next_month.strftime('%Y-%m'),
this_month=date.today().strftime('%Y-%m'), user_color=user_color)
def event_form_values(e):
start, end = ical.to_dt(e['starts_at']), ical.to_dt(e['ends_at'] or e['starts_at'])
if e['all_day']:
end = max(start, end - timedelta(days=1))
return {'title': e['title'], 'user_id': e['user_id'], 'contact_id': e['contact_id'], 'location': e['location'],
'description': e['description'], 'all_day': 'on' if e['all_day'] else '',
'start_date': start.date().isoformat(), 'start_time': start.strftime('%H:%M'),
'end_date': end.date().isoformat(), 'end_time': end.strftime('%H:%M'),
'repeat': ical.simple_freq(e['rrule']) or ('keep' if e['rrule'] else '')}
def event_from_form(f, existing=None):
title = f.get('title', '').strip()
if not title:
return None, 'Vul een titel in.'
try:
sd = date.fromisoformat(f.get('start_date', ''))
ed = date.fromisoformat(f.get('end_date') or f.get('start_date', ''))
all_day = bool(f.get('all_day'))
if all_day:
starts, ends = sd.isoformat(), (max(sd, ed) + timedelta(days=1)).isoformat()
else:
st = datetime.combine(sd, datetime.strptime((f.get('start_time') or '09:00')[:5], '%H:%M').time())
en = (datetime.combine(ed, datetime.strptime(f['end_time'][:5], '%H:%M').time())
if f.get('end_time') else st + timedelta(hours=1))
if en <= st:
en = st + timedelta(hours=1)
starts, ends = st.strftime('%Y-%m-%d %H:%M:%S'), en.strftime('%Y-%m-%d %H:%M:%S')
except ValueError:
return None, 'Controleer de datum en de tijd.'
repeat = f.get('repeat', '')
if repeat == 'keep' and existing:
rrule = existing['rrule']
else:
rrule = f'FREQ={repeat}' if repeat in dict(REPEATS) and repeat else ''
keep_ex = existing and rrule and rrule == existing['rrule'] and bool(existing['all_day']) == all_day \
and starts[11:] == existing['starts_at'][11:]
return {'title': title, 'location': f.get('location', '').strip(), 'description': f.get('description', '').strip(),
'starts_at': starts, 'ends_at': ends, 'all_day': 1 if all_day else 0, 'rrule': rrule,
'exdates': existing['exdates'] if keep_ex else ''}, None
@app.route('/agenda/new', methods=['GET', 'POST'], defaults={'eid': None})
@app.route('/agenda/<int:eid>', methods=['GET', 'POST'])
def calendar_edit(eid):
e = q1('SELECT e.*, c.name AS contact_name FROM events e LEFT JOIN contacts c ON c.id=e.contact_id WHERE e.id=?',
eid) or abort(404) if eid else None
contacts = q("SELECT id, name, type FROM contacts ORDER BY type='lead' DESC, name")
nxt = request.values.get('next', '')
nxt = nxt if nxt.startswith('/') and not nxt.startswith('//') else ''
if request.method == 'POST':
f = request.form
ev, error = event_from_form(f, e)
if error:
flash(error, 'error')
return render_template('event_form.html', e=e, v=f, contacts=contacts, REPEATS=REPEATS, nxt=nxt)
user_id = f.get('user_id', type=int)
if not user_id or not q1('SELECT id FROM users WHERE id=?', user_id):
user_id = g.user['id']
contact_id = f.get('contact_id', type=int) or None
if contact_id and not q1('SELECT id FROM contacts WHERE id=?', contact_id):
contact_id = None
ev['uid'] = e['uid'] if e else f'{uuid.uuid4()}@vanemous-crm'
href = e['href'] if e else f"{ev['uid']}.ics"
ics_text = ical.to_ics(ev, url_for('contact_view', cid=contact_id, _external=True) if contact_id else '')
eid = save_event(user_id, ev, ics_text, href, contact_id, eid)
if contact_id and (not e or e['contact_id'] != contact_id):
log(contact_id, 'event', f"Afspraak: {ev['title']}, {event_when(ev)}")
db().commit()
flash('Afspraak opgeslagen.')
return redirect(nxt or url_for('calendar_view', m=ev['starts_at'][:7]))
if e:
v = event_form_values(e)
else:
d = request.args.get('date') or date.today().isoformat()
hour = min(datetime.now().hour + 1, 22) if d == date.today().isoformat() else 9
v = {'title': '', 'user_id': g.user['id'], 'contact_id': request.args.get('contact_id', type=int), 'location': '',
'description': '', 'all_day': '', 'start_date': d, 'start_time': f'{hour:02d}:00', 'end_date': d,
'end_time': f'{hour + 1:02d}:00', 'repeat': ''}
c = q1('SELECT name, address, postcode, city FROM contacts WHERE id=?', v['contact_id']) if v['contact_id'] else None
if c:
v['title'] = f"Afspraak {c['name']}"
v['location'] = ', '.join(filter(None, [c['address'], ' '.join(filter(None, [c['postcode'], c['city']]))]))
return render_template('event_form.html', e=e, v=v, contacts=contacts, REPEATS=REPEATS, nxt=nxt)
@app.post('/agenda/<int:eid>/delete')
def calendar_delete(eid):
e = get_or_404('events', eid)
db().execute('DELETE FROM events WHERE id=?', (eid,))
bump_calendar(e['user_id'])
db().commit()
flash('Afspraak verwijderd.')
return redirect(url_for('calendar_view', m=e['starts_at'][:7]))
# ---------------------------------------------------------------- CalDAV
#
# Elke gebruiker koppelt zijn eigen agenda met een app-wachtwoord (Mijn account): iPhone/Mac als CalDAV-account
# (server = het CRM-domein; vindt /dav/ via /.well-known/caldav), Thunderbird en DAVx5 (Android) met
# https://domein/dav/. Ondersteund: PROPFIND, PROPPATCH (genegeerd), REPORT calendar-query (met time-range) en
# calendar-multiget, GET/PUT/DELETE van afspraken met ETags; apps synchroniseren via getctag en getetag.
# Paden: /dav/ → /dav/principals/<id>/ → /dav/calendars/<id>/ → /dav/calendars/<id>/agenda/<naam>.ics
D, C, CS = '{DAV:}', '{urn:ietf:params:xml:ns:caldav}', '{http://calendarserver.org/ns/}'
A = '{http://apple.com/ns/ical/}'
DAV_PREFIX = {'DAV:': 'D', 'urn:ietf:params:xml:ns:caldav': 'C', 'http://calendarserver.org/ns/': 'CS',
'http://apple.com/ns/ical/': 'A'}
DAV_METHODS = ['OPTIONS', 'GET', 'HEAD', 'PUT', 'DELETE', 'PROPFIND', 'PROPPATCH', 'REPORT', 'MKCALENDAR', 'MKCOL',
'MOVE', 'COPY']
DAV_CALENDAR = 'agenda'
DAV_MAX_SIZE = 1024 * 1024
def dav_el(clark, value=None):
ns, _, local = clark[1:].partition('}')
prefix = DAV_PREFIX.get(ns)
tag, decl = (f'{prefix}:{local}', '') if prefix else (f'X:{local}', f' xmlns:X="{xml_escape(ns)}"')
return f'<{tag}{decl}/>' if value in (None, '') else f'<{tag}{decl}>{value}</{tag}>'
def dav_href(path):
return f'<D:href>{xml_escape(path)}</D:href>'
def dav_response(href, found=None, missing=(), status=None):
out = '<D:response>' + dav_href(href)
if status:
out += f'<D:status>HTTP/1.1 {status}</D:status>'
if found:
out += ('<D:propstat><D:prop>' + ''.join(dav_el(k, v) for k, v in found.items())
+ '</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat>')
if missing:
out += ('<D:propstat><D:prop>' + ''.join(dav_el(k) for k in missing)
+ '</D:prop><D:status>HTTP/1.1 404 Not Found</D:status></D:propstat>')
return out + '</D:response>'
def dav_multistatus(responses):
body = ('<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus xmlns:D="DAV:" '
'xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" '
'xmlns:A="http://apple.com/ns/ical/">' + ''.join(responses) + '</D:multistatus>')
return make_response(body, 207, {'Content-Type': 'application/xml; charset=utf-8'})
def dav_props(props, wanted):
"""wanted None (allprop of leeg verzoek): alles; anders (gevonden, ontbrekend)."""
if wanted is None:
return props, []
return {k: props[k] for k in wanted if k in props}, [k for k in wanted if k not in props]
def dav_wanted(root):
if root is None or root.find(f'{D}allprop') is not None or root.find(f'{D}prop') is None:
return None
return [child.tag for child in root.find(f'{D}prop')]
def dav_user():
auth = request.authorization
if not auth or (auth.type or '').lower() != 'basic' or not auth.username:
return None
u = q1('SELECT * FROM users WHERE email=? AND active=1', auth.username.strip())
if u and u['dav_password_hash'] and check_password_hash(u['dav_password_hash'], auth.password or ''):
return u
return None
def dav_paths(u):
principal = f"/dav/principals/{u['id']}/"
home = f"/dav/calendars/{u['id']}/"
return principal, home, f'{home}{DAV_CALENDAR}/'
def dav_base_props(u):
principal, home, _ = dav_paths(u)
return {f'{D}current-user-principal': dav_href(principal), f'{D}principal-collection-set': dav_href('/dav/principals/')}
def dav_principal_props(u):
principal, home, _ = dav_paths(u)
return {**dav_base_props(u), f'{D}resourcetype': '<D:principal/>', f'{D}displayname': xml_escape(u['name']),
f'{D}principal-URL': dav_href(principal), f'{C}calendar-home-set': dav_href(home),
f'{C}calendar-user-address-set': dav_href('mailto:' + u['email'])}
def dav_calendar_props(u):
principal = dav_paths(u)[0]
privs = ''.join(f'<D:privilege><D:{p}/></D:privilege>' for p in
('read', 'write', 'write-content', 'write-properties', 'bind', 'unbind',
'read-current-user-privilege-set'))
reports = ''.join(f'<D:supported-report><D:report><C:{r}/></D:report></D:supported-report>'
for r in ('calendar-multiget', 'calendar-query'))
return {**dav_base_props(u), f'{D}resourcetype': '<D:collection/><C:calendar/>',
f'{D}displayname': xml_escape(f"Van Emous CRM · {u['name']}"), f'{D}owner': dav_href(principal),
f'{CS}getctag': str(u['cal_sync']), f'{C}supported-calendar-component-set': '<C:comp name="VEVENT"/>',
f'{C}supported-calendar-data': '<C:calendar-data content-type="text/calendar" version="2.0"/>',
f'{C}calendar-description': 'Afspraken uit de Van Emous CRM', f'{A}calendar-color': '#B95913FF',
f'{C}max-resource-size': str(DAV_MAX_SIZE), f'{D}current-user-privilege-set': privs,
f'{D}supported-report-set': reports}
def dav_event_props(e, with_data=False):
props = {f'{D}getetag': xml_escape(f'"{e["etag"]}"'), f'{D}resourcetype': '',
f'{D}getcontenttype': 'text/calendar; charset=utf-8; component=vevent',
f'{D}getcontentlength': str(len(e['ics'].encode('utf-8')))}
if with_data:
props[f'{C}calendar-data'] = xml_escape(e['ics'])
return props
def dav_event_response(u, e, wanted):
want_data = bool(wanted) and f'{C}calendar-data' in wanted
found, missing = dav_props(dav_event_props(e, want_data), wanted)
return dav_response(dav_paths(u)[2] + url_quote(e['href']), found, missing)
def dav_body():
data = request.get_data()
if not data.strip():
return None
try:
return ET.fromstring(data)
except ET.ParseError:
abort(400)
def dav_time_range(root):
"""time-range uit een calendar-query (UTC) → lokale (start, eind), of None."""
tr = root.find(f'.//{C}time-range') if root is not None else None
if tr is None:
return None
try:
start = ical.parse_dt(tr.get('start'), {})[0] if tr.get('start') else datetime(1970, 1, 1)
end = ical.parse_dt(tr.get('end'), {})[0] if tr.get('end') else datetime(2200, 1, 1)
except ValueError:
return None
return (start if isinstance(start, datetime) else datetime.combine(start, datetime.min.time()),
end if isinstance(end, datetime) else datetime.combine(end, datetime.min.time()))
@app.route('/.well-known/caldav', methods=['GET', 'HEAD', 'PROPFIND', 'OPTIONS'], provide_automatic_options=False)
def dav_wellknown():
return redirect('/dav/', 301)
@app.route('/dav/', defaults={'path': ''}, methods=DAV_METHODS, provide_automatic_options=False)
@app.route('/dav/<path:path>', methods=DAV_METHODS, provide_automatic_options=False)
def dav(path):
allow = ', '.join(DAV_METHODS)
if request.method == 'OPTIONS':
return make_response('', 200, {'DAV': '1, 3, calendar-access', 'Allow': allow, 'MS-Author-Via': 'DAV'})
u = dav_user()
if not u:
return make_response('Inloggen met e-mailadres en app-wachtwoord (Mijn account in de CRM).', 401,
{'WWW-Authenticate': 'Basic realm="Van Emous CRM agenda", charset="UTF-8"'})
parts = [p for p in path.split('/') if p]
if len(parts) >= 2 and parts[1] != str(u['id']):
return make_response('Geen toegang tot deze agenda', 403)
principal, home, cal = dav_paths(u)
method = request.method
depth = request.headers.get('Depth', '1')
# agenda-afspraak
if len(parts) == 4 and parts[0] == 'calendars' and parts[2] == DAV_CALENDAR:
return dav_event(u, unquote(parts[3]), method)
if len(parts) > 3 or (len(parts) == 3 and (parts[0] != 'calendars' or parts[2] != DAV_CALENDAR)):
return make_response('Niet gevonden', 404)
if method in ('GET', 'HEAD'):
return make_response('Van Emous CRM: CalDAV. Koppel de agenda in je agenda-app met dit adres.', 200,
{'Content-Type': 'text/plain; charset=utf-8'})
if method == 'PROPPATCH': # kleur of naam vanuit de app: accepteren maar niet bewaren
root = dav_body()
tags = [c.tag for p in (root.iter(f'{D}prop') if root is not None else []) for c in p]
return dav_multistatus([dav_response('/dav/' + path, {t: None for t in tags})])
if method == 'REPORT':
if len(parts) != 3:
return make_response('REPORT alleen op de agenda', 403)
return dav_report(u, dav_body())
if method != 'PROPFIND':
return make_response('Niet toegestaan', 405, {'Allow': allow})
wanted = dav_wanted(dav_body())
out = []
if not parts:
props = {**dav_base_props(u), f'{D}resourcetype': '<D:collection/>', f'{D}displayname': 'Van Emous CRM'}
out.append(dav_response('/dav/', *dav_props(props, wanted)))
if depth != '0':
out.append(dav_response(principal, *dav_props(dav_principal_props(u), wanted)))
elif parts[0] == 'principals':
if len(parts) == 1:
out.append(dav_response('/dav/principals/', *dav_props(
{**dav_base_props(u), f'{D}resourcetype': '<D:collection/>'}, wanted)))
if depth != '0':
out.append(dav_response(principal, *dav_props(dav_principal_props(u), wanted)))
else:
out.append(dav_response(principal, *dav_props(dav_principal_props(u), wanted)))
elif parts[0] == 'calendars' and len(parts) == 2:
props = {**dav_base_props(u), f'{D}resourcetype': '<D:collection/>', f'{D}owner': dav_href(principal),
f'{D}displayname': xml_escape(u['name'])}
out.append(dav_response(home, *dav_props(props, wanted)))
if depth != '0':
out.append(dav_response(cal, *dav_props(dav_calendar_props(u), wanted)))
elif parts[0] == 'calendars' and len(parts) == 3:
out.append(dav_response(cal, *dav_props(dav_calendar_props(u), wanted)))
if depth != '0':
out += [dav_event_response(u, e, wanted or [f'{D}getetag', f'{D}getcontenttype', f'{D}resourcetype'])
for e in q('SELECT * FROM events WHERE user_id=? ORDER BY id', u['id'])]
else:
return make_response('Niet gevonden', 404)
return dav_multistatus(out)
def dav_report(u, root):
if root is None:
abort(400)
wanted = dav_wanted(root) or [f'{D}getetag']
if root.tag == f'{C}calendar-multiget':
out = []
for h in root.iter(f'{D}href'):
name = unquote((h.text or '').rstrip('/').rsplit('/', 1)[-1])
e = q1('SELECT * FROM events WHERE user_id=? AND href=?', u['id'], name)
out.append(dav_event_response(u, e, wanted) if e else dav_response(h.text or '', status='404 Not Found'))
return dav_multistatus(out)
if root.tag == f'{C}calendar-query':
span = dav_time_range(root)
events = q('SELECT * FROM events WHERE user_id=? ORDER BY id', u['id'])
if span:
events = [e for e in events if ical.occurrences(dict(e), *span)]
return dav_multistatus([dav_event_response(u, e, wanted) for e in events])
return make_response('<?xml version="1.0" encoding="utf-8"?><D:error xmlns:D="DAV:"><D:supported-report/></D:error>',
403, {'Content-Type': 'application/xml; charset=utf-8'})
def dav_event(u, name, method):
if '/' in name or not name or len(name) > 255:
return make_response('Ongeldige naam', 400)
e = q1('SELECT * FROM events WHERE user_id=? AND href=?', u['id'], name)
etag = f'"{e["etag"]}"' if e else None
if_match, if_none = request.headers.get('If-Match'), request.headers.get('If-None-Match')
if method in ('GET', 'HEAD'):
if not e:
return make_response('Niet gevonden', 404)
if if_none and etag in [t.strip() for t in if_none.split(',')]:
return make_response('', 304, {'ETag': etag})
return make_response(e['ics'], 200, {'Content-Type': 'text/calendar; charset=utf-8', 'ETag': etag})
if method == 'PROPFIND':
if not e:
return make_response('Niet gevonden', 404)
return dav_multistatus([dav_event_response(u, e, dav_wanted(dav_body()))])
if method == 'DELETE':
if not e:
return make_response('Niet gevonden', 404)
if if_match and if_match.strip() not in ('*', etag):
return make_response('Gewijzigd op de server', 412)
db().execute('DELETE FROM events WHERE id=?', (e['id'],))
bump_calendar(u['id'])
db().commit()
return make_response('', 204)
if method == 'PUT':
if (if_none or '').strip() == '*' and e:
return make_response('Bestaat al', 412)
if if_match and (not e or if_match.strip() not in ('*', etag)):
return make_response('Gewijzigd op de server', 412)
data = request.get_data()
if len(data) > DAV_MAX_SIZE:
return make_response('Te groot', 413)
text = data.decode('utf-8', 'replace')
try:
ev = ical.from_ics(text)
except ValueError:
ev = None
if not ev or not ev['uid']:
return make_response('Alleen afspraken (VEVENT met UID) worden opgeslagen', 415)
if q1('SELECT id FROM events WHERE user_id=? AND uid=? AND href<>?', u['id'], ev['uid'], name):
return make_response('Er is al een afspraak met deze UID', 409)
save_event(u['id'], ev, text, name, e['contact_id'] if e else None, e['id'] if e else None)
db().commit()
new = q1('SELECT etag FROM events WHERE user_id=? AND href=?', u['id'], name)
return make_response('', 204 if e else 201, {'ETag': f'"{new["etag"]}"'})
return make_response('Niet toegestaan', 405, {'Allow': 'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND'})
# ---------------------------------------------------------------- demo
DEMO_LEADS = [
+310
View File
@@ -0,0 +1,310 @@
"""iCalendar (RFC 5545) lezen en schrijven voor de agenda en CalDAV, alleen met de standaardbibliotheek.
Een afspraak is een dict met uid, title, location, description, starts_at, ends_at, all_day, rrule en exdates.
Tijden zijn lokaal (Europe/Amsterdam) als 'YYYY-MM-DD HH:MM:SS'; een hele dag is 'YYYY-MM-DD' met een
exclusief einde (de dag ná de laatste dag), zoals in iCalendar. exdates is een kommalijst in hetzelfde formaat.
"""
import calendar
import re
from datetime import date, datetime, timedelta, timezone
from zoneinfo import ZoneInfo
TZ_NAME = 'Europe/Amsterdam'
TZ = ZoneInfo(TZ_NAME)
# Outlook/Exchange sturen Windows-namen mee
WINDOWS_TZ = {'W. Europe Standard Time': TZ_NAME, 'Romance Standard Time': 'Europe/Brussels',
'Central Europe Standard Time': 'Europe/Budapest', 'GMT Standard Time': 'Europe/London',
'UTC': 'UTC'}
WEEKDAYS = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']
VTIMEZONE = """BEGIN:VTIMEZONE
TZID:Europe/Amsterdam
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE""".split('\n')
# ---------------------------------------------------------------- lezen
def _parse_line(line):
"""'NAAM;PARAM=waarde:inhoud' → (NAAM, {PARAM: waarde}, inhoud); dubbelepunten tussen aanhalingstekens tellen niet."""
quoted = False
for i, ch in enumerate(line):
if ch == '"':
quoted = not quoted
elif ch == ':' and not quoted:
head, value = line[:i], line[i + 1:]
break
else:
return None
parts = re.findall(r'(?:[^;"]|"[^"]*")+', head)
if not parts:
return None
params = {}
for p in parts[1:]:
k, _, v = p.partition('=')
params[k.strip().upper()] = v.strip().strip('"')
return parts[0].strip().upper(), params, value
def parse(text):
"""Componentenboom: [{'name': 'VCALENDAR', 'props': [(naam, params, waarde)], 'subs': [...]}]."""
root = {'name': None, 'props': [], 'subs': []}
stack = [root]
for raw in re.sub(r'\r?\n[ \t]', '', text).splitlines():
parsed = _parse_line(raw) if raw.strip() else None
if not parsed:
continue
name, params, value = parsed
if name == 'BEGIN':
comp = {'name': value.strip().upper(), 'props': [], 'subs': []}
stack[-1]['subs'].append(comp)
stack.append(comp)
elif name == 'END':
if len(stack) > 1:
stack.pop()
else:
stack[-1]['props'].append((name, params, value))
return root['subs']
def _prop(comp, name):
return next((p for p in comp['props'] if p[0] == name), None)
def unescape(value):
return re.sub(r'\\([\\;,nN])', lambda m: '\n' if m.group(1) in 'nN' else m.group(1), value)
def _zone(tzid):
tzid = WINDOWS_TZ.get(tzid, tzid)
for candidate in (tzid, '/'.join(tzid.split('/')[-2:])): # ook '/mozilla.org/20050126_1/Europe/Amsterdam'
try:
return ZoneInfo(candidate)
except (KeyError, ValueError, OSError):
continue
return None
def parse_dt(value, params):
"""iCalendar-datum of -tijd → (date, True) of (lokale naïeve datetime, False)."""
v = value.strip()
if params.get('VALUE', '').upper() == 'DATE' or re.fullmatch(r'\d{8}', v):
return date(int(v[:4]), int(v[4:6]), int(v[6:8])), True
m = re.fullmatch(r'(\d{8})T(\d{4,6})(Z?)', v)
if not m:
raise ValueError(f'onbekende datum {value!r}')
dt = datetime.strptime(m.group(1) + m.group(2).ljust(6, '0'), '%Y%m%d%H%M%S')
zone = timezone.utc if m.group(3) else (_zone(params['TZID']) if params.get('TZID') else None)
if zone:
dt = dt.replace(tzinfo=zone).astimezone(TZ).replace(tzinfo=None)
return dt, False
def parse_duration(value):
m = re.fullmatch(r'([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?', value.strip())
if not m:
return timedelta(0)
sign, w, d, h, mi, s = m.groups()
td = timedelta(weeks=int(w or 0), days=int(d or 0), hours=int(h or 0), minutes=int(mi or 0), seconds=int(s or 0))
return -td if sign == '-' else td
def fmt_local(value):
return value.isoformat() if not isinstance(value, datetime) else value.strftime('%Y-%m-%d %H:%M:%S')
def from_ics(text):
"""Eerste (hoofd)afspraak uit een kalenderobject, of None als er geen VEVENT in zit."""
events = [s for c in parse(text) if c['name'] == 'VCALENDAR' for s in c['subs'] if s['name'] == 'VEVENT']
if not events:
return None
ev = next((e for e in events if not _prop(e, 'RECURRENCE-ID')), events[0])
start_p = _prop(ev, 'DTSTART')
if not start_p:
return None
start, all_day = parse_dt(start_p[2], start_p[1])
end_p, dur_p = _prop(ev, 'DTEND'), _prop(ev, 'DURATION')
if end_p:
end, _ = parse_dt(end_p[2], end_p[1])
if all_day and isinstance(end, datetime):
end = end.date()
elif not all_day and not isinstance(end, datetime):
end = datetime.combine(end, datetime.min.time())
elif dur_p:
end = start + parse_duration(dur_p[2])
else:
end = start + timedelta(days=1) if all_day else start
if all_day and end <= start:
end = start + timedelta(days=1)
exdates = []
for name, params, value in ev['props']:
if name == 'EXDATE':
for v in value.split(','):
try:
exdates.append(fmt_local(parse_dt(v, params)[0]))
except ValueError:
pass
text_of = lambda n: unescape(_prop(ev, n)[2]) if _prop(ev, n) else ''
rrule = _prop(ev, 'RRULE')
return {'uid': text_of('UID').strip(), 'title': text_of('SUMMARY').strip(), 'location': text_of('LOCATION').strip(),
'description': text_of('DESCRIPTION').strip(), 'starts_at': fmt_local(start), 'ends_at': fmt_local(end),
'all_day': 1 if all_day else 0, 'rrule': rrule[2].strip() if rrule else '', 'exdates': ','.join(exdates)}
# ---------------------------------------------------------------- schrijven
def escape(value):
return (str(value or '').replace('\\', '\\\\').replace(';', '\\;').replace(',', '\\,')
.replace('\r\n', '\\n').replace('\n', '\\n'))
def _fold(line):
"""Regels langer dan 75 octetten vouwen (zonder een UTF-8-teken te breken)."""
out, cur, size = [], '', 0
for ch in line:
n = len(ch.encode('utf-8'))
if size + n > 75:
out.append(cur)
cur, size = ' ', 1
cur += ch
size += n
out.append(cur)
return '\r\n'.join(out)
def _ical_dt(value, all_day):
if all_day:
return f";VALUE=DATE:{value.replace('-', '')[:8]}"
return f";TZID={TZ_NAME}:{datetime.strptime(value, '%Y-%m-%d %H:%M:%S').strftime('%Y%m%dT%H%M%S')}"
def to_ics(ev, url=''):
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Van Emous//CRM//NL', 'CALSCALE:GREGORIAN']
if not ev['all_day']:
lines += VTIMEZONE
lines += ['BEGIN:VEVENT', f"UID:{ev['uid']}", f'DTSTAMP:{stamp}', f'LAST-MODIFIED:{stamp}',
'DTSTART' + _ical_dt(ev['starts_at'], ev['all_day']), 'DTEND' + _ical_dt(ev['ends_at'], ev['all_day']),
f"SUMMARY:{escape(ev['title'])}"]
if ev.get('location'):
lines.append(f"LOCATION:{escape(ev['location'])}")
if ev.get('description'):
lines.append(f"DESCRIPTION:{escape(ev['description'])}")
if url:
lines.append(f'URL:{url}')
if ev.get('rrule'):
lines.append(f"RRULE:{ev['rrule']}")
for x in filter(None, (ev.get('exdates') or '').split(',')):
lines.append('EXDATE' + _ical_dt(x, ev['all_day']))
lines += ['END:VEVENT', 'END:VCALENDAR']
return '\r\n'.join(_fold(l) for l in lines) + '\r\n'
# ---------------------------------------------------------------- herhalingen
def to_dt(value):
return datetime.strptime(value, '%Y-%m-%d %H:%M:%S') if len(value) > 10 else datetime.strptime(value, '%Y-%m-%d')
def parse_rrule(rrule):
return {k.upper(): v for k, _, v in (p.partition('=') for p in rrule.split(';') if p)}
def simple_freq(rrule):
"""'WEEKLY' voor een eenvoudige herhaling zoals het CRM-formulier hem maakt, anders '' (dan blijft hij staan)."""
r = parse_rrule(rrule or '')
return r['FREQ'] if set(r) == {'FREQ'} and r['FREQ'] in ('DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY') else ''
def _nth_weekday(year, month, n, wd):
days = [d for d in range(1, calendar.monthrange(year, month)[1] + 1) if date(year, month, d).weekday() == wd]
try:
return days[n - 1] if n > 0 else days[n]
except IndexError:
return None
def _candidates(start, rule):
freq, interval = rule.get('FREQ', ''), max(1, int(rule.get('INTERVAL') or 1))
byday = [d for d in (rule.get('BYDAY') or '').split(',') if d]
bymonthday = [int(d) for d in (rule.get('BYMONTHDAY') or '').split(',') if re.fullmatch(r'-?\d+', d)]
i = 0
while True:
if freq == 'DAILY':
yield start + timedelta(days=i * interval)
elif freq == 'WEEKLY':
week = start - timedelta(days=start.weekday()) + timedelta(weeks=i * interval)
days = sorted({WEEKDAYS.index(d[-2:]) for d in byday if d[-2:] in WEEKDAYS}) or [start.weekday()]
for d in days:
c = week + timedelta(days=d)
if c >= start:
yield c
elif freq == 'MONTHLY':
y, m = divmod(start.month - 1 + i * interval, 12)
y, m = start.year + y, m + 1
last = calendar.monthrange(y, m)[1]
if byday:
found = []
for spec in byday:
mm = re.fullmatch(r'([+-]?\d+)?(MO|TU|WE|TH|FR|SA|SU)', spec)
if mm:
day = _nth_weekday(y, m, int(mm.group(1) or 1), WEEKDAYS.index(mm.group(2)))
if day:
found.append(day)
else:
found = [d if d > 0 else last + d + 1 for d in (bymonthday or [start.day])]
for d in sorted(set(found)):
if 1 <= d <= last:
c = start.replace(year=y, month=m, day=d)
if c >= start:
yield c
elif freq == 'YEARLY':
try:
yield start.replace(year=start.year + i * interval)
except ValueError: # 29 februari
pass
else:
return
i += 1
if i > 5000:
return
def occurrences(ev, range_start, range_end):
"""Begin- en eindtijden (datetimes) van een afspraak, herhalingen uitgevouwen, die [range_start, range_end) raken."""
start, end = to_dt(ev['starts_at']), to_dt(ev['ends_at'] or ev['starts_at'])
dur = max(end - start, timedelta(0))
hits = lambda s: s < range_end and (s + dur > range_start if dur else s >= range_start)
if not ev.get('rrule'):
return [(start, start + dur)] if hits(start) else []
rule = parse_rrule(ev['rrule'])
count = int(rule['COUNT']) if (rule.get('COUNT') or '').isdigit() else None
until = None
if rule.get('UNTIL'):
try:
u, is_date = parse_dt(rule['UNTIL'], {})
until = datetime.combine(u, datetime.max.time()) if is_date else u
except ValueError:
pass
skip = {to_dt(x) for x in (ev.get('exdates') or '').split(',') if x}
out, n = [], 0
for c in _candidates(start, rule):
if (until and c > until) or c >= range_end or (count is not None and n >= count):
break
n += 1
if c not in skip and hits(c):
out.append((c, c + dur))
return out
+121 -23
View File
@@ -1,9 +1,16 @@
"""IMAP ophalen en SMTP versturen, alleen met de standaardbibliotheek."""
"""IMAP ophalen en SMTP versturen, alleen met de standaardbibliotheek.
Beveiliging per server: 'ssl' (direct TLS, IMAP 993 / SMTP 465), 'starttls' (IMAP 143 / SMTP 587) of 'none';
leeg = afleiden uit de poort. Met smtp_sent_folder komt een kopie van verstuurde mail in die IMAP-map, voor
servers die dat zelf niet doen (Zimbra, MX Plan; Gmail doet het wel zelf).
"""
import email
import html
import imaplib
import re
import smtplib
import ssl
import time
from datetime import datetime, timedelta
from email.message import EmailMessage
from email.policy import default as policy
@@ -28,17 +35,98 @@ def _text(msg):
return re.sub(r'\n{3,}', '\n\n', content).strip()
# Voorinstellingen voor Instellingen › E-mail (de pagina vult de velden in; daarna blijft alles aanpasbaar).
PROVIDERS = {
'ovh': {'label': 'OVHcloud Zimbra / MX Plan (Europa)', 'imap_host': 'imap.mail.ovh.net', 'imap_port': '993',
'imap_security': 'ssl', 'smtp_host': 'smtp.mail.ovh.net', 'smtp_port': '465', 'smtp_security': 'ssl',
'smtp_sent_folder': 'Sent'},
'ovh-ca': {'label': 'OVHcloud Zimbra / MX Plan (Amerika, Azië)', 'imap_host': 'imap.mail.ovh.ca',
'imap_port': '993', 'imap_security': 'ssl', 'smtp_host': 'smtp.mail.ovh.ca', 'smtp_port': '465',
'smtp_security': 'ssl', 'smtp_sent_folder': 'Sent'},
'gmail': {'label': 'Gmail / Google Workspace (app-wachtwoord)', 'imap_host': 'imap.gmail.com', 'imap_port': '993',
'imap_security': 'ssl', 'smtp_host': 'smtp.gmail.com', 'smtp_port': '587', 'smtp_security': 'starttls',
'smtp_sent_folder': ''},
}
SECURITY = [('', 'Automatisch (uit de poort)'), ('ssl', 'SSL/TLS'), ('starttls', 'STARTTLS'), ('none', 'Geen')]
def _security(cfg, kind, ssl_port):
sec = (cfg.get(f'{kind}_security') or '').lower()
return sec if sec in ('ssl', 'starttls', 'none') else ('ssl' if int(cfg.get(f'{kind}_port') or ssl_port) == ssl_port else 'starttls')
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)
sec = _security(cfg, 'imap', 993)
if sec == 'ssl':
conn = imaplib.IMAP4_SSL(host, port, ssl_context=ssl.create_default_context(), timeout=30)
else:
conn = imaplib.IMAP4(host, port, timeout=30)
conn.starttls()
if sec == 'starttls':
conn.starttls(ssl.create_default_context())
conn.login(cfg['imap_user'], cfg.get('imap_pass', ''))
return conn
def _connect_smtp(cfg):
host, port = cfg.get('smtp_host'), int(cfg.get('smtp_port') or 587)
if not host:
raise RuntimeError('Geen SMTP-server ingesteld')
sec = _security(cfg, 'smtp', 465)
if sec == 'ssl':
server = smtplib.SMTP_SSL(host, port, timeout=30, context=ssl.create_default_context())
else:
server = smtplib.SMTP(host, port, timeout=30)
if sec == 'starttls':
server.starttls(context=ssl.create_default_context())
user = cfg.get('smtp_user') or cfg.get('imap_user', '')
if user:
server.login(user, cfg.get('smtp_pass') or cfg.get('imap_pass', ''))
return server
def _quote(folder):
return '"' + folder.replace('\\', '\\\\').replace('"', '\\"') + '"'
def folders(conn):
names = []
for line in conn.list()[1] or []:
m = re.match(rb'\((?P<flags>[^)]*)\) (?:"[^"]*"|NIL) (?P<name>.+)$', line or b'')
if m and b'\\noselect' not in m.group('flags').lower():
names.append(m.group('name').decode('utf-8', 'replace').strip().strip('"'))
return names
def test(cfg):
"""Verbindingen proberen zonder iets te versturen: [(gelukt, melding)]."""
out = []
if cfg.get('imap_host'):
try:
conn = _connect_imap(cfg)
try:
names = folders(conn)
folder = cfg.get('imap_folder') or 'INBOX'
out.append((True, f"IMAP: ingelogd op {cfg['imap_host']}; mappen: {', '.join(names[:12]) or '—'}"))
if conn.select(_quote(folder), readonly=True)[0] != 'OK':
out.append((False, f'IMAP: map "{folder}" niet gevonden'))
sent = cfg.get('smtp_sent_folder')
if sent and sent not in names:
out.append((False, f'IMAP: map voor verzonden mail "{sent}" niet gevonden'))
finally:
conn.logout()
except Exception as e:
out.append((False, f"IMAP: {e or type(e).__name__}"))
else:
out.append((False, 'IMAP: geen server ingesteld'))
try:
with _connect_smtp(cfg):
out.append((True, f"SMTP: ingelogd op {cfg['smtp_host']}"))
except Exception as e:
out.append((False, f"SMTP: {e or type(e).__name__}"))
return out
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'
@@ -48,7 +136,7 @@ def fetch(cfg, known, limit=200):
conn = _connect_imap(cfg)
try:
typ, _ = conn.select(f'"{folder}"', readonly=True)
typ, _ = conn.select(_quote(folder), readonly=True)
if typ != 'OK':
raise RuntimeError(f'Map "{folder}" niet gevonden')
_, data = conn.uid('search', None, 'SINCE', since_str)
@@ -85,32 +173,42 @@ def fetch(cfg, known, limit=200):
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')
def send(cfg, to, subject, body, in_reply_to=None, cc='', attachments=()):
"""Versturen; geeft (Message-ID, waarschuwing of None). Een mislukte kopie in Verzonden is alleen een waarschuwing.
attachments = [(bestandsnaam, bytes, mimetype)]."""
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)
sender = cfg.get('smtp_from') or user
msg = EmailMessage()
msg['From'] = cfg.get('smtp_from') or user
msg['From'] = sender
msg['To'] = to
if cc:
msg['Cc'] = cc
msg['Subject'] = subject
msg['Date'] = formatdate(localtime=True)
msg['Message-ID'] = make_msgid()
domain = parseaddr(sender)[1].rpartition('@')[2] or None
msg['Message-ID'] = make_msgid(domain=domain)
if in_reply_to:
msg['In-Reply-To'] = in_reply_to
msg['References'] = in_reply_to
msg.set_content(body)
for name, data, ctype in attachments:
maintype, _, subtype = (ctype or 'application/octet-stream').partition('/')
msg.add_attachment(data, maintype=maintype, subtype=subtype or 'octet-stream', filename=name)
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)
with _connect_smtp(cfg) as server:
server.send_message(msg)
return msg['Message-ID']
warning = None
folder = cfg.get('smtp_sent_folder')
if folder and cfg.get('imap_host'):
try:
conn = _connect_imap(cfg)
try:
typ, data = conn.append(_quote(folder), '\\Seen', imaplib.Time2Internaldate(time.time()), msg.as_bytes())
if typ != 'OK':
raise RuntimeError((data[0] or b'').decode('utf-8', 'replace') if data else typ)
finally:
conn.logout()
except Exception as e:
warning = f'Verstuurd, maar niet bewaard in "{folder}": {e}'
return msg['Message-ID'], warning
+41 -1
View File
@@ -145,10 +145,24 @@ CREATE TABLE IF NOT EXISTS emails (
subject TEXT DEFAULT '',
body TEXT DEFAULT '',
date TEXT NOT NULL,
is_read INTEGER NOT NULL DEFAULT 0
is_read INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
cc TEXT DEFAULT '',
attachments TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_emails_from ON emails(from_addr);
-- E-mailtemplates: onderwerp en tekst met velden als {voornaam}, ingevuld bij het opstellen of versturen
-- (MAIL_FIELDS in app.py).
CREATE TABLE IF NOT EXISTS email_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
subject TEXT DEFAULT '',
body TEXT DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE,
@@ -158,3 +172,29 @@ CREATE TABLE IF NOT EXISTS tasks (
done INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
-- Agenda: één agenda per gebruiker, ook via CalDAV (/dav/). `ics` is het kalenderobject zoals een
-- agenda-app het stuurde (of zoals de CRM het maakte), de losse kolommen zijn daaruit gehaald voor weergave.
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
uid TEXT NOT NULL,
href TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
location TEXT DEFAULT '',
description TEXT DEFAULT '',
starts_at TEXT NOT NULL,
ends_at TEXT NOT NULL,
all_day INTEGER NOT NULL DEFAULT 0,
rrule TEXT DEFAULT '',
exdates TEXT DEFAULT '',
ics TEXT NOT NULL,
etag TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_events_href ON events(user_id, href);
CREATE UNIQUE INDEX IF NOT EXISTS idx_events_uid ON events(user_id, uid);
CREATE INDEX IF NOT EXISTS idx_events_start ON events(starts_at);
CREATE INDEX IF NOT EXISTS idx_events_contact ON events(contact_id);
+27
View File
@@ -0,0 +1,27 @@
// E-mail opstellen (templates/_compose.html): een template zet zijn tekst boven het bericht (of vervangt de tekst die
// de vorige template daar zette) en vult een leeg onderwerp. Velden als {voornaam} worden ingevuld met data-vars; wat
// overblijft, vult de server bij het versturen in.
document.querySelectorAll('form.compose').forEach((form) => {
const pick = form.querySelector('select[name="template"]');
if (!pick) return;
const vars = JSON.parse(form.dataset.vars || '{}');
const fill = (text) => text.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? vars[k] : m));
const subject = form.elements.subject, body = form.elements.body;
let added = '', addedSubject = '';
function apply() {
const o = pick.selectedOptions[0];
const on = o && o.value;
const rest = (added && body.value.startsWith(added) ? body.value.slice(added.length) : body.value).replace(/^\n+/, '');
const text = on ? fill(o.dataset.body || '') : '';
added = text && rest ? `${text}\n\n` : text;
body.value = added + rest;
if (!subject.value.trim() || subject.value === addedSubject) {
addedSubject = on ? fill(o.dataset.subject || '') : '';
subject.value = addedSubject;
}
body.focus();
body.setSelectionRange(text.length, text.length);
}
pick.addEventListener('change', apply);
if (pick.value) apply();
});
+58
View File
@@ -0,0 +1,58 @@
/* Zoeken in de bovenbalk: live resultaten (search_live) onder het veld; Enter opent de volledige zoekpagina.
* Pijltjes lopen door de resultaten, Escape sluit, "/" springt naar het zoekveld. */
(() => {
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
document.querySelectorAll('form.tsearch').forEach((form) => {
const input = form.querySelector('input[name=q]');
const box = form.querySelector('.tsearch-results');
let timer = null, ctrl = null, last = '';
const items = () => [...box.querySelectorAll('a')];
const close = () => { box.classList.remove('open'); input.setAttribute('aria-expanded', 'false'); };
const open = () => { if (box.innerHTML) { box.classList.add('open'); input.setAttribute('aria-expanded', 'true'); } };
const render = (data) => {
box.innerHTML = data.groups.length
? data.groups.map((g) => `<div class="ts-group">${esc(g.label)}</div>` + g.items.map((i) =>
`<a class="ts-item" role="option" href="${esc(i.url)}">${esc(i.title)}${i.sub ? `<small>${esc(i.sub)}</small>` : ''}</a>`).join('')).join('')
+ `<a class="ts-all" href="${esc(data.url)}">Alle resultaten voor “${esc(data.q)}”</a>`
: `<div class="ts-empty">Niets gevonden voor “${esc(data.q)}”.</div>`;
open();
};
input.addEventListener('input', () => {
clearTimeout(timer);
const v = input.value.trim();
if (v.length < 2) { box.innerHTML = ''; close(); return; }
timer = setTimeout(() => {
if (v === last && box.innerHTML) { open(); return; }
if (ctrl) ctrl.abort();
ctrl = new AbortController();
fetch(`${form.dataset.live}?q=${encodeURIComponent(v)}`, { signal: ctrl.signal, headers: { Accept: 'application/json' } })
.then((r) => (r.ok ? r.json() : null))
.then((d) => { if (d && input.value.trim() === v) { last = v; render(d); } })
.catch(() => {});
}, 180);
});
form.addEventListener('keydown', (e) => {
const list = items(), at = list.indexOf(document.activeElement);
if (e.key === 'ArrowDown' && list.length) { e.preventDefault(); open(); list[Math.min(at + 1, list.length - 1)].focus(); }
else if (e.key === 'ArrowUp' && list.length) { e.preventDefault(); (at <= 0 ? input : list[at - 1]).focus(); }
else if (e.key === 'Escape') { close(); input.focus(); }
});
input.addEventListener('focus', () => { if (input.value.trim().length >= 2) open(); });
document.addEventListener('click', (e) => { if (!form.contains(e.target)) close(); });
// mobiel: het veld krijgt de focus zodra het uitklapmenu opengaat
const toggle = form.closest('.dropdown')?.querySelector('[data-bs-toggle="dropdown"]');
if (toggle) toggle.addEventListener('shown.bs.dropdown', () => input.focus());
});
document.addEventListener('keydown', (e) => {
if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey || e.target.closest('input, textarea, select, [contenteditable]')) return;
const input = [...document.querySelectorAll('form.tsearch input[name=q]')].find((i) => i.offsetParent);
if (input) { e.preventDefault(); input.focus(); input.select(); }
});
})();
+72 -2
View File
@@ -72,15 +72,41 @@ a:focus-visible, button:focus-visible, .btn:focus-visible { outline: 3px solid v
.active .count { background: var(--white); }
.topbar { position: sticky; top: 0; z-index: 1020; background: var(--white); border-bottom: 1px solid var(--line); padding: 0.6rem 1rem; display: flex; align-items: center; gap: 0.75rem; }
.topbar img { width: 120px; display: block; }
.topbar-logo img { width: 120px; display: block; }
.topbar .menu-btn { border: 1px solid var(--line); background: var(--white); border-radius: var(--radius-pill); padding: 0.45rem 0.9rem; display: inline-flex; align-items: center; gap: 0.5rem; font-weight: 600; color: var(--ink); }
.topbar .menu-btn svg { width: 20px; height: 20px; }
.topbar .count { margin-left: auto; }
.topbar-tools { margin-left: auto; display: flex; align-items: center; gap: 0.5rem; }
.icon-btn { width: 40px; height: 40px; border: 1px solid var(--line); background: var(--white); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: var(--ink); padding: 0; }
.icon-btn svg { width: 20px; height: 20px; }
.icon-btn:hover, .icon-btn[aria-expanded="true"] { background: var(--orange-tint-soft); border-color: var(--orange-line); }
.new-btn { --bs-btn-padding-x: 0.9rem; height: 40px; }
.new-btn svg { width: 18px; height: 18px; }
@media (max-width: 575.98px) { .new-btn { width: 40px; padding: 0; } }
.new-menu { --bs-dropdown-link-active-bg: var(--orange); --bs-dropdown-border-color: var(--line); --bs-dropdown-border-radius: var(--radius-md); min-width: 12rem; padding: 0.4rem; }
.new-menu .dropdown-item { border-radius: var(--radius-sm); padding: 0.45rem 0.8rem; font-weight: 600; }
.new-menu .dropdown-item:hover { background: var(--orange-tint-soft); }
/* zoeken: live resultaten onder het veld (desktop) of in het uitklapmenu (mobiel) */
.tsearch { position: relative; }
.tsearch-icon { position: absolute; left: 0.8rem; top: 50%; width: 18px; height: 18px; transform: translateY(-50%); color: var(--ink-muted); pointer-events: none; }
.tsearch input { padding-left: 2.4rem; border-radius: var(--radius-pill); width: 340px; max-width: 100%; padding-top: 0.45rem; padding-bottom: 0.45rem; }
.tsearch-results { display: none; position: absolute; right: 0; top: calc(100% + 0.4rem); width: min(440px, 90vw); max-height: 70vh; overflow-y: auto; background: var(--white); border: 1px solid var(--line); border-radius: var(--radius-md); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12); padding: 0.4rem; z-index: 1030; }
.tsearch-results.open { display: block; }
.ts-group { font-size: 0.72rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); padding: 0.5rem 0.6rem 0.2rem; }
.ts-item { display: block; padding: 0.4rem 0.6rem; border-radius: var(--radius-sm); color: var(--ink); line-height: 1.3; }
.ts-item small { display: block; color: var(--ink-muted); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ts-item:hover, .ts-item:focus { background: var(--orange-tint-soft); text-decoration: none; outline: none; }
.ts-all { display: block; text-align: center; padding: 0.5rem; border-top: 1px solid var(--line); margin-top: 0.3rem; }
.ts-empty { color: var(--ink-muted); padding: 0.6rem; }
.tsearch-pop { width: calc(100vw - 2rem); padding: 0.75rem; border-radius: var(--radius-md); }
.tsearch-pop .tsearch input { width: 100%; }
.tsearch-pop .tsearch-results { position: static; width: auto; box-shadow: none; border: 0; padding: 0.4rem 0 0; max-height: 60vh; }
.app-main { flex: 1; min-width: 0; }
.content { padding: 1.25rem 1rem 2rem; max-width: 1400px; }
@media (min-width: 992px) {
.topbar { padding: 0.75rem 2.5rem; }
.app { display: flex; min-height: 100vh; }
.side { width: 240px; flex: none; position: sticky; top: 0; height: 100vh; background: var(--white); border-right: 1px solid var(--line); padding: 1.5rem 1rem; display: flex; flex-direction: column; }
.side-panel { display: flex; flex-direction: column; }
@@ -301,3 +327,47 @@ tr.link td { transition: background-color 0.2s; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition: none !important; animation: none !important; }
}
/* ---------- agenda ---------- */
.u0 { --u: #b95913; } .u1 { --u: #3f6e9e; } .u2 { --u: #5b8a3a; } .u3 { --u: #8a4f9e; } .u4 { --u: #a33f4a; } .u5 { --u: #2f7f7b; }
.cal { width: 100%; table-layout: fixed; border-collapse: collapse; }
.cal th { font-size: 0.75rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); padding: 0 0.4rem 0.4rem; }
.cal td { border: 1px solid var(--line); vertical-align: top; height: 7.5rem; padding: 0.3rem; }
.cal td.out { background: var(--ultra-light); }
.cal td.out .cal-num { color: var(--control); }
.cal-num { display: inline-flex; align-items: center; justify-content: center; min-width: 1.8rem; height: 1.8rem; border-radius: 50%; color: var(--ink); font-weight: 600; font-size: 0.875rem; }
.cal-num:hover { background: var(--orange-tint-soft); text-decoration: none; }
.cal td.today .cal-num { background: var(--orange); color: var(--white); }
.cal-ev { display: block; font-size: 0.8rem; line-height: 1.25; padding: 0.15rem 0.4rem; margin-top: 0.15rem; border-radius: 0.35rem; color: var(--ink); background: color-mix(in srgb, var(--u, #b95913) 12%, white); border-left: 3px solid var(--u, #b95913); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cal-ev:hover { text-decoration: none; background: color-mix(in srgb, var(--u, #b95913) 22%, white); }
.cal-ev .t { font-weight: 400; color: var(--ink-muted); margin-right: 0.25rem; }
.cal-ev.task { background: var(--white); border: 1px dashed var(--control); border-left-width: 1px; font-weight: 400; }
.cal-ev.allday { background: var(--u, #b95913); color: var(--white); }
.cal-ld { display: flex; gap: 0.9rem; padding: 0.7rem 0; border-bottom: 1px solid var(--line); }
.cal-ld:last-child { border-bottom: 0; }
.cal-ldate { width: 3.2rem; flex: none; text-align: center; line-height: 1.1; }
.cal-ldate b { display: block; font-family: var(--font-heading); font-size: 1.4rem; font-weight: 600; }
.cal-ldate span { font-size: 0.75rem; color: var(--ink-muted); text-transform: uppercase; letter-spacing: 0.06em; }
.cal-ld.today .cal-ldate b { color: var(--orange); }
.cal-ld .cal-items { flex: 1; min-width: 0; }
.cal-ld .cal-ev { white-space: normal; font-size: 0.9rem; padding: 0.35rem 0.6rem; margin: 0 0 0.3rem; }
.cal-legend { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; font-size: 0.85rem; color: var(--ink-muted); margin-top: 0.75rem; }
.cal-legend span::before { content: ""; display: inline-block; width: 0.7rem; height: 0.7rem; border-radius: 3px; background: var(--u); margin-right: 0.35rem; vertical-align: -1px; }
.agenda-row { display: flex; gap: 0.75rem; padding: 0.55rem 0; border-bottom: 1px solid var(--line); align-items: baseline; }
.agenda-row:last-child { border-bottom: 0; }
.agenda-row .when { width: 7.5rem; flex: none; color: var(--ink-muted); font-size: 0.875rem; }
.dav-box code { word-break: break-all; }
.dav-pass { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1.15rem; letter-spacing: 0.04em; background: var(--orange-tint-soft); border: 1px solid var(--orange-line); border-radius: var(--radius-sm); padding: 0.5rem 0.8rem; display: inline-block; }
/* ---------- afdrukken: alleen de inhoud (offerte, bestek) ---------- */
@media print {
body { background: #fff; }
.side, .topbar, .flash, .no-print { display: none !important; }
.app { display: block; }
.content { padding: 0; max-width: none; }
}
/* E-mailtemplates: velden om in te voegen */
.mail-fields { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.4rem; }
.mail-fields li { display: flex; align-items: center; gap: 0.6rem; font-size: 0.9rem; }
.mail-fields .btn { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; flex: none; }
+29
View File
@@ -0,0 +1,29 @@
{# E-mail opstellen (nieuw, beantwoorden, vanaf een contact). m = {to, cc, subject, body, contact_id, reply_to, next,
template}. Een template zet zijn tekst boven het bericht; velden als {voornaam} vult static/compose.js meteen in met
vars, wat overblijft vult mail_send bij het versturen in. #}
{% macro compose(m, templates, vars, id='m', datalist='', button='Versturen') %}
<form method="post" action="{{ url_for('mail_send') }}" enctype="multipart/form-data" class="compose" data-vars='{{ vars|tojson }}'>
{% if m.reply_to %}<input type="hidden" name="reply_to" value="{{ m.reply_to }}">{% endif %}
{% if m.contact_id %}<input type="hidden" name="contact_id" value="{{ m.contact_id }}">{% endif %}
{% if m.next %}<input type="hidden" name="next" value="{{ m.next }}">{% endif %}
<div class="row g-3">
{% if templates %}
<div class="col-12"><label class="form-label" for="{{ id }}-template">Template</label>
<select class="form-select" id="{{ id }}-template" name="template" autocomplete="off">
<option value="">Geen template</option>
{% for t in templates %}<option value="{{ t.id }}" data-subject="{{ t.subject }}" data-body="{{ t.body }}" {{ 'selected' if m.template == t.id }}>{{ t.name }}</option>{% endfor %}
</select>
<p class="hint">{% if vars.naam is defined %}Velden als {voornaam} worden ingevuld met de gegevens van {{ vars.naam or 'het contact' }}.{% else %}Velden als {voornaam} worden bij het versturen ingevuld met het contact van het adres in Aan.{% endif %}</p>
</div>
{% endif %}
<div class="col-md-6"><label class="form-label" for="{{ id }}-to">Aan</label><input class="form-control" id="{{ id }}-to" type="email" name="to" value="{{ m.to or '' }}" multiple required {% if datalist %}list="{{ datalist }}" autocomplete="off"{% endif %}></div>
<div class="col-md-6"><label class="form-label" for="{{ id }}-cc">CC</label><input class="form-control" id="{{ id }}-cc" type="email" name="cc" value="{{ m.cc or '' }}" multiple {% if datalist %}list="{{ datalist }}" autocomplete="off"{% endif %}></div>
<div class="col-12"><label class="form-label" for="{{ id }}-subject">Onderwerp</label><input class="form-control" id="{{ id }}-subject" name="subject" value="{{ m.subject or '' }}" required></div>
<div class="col-12"><label class="form-label" for="{{ id }}-body">Bericht</label><textarea class="form-control" id="{{ id }}-body" name="body" rows="10" required>
{{ m.body or '' }}</textarea></div>
<div class="col-12"><label class="form-label" for="{{ id }}-files">Bijlagen</label><input class="form-control" id="{{ id }}-files" type="file" name="attachments" multiple></div>
</div>
<div class="actions"><button class="btn btn-primary">{{ button }}</button></div>
</form>
<script src="{{ url_for('static', filename='compose.js') }}" defer></script>
{% endmacro %}
+121
View File
@@ -0,0 +1,121 @@
{# Offerte als document (A4): in de CRM binnen het menu (quote_print.html), in het portaal los (quote_portal.html). #}
<style>
.qdoc { font-family: "Mukta", system-ui, sans-serif; color: #212121; font-size: 14px; line-height: 1.55; font-variant-numeric: lining-nums tabular-nums; }
.qdoc .page { background: #fff; max-width: 210mm; margin: 24px auto; padding: 16mm 15mm; border-radius: 20px; }
.qdoc header { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; }
.qdoc header img { width: 210px; max-width: 45%; }
.qdoc .company { text-align: right; color: #5c6670; font-size: 13px; }
.qdoc .company strong { color: #212121; }
.qdoc .meta { display: flex; justify-content: space-between; gap: 24px; margin: 40px 0 28px; }
.qdoc .label { font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: #5c6670; }
.qdoc .kv td { padding: 1px 0 1px 16px; }
.qdoc .kv td:first-child { padding-left: 0; }
.qdoc h1 { font-family: "Kantumruy Pro", sans-serif; font-weight: 500; font-size: 26px; line-height: 1.15; margin: 0 0 12px; }
.qdoc h1::before { content: ""; display: block; width: 3rem; height: 3px; background: #b95913; margin-bottom: 14px; }
.qdoc .intro, .qdoc .notes { white-space: pre-line; }
.qdoc table.lines { width: 100%; border-collapse: collapse; margin: 24px 0 8px; }
.qdoc .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); }
.qdoc .lines td { padding: 10px 8px; border-bottom: 1px solid rgba(0,0,0,.08); vertical-align: top; }
.qdoc .lines tr { break-inside: avoid; }
.qdoc .desc { white-space: pre-line; }
.qdoc .desc::first-line { font-weight: 600; }
.qdoc .num { text-align: right; white-space: nowrap; }
.qdoc .totals { margin-left: auto; width: 300px; border-collapse: collapse; }
.qdoc .totals td { padding: 4px 8px; }
.qdoc .totals .grand td { font-family: "Kantumruy Pro", sans-serif; font-weight: 600; font-size: 18px; border-top: 2px solid #212121; padding-top: 8px; }
.qdoc .notes { margin-top: 32px; }
.qdoc .sign { margin-top: 40px; display: flex; gap: 48px; }
.qdoc .sign div { flex: 1; border-top: 1px solid rgba(0,0,0,.3); padding-top: 6px; color: #5c6670; font-size: 12px; }
.qdoc .bestek { break-before: page; margin-top: 48px; }
.qdoc .bestek h2 { font-family: "Kantumruy Pro", sans-serif; font-weight: 500; font-size: 15px; margin: 22px 0 4px; display: flex; justify-content: space-between; border-bottom: 2px solid #212121; padding-bottom: 3px; }
.qdoc .bestek table { width: 100%; border-collapse: collapse; }
.qdoc .bestek td { padding: 4px 8px 4px 0; border-bottom: 1px solid rgba(0,0,0,.08); vertical-align: top; }
.qdoc .bestek tr { break-inside: avoid; }
.qdoc .bestek .info td { color: #5c6670; font-style: italic; }
.qdoc .bestek .intro { color: #5c6670; font-size: 13px; }
.qdoc 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 {
.qdoc .page { margin: 0; padding: 0; max-width: none; border-radius: 0; }
}
@media (max-width: 640px) {
.qdoc .page { padding: 20px 16px; margin: 0; border-radius: 0; }
.qdoc header, .qdoc .meta { flex-direction: column; }
.qdoc .company { text-align: left; }
.qdoc .totals { width: 100%; }
}
</style>
<div class="qdoc">
<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>
{% if bestek %}
<section class="bestek" id="bestek">
<h1>Bestek{% if bestek.design.title %}: {{ bestek.design.title }}{% endif %}</h1>
<p class="intro">Bijlage bij offerte {{ qt.number }}. Omschrijving en hoeveelheden van de woning volgens het ontwerp{% if bestek.units %}
({{ bestek.units }} module{{ 's' if bestek.units != 1 }}){% endif %}, berekend op {{ bestek.computedAt[:19]|dt('%d-%m-%Y') }}. Hoeveelheden zijn netto
volgens tekening; de prijzen staan in de offerte.</p>
{% for ch in bestek.chapters %}
<h2><span>{{ loop.index }}. {{ ch.name }}</span></h2>
<table><tbody>
{% for l in ch.lines %}<tr><td>{{ l.label }}</td><td class="num">{{ l.qty|nr }} {{ l.unit }}</td></tr>{% endfor %}
{% for l in ch.info %}<tr class="info"><td>{{ l.label }}</td><td class="num">{{ l.qty|nr }} {{ l.unit }}</td></tr>{% endfor %}
</tbody></table>
{% endfor %}
</section>
{% endif %}
</div>
</div>
+33 -1
View File
@@ -2,7 +2,9 @@
{% 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"><div class="card-body">
<div class="row g-4">
<div class="col-lg-6">
<form method="post" class="card"><div class="card-body">
<h2>Wachtwoord wijzigen</h2>
<div class="row g-3">
<div class="col-12"><label class="form-label" for="old">Huidig wachtwoord</label><input class="form-control" id="old" type="password" name="old_password" required></div>
@@ -10,4 +12,34 @@
</div>
<div class="actions"><button class="btn btn-primary">Opslaan</button></div>
</div></form>
</div>
<div class="col-lg-6">
<div class="card dav-box" id="agenda"><div class="card-body">
<h2>Agenda koppelen</h2>
<p>Zet je CRM-agenda in de agenda van je telefoon of computer (CalDAV). Wijzigingen gaan beide kanten op. Je logt
daar in met een apart <strong>app-wachtwoord</strong>, niet met je CRM-wachtwoord.</p>
{% if dav.password %}
<p class="mb-1">Je nieuwe app-wachtwoord (alleen nu te zien):</p>
<p><span class="dav-pass">{{ dav.password }}</span></p>
{% endif %}
<table class="table table-sm mb-3">
<tr><th class="label" scope="row">Server</th><td><code>{{ dav.host }}</code></td></tr>
<tr><th class="label" scope="row">Volledig adres</th><td><code>{{ dav.url }}</code></td></tr>
<tr><th class="label" scope="row">Gebruikersnaam</th><td><code>{{ g.user.email }}</code></td></tr>
<tr><th class="label" scope="row">Wachtwoord</th><td>{{ 'het app-wachtwoord' if dav.active else 'maak hieronder een app-wachtwoord aan' }}</td></tr>
</table>
<ul class="hint ps-3">
<li><strong>iPhone / iPad:</strong> Instellingen › Agenda › Accounts › Voeg account toe › Anders › Voeg CalDAV-account toe; server <code>{{ dav.host }}</code>.</li>
<li><strong>Mac:</strong> Agenda › Instellingen › Accounts › + › Ander CalDAV-account; type Handmatig, server <code>{{ dav.host }}</code>.</li>
<li><strong>Android:</strong> installeer DAVx⁵, kies "Inloggen met URL en gebruikersnaam" met het volledige adres.</li>
<li><strong>Thunderbird:</strong> Nieuwe agenda › Op het netwerk; het volledige adres.</li>
</ul>
<form method="post" class="actions">
<button class="btn btn-primary" name="action" value="dav" {% if dav.active %}onclick="return confirm('Een nieuw app-wachtwoord maakt het oude ongeldig. Doorgaan?')"{% endif %}>{{ 'Nieuw app-wachtwoord' if dav.active else 'App-wachtwoord aanmaken' }}</button>
{% if dav.active %}<button class="btn btn-outline-secondary" name="action" value="dav-off" onclick="return confirm('Agendakoppeling uitzetten?')">Koppeling uitzetten</button>{% endif %}
</form>
</div></div>
</div>
</div>
{% endblock %}
+44 -5
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{% block head %}{% endblock %}
<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>
@@ -19,6 +20,8 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js" defer></script>
<script src="{{ url_for('static', filename='fx.js') }}" defer></script>
{% if g.user %}<script src="{{ url_for('static', filename='search.js') }}" defer></script>{% endif %}
{% block styles %}{% endblock %}
</head>
<body>
{% block body %}
@@ -40,7 +43,9 @@
<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('calendar_view') }}" class="{{ 'active' if ep.startswith('calendar') }}">Agenda</a>
<a href="{{ url_for('inbox') }}" class="{{ 'active' if ep.startswith('inbox') or ep in ('mail_new', 'mail_send') }}">Inbox {% if unread %}<span class="count">{{ unread }}</span>{% endif %}</a>
<a href="{{ url_for('mail_templates') }}" class="{{ 'active' if ep.startswith('mail_template') }}">E-mailtemplates</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>
@@ -55,12 +60,45 @@
</div>
</aside>
<div class="app-main">
<header class="topbar d-lg-none">
<button class="menu-btn" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidenav" aria-controls="sidenav">
<header class="topbar">
<button class="menu-btn d-lg-none" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidenav" aria-controls="sidenav">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg>Menu
</button>
<a href="{{ url_for('dashboard') }}"><img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous"></a>
{% if unread %}<a href="{{ url_for('inbox') }}" class="count" aria-label="{{ unread }} ongelezen e-mail">{{ unread }}</a>{% endif %}
<a href="{{ url_for('dashboard') }}" class="topbar-logo d-lg-none"><img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous"></a>
<div class="topbar-tools">
{% set sq = request.args.get('q', '') if ep == 'search' else '' %}
{% macro search_form(id, sq) %}
<form class="tsearch" action="{{ url_for('search') }}" method="get" role="search" data-live="{{ url_for('search_live') }}">
<svg class="tsearch-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input class="form-control" id="{{ id }}" type="search" name="q" value="{{ sq }}" placeholder="Zoek lead, klant, offerte…" autocomplete="off" aria-label="Zoeken" aria-controls="{{ id }}-results" aria-autocomplete="list">
<div class="tsearch-results" id="{{ id }}-results" role="listbox" aria-label="Zoekresultaten"></div>
</form>
{% endmacro %}
<div class="d-none d-md-block">{{ search_form('q-top', sq) }}</div>
<div class="dropdown d-md-none">
<button class="icon-btn" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false" aria-label="Zoeken">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
</button>
<div class="dropdown-menu dropdown-menu-end tsearch-pop">{{ search_form('q-pop', sq) }}</div>
</div>
{% if unread %}<a href="{{ url_for('inbox') }}" class="count d-lg-none" aria-label="{{ unread }} ongelezen e-mail">{{ unread }}</a>{% endif %}
<div class="dropdown">
<button class="btn btn-primary btn-sm new-btn" type="button" data-bs-toggle="dropdown" aria-expanded="false" aria-label="Nieuw">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg><span class="d-none d-sm-inline">Nieuw</span>
</button>
<ul class="dropdown-menu dropdown-menu-end new-menu">
<li><a class="dropdown-item" href="{{ url_for('contact_new', type='lead') }}">Lead</a></li>
<li><a class="dropdown-item" href="{{ url_for('contact_new', type='klant') }}">Contactpersoon</a></li>
<li><a class="dropdown-item" href="{{ url_for('relation_edit', kind='klant') }}">Klant</a></li>
<li><a class="dropdown-item" href="{{ url_for('relation_edit', kind='partner') }}">Partner</a></li>
<li><hr class="dropdown-divider"></li>
<li><form method="post" action="{{ url_for('quote_new') }}"><button class="dropdown-item">Offerte</button></form></li>
<li><a class="dropdown-item" href="{{ url_for('product_edit') }}">Product</a></li>
<li><a class="dropdown-item" href="{{ url_for('calendar_edit') }}">Afspraak</a></li>
<li><a class="dropdown-item" href="{{ url_for('mail_new') }}">E-mail</a></li>
</ul>
</div>
</div>
</header>
<main class="content">
{% with msgs = get_flashed_messages(with_categories=true) %}
@@ -71,5 +109,6 @@
</div>
</div>
{% endblock %}
{% block scripts %}{% endblock %}
</body>
</html>
+13 -18
View File
@@ -1,18 +1,12 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{% extends 'base.html' %}
{% block head %}
{# De engine haalt kosten/*.csv en assets/ relatief op: base = de gekopieerde configurator. Alle CRM-links zijn absoluut. #}
<base href="{{ url_for('static', filename='configurator/') }}">
<title>Bestek · Offerte {{ qt.number }} · Van Emous CRM</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Kantumruy+Pro:wght@500;600&family=Mukta:wght@400;600;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% endblock %}
{% block title %}Bestek · Offerte {{ qt.number }}{% endblock %}
{% block styles %}
<style>
.content { margin: 0 auto; max-width: 980px; }
.bestek-page { max-width: 980px; }
.bs-ch { margin-bottom: 1.5rem; }
.bs-ch-head { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; border-bottom: 2px solid var(--ink); padding-bottom: 0.3rem; margin-bottom: 0.25rem; }
.bs-ch-head h2 { margin: 0; font-size: 1.1rem; }
@@ -28,9 +22,9 @@
.bs-opts .form-check { margin-bottom: 0.4rem; }
@media (max-width: 640px) { .bs-ch td.num { width: auto; } }
</style>
</head>
<body>
<main class="content">
{% endblock %}
{% block content %}
<div class="bestek-page">
<div class="head">
<div>
<a class="label" href="{{ url_for('quote_edit', qid=qt.id) }}">← Offerte {{ qt.number }}</a>
@@ -68,12 +62,13 @@
<a class="btn btn-outline-secondary" href="{{ url_for('quote_edit', qid=qt.id) }}">Annuleren</a></div>
</div></form>
</div>
</main>
</div>
{% endblock %}
{% block scripts %}
{% if engine %}
<script type="application/json" id="design">{{ design|tojson }}</script>
<script type="application/json" id="bestek-meta">{{ {'design': {'file_id': design_file.id, 'title': design_file.title}, 'configurator': design.get('configurator') or ''}|tojson }}</script>
{% for src in scripts %}<script src="{{ src }}"></script>
{% endfor %}<script src="{{ url_for('static', filename='bestek.js') }}"></script>
{% endif %}
</body>
</html>
{% endblock %}
+64
View File
@@ -0,0 +1,64 @@
{% extends 'base.html' %}
{% block title %}Agenda · {{ title }}{% endblock %}
{% block content %}
<div class="head">
<div><div class="kicker">Agenda</div><h1>{{ title }}</h1></div>
<div class="actions">
<form method="get" action="{{ url_for('calendar_view') }}">
<input type="hidden" name="m" value="{{ month.strftime('%Y-%m') }}">
<select class="form-select w-auto" name="u" onchange="this.form.submit()" aria-label="Wiens agenda">
<option value="all" {{ 'selected' if who == 'all' }}>Iedereen</option>
<option value="me" {{ 'selected' if who == 'me' }}>Mijn agenda</option>
{% for u in USERS if u.id != g.user.id %}<option value="{{ u.id }}" {{ 'selected' if who == u.id|string }}>{{ u.name }}</option>{% endfor %}
</select>
</form>
<a class="btn btn-outline-secondary" href="{{ url_for('calendar_view', m=prev) }}" aria-label="Vorige maand">‹</a>
{% if month.strftime('%Y-%m') != this_month %}<a class="btn btn-outline-secondary" href="{{ url_for('calendar_view', m=this_month) }}">Vandaag</a>{% endif %}
<a class="btn btn-outline-secondary" href="{{ url_for('calendar_view', m=next) }}" aria-label="Volgende maand">›</a>
<a class="btn btn-primary" href="{{ url_for('calendar_edit') }}">Nieuwe afspraak</a>
</div>
</div>
{% macro item(i) %}
<a class="cal-ev {{ i.color }}{{ ' task' if i.kind == 'task' else (' allday' if i.all_day else '') }}" href="{{ i.url }}"
title="{{ i.title }} · {{ i.when }}{% if i.contact %} · {{ i.contact }}{% endif %}{% if i.user %} · {{ i.user }}{% endif %}">
{%- if i.kind == 'task' %}☐ {% elif not i.all_day %}<span class="t">{{ i.start.strftime('%H:%M') }}</span>{% endif -%}
{{ i.title }}{% if i.repeats %} ↻{% endif %}
</a>
{% endmacro %}
<div class="card"><div class="card-body">
<table class="cal d-none d-md-table">
<thead><tr>{% for d in DAYS %}<th scope="col">{{ d }}</th>{% endfor %}</tr></thead>
<tbody>
{% for week in weeks %}
<tr>
{% for d in week %}
<td class="{{ 'out' if d.month != month.month }}{{ ' today' if d.isoformat() == today }}">
<a class="cal-num" href="{{ url_for('calendar_edit', date=d.isoformat()) }}" aria-label="Afspraak op {{ day_label(d) }}">{{ d.day }}</a>
{% for i in by_day.get(d, []) %}{{ item(i) }}{% endfor %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
<div class="d-md-none">
{% set ns = namespace(any=false) %}
{% for week in weeks %}{% for d in week if d.month == month.month and by_day.get(d) %}
{% set ns.any = true %}
<div class="cal-ld{{ ' today' if d.isoformat() == today }}">
<a class="cal-ldate" href="{{ url_for('calendar_edit', date=d.isoformat()) }}"><b>{{ d.day }}</b><span>{{ DAYS[d.weekday()] }}</span></a>
<div class="cal-items">{% for i in by_day[d] %}{{ item(i) }}{% endfor %}</div>
</div>
{% endfor %}{% endfor %}
{% if not ns.any %}<p class="empty">Geen afspraken of taken in {{ title|lower }}.</p>{% endif %}
</div>
{% if who == 'all' and USERS|length > 1 %}
<div class="cal-legend">{% for u in USERS %}<span class="{{ user_color(u.id) }}">{{ u.name }}</span>{% endfor %}<span style="--u: transparent; ">☐ taak</span></div>
{% endif %}
</div></div>
<p class="hint">Je eigen agenda op je telefoon of computer? Zie <a href="{{ url_for('account', _anchor='agenda') }}">Mijn account › Agenda koppelen</a>.</p>
{% endblock %}
+15 -10
View File
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% from '_compose.html' import compose %}
{% block title %}{{ c.name }}{% endblock %}
{% block content %}
<div class="head">
@@ -101,6 +102,18 @@
</form>
</div></div>
<div class="card"><div class="card-body">
<div class="card-top"><h2>Afspraken</h2>
<div class="actions"><a class="btn btn-outline-primary btn-sm" href="{{ url_for('calendar_edit', contact_id=c.id, next=request.path) }}">Afspraak plannen</a></div></div>
{% for i in appointments %}
<div class="agenda-row"><div class="when">{{ i.when }}</div>
<div><a href="{{ url_for('calendar_edit', eid=i.id, next=request.path) }}">{{ i.title }}</a>{% if i.repeats %} ↻{% endif %}
<div class="muted">{{ i.user }}{% if i.location %} · {{ i.location }}{% endif %}</div></div></div>
{% else %}
<p class="empty">Geen afspraken gepland.</p>
{% endfor %}
</div></div>
<div class="card o-3"><div class="card-body">
<h2>Notitie toevoegen</h2>
<form method="post" action="{{ url_for('contact_note', cid=c.id) }}">
@@ -113,15 +126,7 @@
<div class="card"><div class="card-body">
<details>
<summary>E-mail sturen</summary>
<form method="post" action="{{ url_for('mail_send') }}">
<input type="hidden" name="contact_id" value="{{ c.id }}">
<div class="row g-3">
<div class="col-12"><label class="form-label" for="m-to">Aan</label><input class="form-control" id="m-to" type="email" name="to" value="{{ c.email }}" required></div>
<div class="col-12"><label class="form-label" for="m-subject">Onderwerp</label><input class="form-control" id="m-subject" name="subject" required></div>
<div class="col-12"><label class="form-label" for="m-body">Bericht</label><textarea class="form-control" id="m-body" name="body" rows="8" required></textarea></div>
</div>
<div class="actions"><button class="btn btn-primary">Versturen</button></div>
</form>
{{ compose({'to': c.email, 'contact_id': c.id}, mail_templates, mail_vars) }}
</details>
</div></div>
{% endif %}
@@ -170,7 +175,7 @@
{% 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 %}
{% if c.address or c.postcode 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>
+9
View File
@@ -97,6 +97,15 @@
<p class="empty">Geen openstaande taken.</p>
{% endfor %}
</div></div>
<div class="card"><div class="card-body">
<div class="card-top"><h2>Mijn agenda</h2><div class="actions"><a class="btn btn-outline-primary btn-sm" href="{{ url_for('calendar_view') }}">Agenda</a></div></div>
{% for i in agenda %}
<div class="agenda-row"><div class="when">{{ 'Vandaag' if i.start.date().isoformat() == today else i.when.split(' ')[:3]|join(' ') }}{% if not i.all_day %}<br>{{ i.start.strftime('%H:%M') }}{% endif %}</div>
<div><a href="{{ i.url }}">{{ i.title }}</a>{% if i.contact %}<div class="muted">{{ i.contact }}</div>{% endif %}</div></div>
{% else %}
<p class="empty">Geen afspraken de komende 7 dagen.</p>
{% endfor %}
</div></div>
<div class="card"><div class="card-body">
<h2>Ongelezen e-mail</h2>
{% for e in mails %}
+15 -14
View File
@@ -1,39 +1,40 @@
{% extends 'base.html' %}
{% from '_compose.html' import compose %}
{% block title %}{{ e.subject }}{% endblock %}
{% block content %}
{% set incoming = e.direction == 'in' %}
<div class="head">
<div>
<a class="label" href="{{ url_for('inbox') }}">← Inbox</a>
<a class="label" href="{{ url_for('inbox', f=None if incoming and not e.archived else ('archived' if incoming else 'sent')) }}">← {{ 'Verzonden' if not incoming else ('Archief' if e.archived else 'Inbox') }}</a>
<h1>{{ e.subject }}</h1>
<div class="muted" style="overflow-wrap:anywhere">
{% if e.direction == 'in' %}Van {{ e.from_name }} &lt;{{ e.from_addr }}&gt;{% else %}Aan {{ e.to_addr }}{% endif %}
{% if incoming %}Van {{ e.from_name }} &lt;{{ e.from_addr }}&gt;{% else %}Aan {{ e.to_addr }}{% endif %}
{% if e.cc %}· CC {{ e.cc }}{% endif %}
· {{ e.date|dt }}
</div>
{% if e.attachments %}<div class="muted" style="overflow-wrap:anywhere">Bijlagen: {{ e.attachments }}</div>{% endif %}
</div>
<div class="actions">
{% if contact %}
<a class="btn btn-outline-primary" href="{{ url_for('contact_view', cid=contact.id) }}">Naar {{ contact.name }}</a>
{% elif e.direction == 'in' %}
{% elif incoming %}
<form method="post" action="{{ url_for('inbox_contact', eid=e.id) }}" class="inline"><button class="btn btn-outline-primary">Maak contact aan</button></form>
{% endif %}
<a class="btn btn-outline-primary" href="{{ url_for('mail_new', forward=e.id) }}">Doorsturen</a>
{% if incoming %}
<form method="post" action="{{ url_for('inbox_unread', eid=e.id) }}" class="inline"><button class="btn btn-outline-secondary">Markeer als ongelezen</button></form>
<form method="post" action="{{ url_for('inbox_archive', eid=e.id) }}" class="inline"><button class="btn btn-outline-secondary">{{ 'Terug naar inbox' if e.archived else 'Archiveren' }}</button></form>
{% endif %}
</div>
</div>
<div class="card"><div class="card-body"><div class="mailbody">{{ e.body }}</div></div></div>
{% if e.direction == 'in' %}
{% if incoming %}
<div class="card"><div class="card-body">
<h2>Beantwoorden</h2>
<form method="post" action="{{ url_for('mail_send') }}">
<input type="hidden" name="reply_to" value="{{ e.id }}">
{% if contact %}<input type="hidden" name="contact_id" value="{{ contact.id }}">{% endif %}
<div class="row g-3">
<div class="col-md-6"><label class="form-label" for="to">Aan</label><input class="form-control" id="to" type="email" name="to" value="{{ e.from_addr }}" required></div>
<div class="col-md-6"><label class="form-label" for="subject">Onderwerp</label><input class="form-control" id="subject" name="subject" value="{{ e.subject if e.subject.lower().startswith('re:') else 'Re: ' ~ e.subject }}" required></div>
<div class="col-12"><label class="form-label" for="body">Bericht</label><textarea class="form-control" id="body" name="body" rows="8" required></textarea></div>
</div>
<div class="actions"><button class="btn btn-primary">Versturen</button></div>
</form>
{{ compose({'to': e.from_addr, 'subject': e.subject if e.subject.lower().startswith('re:') else 'Re: ' ~ e.subject,
'reply_to': e.id, 'contact_id': contact.id if contact else None}, templates, vars) }}
</div></div>
{% endif %}
{% endblock %}
+74
View File
@@ -0,0 +1,74 @@
{% extends 'base.html' %}
{% block title %}{{ e.title if e else 'Nieuwe afspraak' }}{% endblock %}
{% block content %}
<div class="head">
<div>
<a class="label" href="{{ nxt or url_for('calendar_view', m=v.start_date[:7]) }}">← Agenda</a>
<h1>{{ 'Afspraak bewerken' if e else 'Nieuwe afspraak' }}</h1>
</div>
{% if e and e.contact_id %}<div class="actions"><a class="btn btn-outline-secondary" href="{{ url_for('contact_view', cid=e.contact_id) }}">{{ e.contact_name }}</a></div>{% endif %}
</div>
<form method="post" class="card" style="max-width:860px"><div class="card-body">
<input type="hidden" name="next" value="{{ nxt }}">
<div class="row g-3">
<div class="col-12"><label class="form-label" for="title">Titel *</label><input class="form-control" id="title" name="title" value="{{ v.title }}" required autofocus></div>
<div class="col-12">
<div class="form-check"><input class="form-check-input" type="checkbox" id="all_day" name="all_day" {{ 'checked' if v.all_day }}><label class="form-check-label" for="all_day">Hele dag</label></div>
</div>
<div class="col-7 col-md-4"><label class="form-label" for="start_date">Van</label><input class="form-control" type="date" id="start_date" name="start_date" value="{{ v.start_date }}" required></div>
<div class="col-5 col-md-2 ev-time"><label class="form-label" for="start_time">Tijd</label><input class="form-control" type="time" id="start_time" name="start_time" value="{{ v.start_time }}" step="300"></div>
<div class="col-7 col-md-4"><label class="form-label" for="end_date">Tot en met</label><input class="form-control" type="date" id="end_date" name="end_date" value="{{ v.end_date }}"></div>
<div class="col-5 col-md-2 ev-time"><label class="form-label" for="end_time">Tijd</label><input class="form-control" type="time" id="end_time" name="end_time" value="{{ v.end_time }}" step="300"></div>
<div class="col-md-6"><label class="form-label" for="user_id">Agenda van</label>
<select class="form-select" id="user_id" name="user_id">
{% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if v.user_id|string == u.id|string }}>{{ u.name }}</option>{% endfor %}
</select>
</div>
<div class="col-md-6"><label class="form-label" for="repeat">Herhalen</label>
<select class="form-select" id="repeat" name="repeat">
{% if v.repeat == 'keep' %}<option value="keep" selected>Zoals ingesteld in de agenda-app</option>{% endif %}
{% for k, l in REPEATS %}<option value="{{ k }}" {{ 'selected' if v.repeat == k }}>{{ l }}</option>{% endfor %}
</select>
</div>
<div class="col-md-6"><label class="form-label" for="contact_id">Contact</label>
<select class="form-select" id="contact_id" name="contact_id">
<option value="">— Geen —</option>
{% for c in contacts %}<option value="{{ c.id }}" {{ 'selected' if v.contact_id|string == c.id|string }}>{{ c.name }}{% if c.type == 'lead' %} (lead){% endif %}</option>{% endfor %}
</select>
</div>
<div class="col-md-6"><label class="form-label" for="location">Locatie</label><input class="form-control" id="location" name="location" value="{{ v.location }}"></div>
<div class="col-12"><label class="form-label" for="description">Notitie</label><textarea class="form-control" id="description" name="description" rows="4">{{ v.description }}</textarea></div>
</div>
{% if e and e.ics and 'VALARM' in e.ics %}<p class="hint">Deze afspraak heeft herinneringen uit een agenda-app; opslaan in de CRM haalt die weg.</p>{% endif %}
<div class="actions">
<button class="btn btn-primary">Opslaan</button>
<a class="btn btn-outline-secondary" href="{{ nxt or url_for('calendar_view', m=v.start_date[:7]) }}">Annuleren</a>
{% if e %}<button class="btn btn-outline-secondary ms-auto" form="delete-event" onclick="return confirm('Afspraak verwijderen?')">Verwijderen</button>{% endif %}
</div>
</div></form>
{% if e %}<form id="delete-event" method="post" action="{{ url_for('calendar_delete', eid=e.id) }}"></form>{% endif %}
<script>
(() => {
const allDay = document.getElementById('all_day'), sd = document.getElementById('start_date'), ed = document.getElementById('end_date');
const st = document.getElementById('start_time'), et = document.getElementById('end_time');
const sync = () => document.querySelectorAll('.ev-time').forEach((el) => { el.hidden = allDay.checked; });
allDay.addEventListener('change', sync);
sync();
// einddatum en -tijd schuiven mee met het begin
let lastStart = sd.value;
sd.addEventListener('change', () => {
if (!ed.value || ed.value === lastStart || ed.value < sd.value) ed.value = sd.value;
lastStart = sd.value;
});
let lastTime = st.value;
st.addEventListener('change', () => {
const toMin = (t) => { const [h, m] = t.split(':').map(Number); return h * 60 + m; };
if (lastTime && et.value && st.value) {
const end = Math.min(toMin(et.value) + toMin(st.value) - toMin(lastTime), 23 * 60 + 55);
if (end > toMin(st.value)) et.value = `${String(Math.floor(end / 60)).padStart(2, '0')}:${String(end % 60).padStart(2, '0')}`;
}
lastTime = st.value;
});
})();
</script>
{% endblock %}
+20 -9
View File
@@ -1,10 +1,17 @@
{% extends 'base.html' %}
{% block title %}Inbox{% endblock %}
{% block content %}
{% set sent = f == 'sent' %}
<div class="head">
<div><div class="kicker">E-mail</div><h1>Inbox</h1>{% if last_sync %}<div class="muted">Laatst opgehaald: {{ last_sync|dt }}</div>{% endif %}</div>
<div><div class="kicker">E-mail</div><h1>{{ {'sent': 'Verzonden', 'archived': 'Archief'}.get(f, 'Inbox') }}</h1>{% if last_sync %}<div class="muted">Laatst opgehaald: {{ last_sync|dt }}</div>{% endif %}</div>
<div class="actions">
<form method="post" action="{{ url_for('inbox_sync') }}" class="inline"><button class="btn btn-primary">Nieuwe e-mail ophalen</button></form>
<form class="search" method="get">
<input type="hidden" name="f" value="{{ f }}">
<input class="form-control" type="search" name="q" value="{{ term }}" placeholder="Zoek op afzender, onderwerp, tekst…" aria-label="Zoeken in e-mail">
</form>
<a class="btn btn-outline-primary" href="{{ url_for('mail_templates') }}">Templates</a>
<form method="post" action="{{ url_for('inbox_sync') }}" class="inline"><button class="btn btn-outline-primary">Nieuwe e-mail ophalen</button></form>
<a class="btn btn-primary" href="{{ url_for('mail_new') }}">Nieuwe e-mail</a>
</div>
</div>
@@ -13,26 +20,30 @@
{% 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>
{% for k, l in [('all', 'Inbox'), ('unread', 'Ongelezen'), ('unlinked', 'Niet gekoppeld aan contact'), ('sent', 'Verzonden'), ('archived', 'Archief')] %}
<a href="{{ url_for('inbox', f=k if k != 'all' else None, q=term or None) }}" class="{{ 'on' if f == k }}">{{ l }}{% if k == 'unread' and unread %} <span class="muted">{{ unread }}</span>{% endif %}</a>
{% endfor %}
{% if unread and not sent %}
<form method="post" action="{{ url_for('inbox_read_all') }}" class="inline"><button class="btn btn-link btn-sm">Alles als gelezen markeren</button></form>
{% endif %}
</div>
<div class="card"><div class="card-body">
{% if rows %}
<div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th>Van</th><th>Onderwerp</th><th>Contact</th><th class="num">Datum</th></tr></thead>
<thead><tr><th>{{ 'Aan' if sent else '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 class="stack-title">{% if not e.is_read %}<span class="dot" aria-label="Ongelezen"></span>{% endif %}{{ e.from_name or e.from_addr }}<a class="d-block d-md-none" href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td>
<td class="d-none d-md-table-cell"><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td>
<td class="stack-title">{% if not e.is_read %}<span class="dot" aria-label="Ongelezen"></span>{% endif %}{{ e.to_addr if sent else e.from_name or e.from_addr }}<a class="d-block d-md-none" href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td>
<td class="d-none d-md-table-cell"><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a>{% if e.attachments %} <span class="muted" title="{{ e.attachments }}">· bijlage</span>{% endif %}</td>
<td data-label="Contact">{{ e.contact_name or '' }}</td>
<td data-label="Datum" class="muted num">{{ e.date|dt }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Geen berichten.</p>{% endif %}
{% else %}<p class="empty">{{ 'Niets gevonden.' if term else 'Geen berichten.' }}</p>{% endif %}
</div></div>
{% if f == 'archived' %}<p class="muted">Archiveren haalt een bericht alleen uit de inbox van de CRM; in je mailbox blijft het staan.</p>{% endif %}
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% from '_compose.html' import compose %}
{% block title %}Nieuwe e-mail{% endblock %}
{% block content %}
<div class="head">
<div><a class="label" href="{{ url_for('inbox') }}">← Inbox</a><h1>{{ 'Beantwoorden' if m.reply_to else 'Nieuwe e-mail' }}</h1>
{% if contact %}<div class="muted">Aan {{ contact.name }}</div>{% endif %}</div>
<div class="actions"><a class="btn btn-outline-primary" href="{{ url_for('mail_templates') }}">Templates</a></div>
</div>
<div class="card"><div class="card-body">
{{ compose(m, templates, vars, datalist='mail-contacts') }}
{% if not templates %}<p class="hint">Nog geen templates. <a href="{{ url_for('mail_template_edit') }}">Maak een template</a> voor berichten die je vaker stuurt.</p>{% endif %}
</div></div>
<datalist id="mail-contacts">{% for c in contacts %}<option value="{{ c.email }}">{{ c.name }}</option>{% endfor %}</datalist>
{% endblock %}
+52
View File
@@ -0,0 +1,52 @@
{% extends 'base.html' %}
{% block title %}{{ t.name or 'Nieuwe template' }}{% endblock %}
{% block content %}
<div class="head">
<div><a class="label" href="{{ url_for('mail_templates') }}">← Templates</a><h1>{{ 'Template bewerken' if t.id else 'Nieuwe template' }}</h1></div>
{% if t.id %}<div class="actions"><a class="btn btn-outline-primary" href="{{ url_for('mail_new', template=t.id) }}">Gebruiken</a></div>{% endif %}
</div>
<div class="row g-4">
<div class="col-lg-8">
<form method="post" class="card"><div class="card-body">
<div class="row g-3">
<div class="col-12"><label class="form-label" for="name">Naam *</label><input class="form-control" id="name" name="name" value="{{ t.name or '' }}" placeholder="Bijvoorbeeld: Ontwerp ontvangen" required></div>
<div class="col-12"><label class="form-label" for="subject">Onderwerp</label><input class="form-control" id="subject" name="subject" value="{{ t.subject or '' }}" data-field-target></div>
<div class="col-12"><label class="form-label" for="body">Tekst</label><textarea class="form-control" id="body" name="body" rows="16" data-field-target>
{{ t.body or '' }}</textarea></div>
</div>
<div class="actions">
<button class="btn btn-primary">Opslaan</button>
<a class="btn btn-outline-secondary" href="{{ url_for('mail_templates') }}">Annuleren</a>
</div>
</div></form>
{% if t.id %}
<form method="post" action="{{ url_for('mail_template_delete', tid=t.id) }}" onsubmit="return confirm('Template verwijderen?')">
<button class="btn btn-outline-secondary btn-sm">Template verwijderen</button>
</form>
{% endif %}
</div>
<div class="col-lg-4">
<div class="card"><div class="card-body">
<h2>Velden</h2>
<p class="hint">Klik om een veld in te voegen in het onderwerp of de tekst (waar de cursor stond).</p>
<ul class="mail-fields">
{% for k, label in FIELDS %}<li><button type="button" class="btn btn-outline-primary btn-sm" data-field="{{ k }}">{{ '{' ~ k ~ '}' }}</button><span class="muted">{{ label }}</span></li>{% endfor %}
</ul>
</div></div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Veld invoegen in het laatst gebruikte tekstvak (standaard de tekst).
(() => {
let target = document.getElementById('body');
document.querySelectorAll('[data-field-target]').forEach(el => el.addEventListener('focus', () => { target = el; }));
document.querySelectorAll('[data-field]').forEach(btn => btn.addEventListener('click', () => {
const text = `{${btn.dataset.field}}`, a = target.selectionStart ?? target.value.length, b = target.selectionEnd ?? a;
target.setRangeText(text, a, b, 'end');
target.focus();
}));
})();
</script>
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
{% extends 'base.html' %}
{% block title %}E-mailtemplates{% endblock %}
{% block content %}
<div class="head">
<div><div class="kicker">E-mail</div><h1>Templates</h1></div>
<div class="actions">
<a class="btn btn-outline-primary" href="{{ url_for('inbox') }}">Inbox</a>
<a class="btn btn-primary" href="{{ url_for('mail_template_edit') }}">Nieuwe template</a>
</div>
</div>
<div class="card"><div class="card-body">
{% if rows %}
<div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th>Naam</th><th>Onderwerp</th><th>Bijgewerkt</th><th></th></tr></thead>
<tbody>
{% for t in rows %}
<tr class="link" onclick="location='{{ url_for('mail_template_edit', tid=t.id) }}'">
<td class="stack-title"><a href="{{ url_for('mail_template_edit', tid=t.id) }}">{{ t.name }}</a></td>
<td data-label="Onderwerp">{{ t.subject }}</td>
<td data-label="Bijgewerkt" class="muted">{{ t.updated_at|dt }}</td>
<td class="num"><a class="btn btn-outline-primary btn-sm" href="{{ url_for('mail_new', template=t.id) }}" onclick="event.stopPropagation()">Gebruiken</a></td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Nog geen templates.</p>{% endif %}
</div></div>
<p class="muted">Kies een template bij een nieuwe e-mail, een antwoord of op de pagina van een contact. Velden tussen accolades,
zoals {voornaam}, worden ingevuld met de gegevens van het contact, van jou en van het bedrijf.</p>
{% endblock %}
+36
View File
@@ -0,0 +1,36 @@
<!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); }
@media print {
body { background: #fff; }
.bar { display: none; }
}
</style>
</head>
<body>
<div class="bar">
<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 %}
<button class="btn {{ 'ghost' if qt.status == 'verzonden' }}" onclick="window.print()">Afdrukken / opslaan als PDF</button>
</div>
{% include '_quote_doc.html' %}
</body>
</html>
+15 -145
View File
@@ -1,152 +1,22 @@
<!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">
{% extends 'base.html' %}
{% block title %}Offerte {{ qt.number }}{% if c %} · {{ c.name }}{% endif %}{% endblock %}
{% block styles %}
<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; }
.bestek { break-before: page; margin-top: 48px; }
.bestek h2 { font-family: "Kantumruy Pro", sans-serif; font-weight: 500; font-size: 15px; margin: 22px 0 4px; display: flex; justify-content: space-between; border-bottom: 2px solid #212121; padding-bottom: 3px; }
.bestek table { width: 100%; border-collapse: collapse; }
.bestek td { padding: 4px 8px 4px 0; border-bottom: 1px solid rgba(0,0,0,.08); vertical-align: top; }
.bestek tr { break-inside: avoid; }
.bestek .info td { color: #5c6670; font-style: italic; }
.bestek .intro { color: #5c6670; font-size: 13px; }
footer { margin-top: 40px; padding-top: 12px; border-top: 1px solid rgba(0,0,0,.08); color: #5c6670; font-size: 12px; text-align: center; }
@media print {
body { background: #fff; }
.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%; }
}
.qdoc .page { margin: 0 auto 2rem; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); }
@media print { .qdoc .page { margin: 0; } }
</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">
{% endblock %}
{% block content %}
<div class="head no-print">
<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 %}
<a class="label" href="{{ url_for('quote_edit', qid=qt.id) }}">← Offerte {{ qt.number }}</a>
<h1>Offerte bekijken</h1>
</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 class="actions">
<a class="btn btn-outline-secondary" href="{{ url_for('quote_edit', qid=qt.id) }}">Bewerken</a>
<button class="btn btn-primary" type="button" onclick="window.print()">Afdrukken / opslaan als PDF</button>
</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>
{% if bestek %}
<section class="bestek" id="bestek">
<h1>Bestek{% if bestek.design.title %}: {{ bestek.design.title }}{% endif %}</h1>
<p class="intro">Bijlage bij offerte {{ qt.number }}. Omschrijving en hoeveelheden van de woning volgens het ontwerp{% if bestek.units %}
({{ bestek.units }} module{{ 's' if bestek.units != 1 }}){% endif %}, berekend op {{ bestek.computedAt[:19]|dt('%d-%m-%Y') }}. Hoeveelheden zijn netto
volgens tekening; de prijzen staan in de offerte.</p>
{% for ch in bestek.chapters %}
<h2><span>{{ loop.index }}. {{ ch.name }}</span></h2>
<table><tbody>
{% for l in ch.lines %}<tr><td>{{ l.label }}</td><td class="num">{{ l.qty|nr }} {{ l.unit }}</td></tr>{% endfor %}
{% for l in ch.info %}<tr class="info"><td>{{ l.label }}</td><td class="num">{{ l.qty|nr }} {{ l.unit }}</td></tr>{% endfor %}
</tbody></table>
{% endfor %}
</section>
{% endif %}
</div>
</body>
</html>
{% include '_quote_doc.html' %}
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends 'base.html' %}
{% block title %}Zoeken{% if term %}: {{ term }}{% endif %}{% endblock %}
{% block content %}
<div class="head"><div><div class="kicker">Zoeken</div><h1>{{ '“' ~ term ~ '”' if term else 'Zoeken' }}</h1></div></div>
{% if term|length < 2 %}
<div class="card"><div class="card-body"><p class="empty">Typ minstens twee tekens in het zoekveld bovenaan.</p></div></div>
{% elif not groups %}
<div class="card"><div class="card-body"><p class="empty">Niets gevonden. Zoek op naam, e-mailadres, telefoonnummer, plaats, offertenummer of SKU.</p></div></div>
{% else %}
{% for grp in groups %}
<div class="card"><div class="card-body">
<h2>{{ grp.label }} <span class="count">{{ grp['items']|length }}{{ '+' if grp.more }}</span></h2>
{% for i in grp['items'] %}
<div class="agenda-row"><div><a href="{{ i.url }}">{{ i.title }}</a>{% if i.sub %}<div class="muted">{{ i.sub }}</div>{% endif %}</div></div>
{% endfor %}
{% if grp.more %}<p class="hint">Meer dan {{ grp['items']|length }} resultaten; maak de zoekterm specifieker.</p>{% endif %}
</div></div>
{% endfor %}
{% endif %}
{% endblock %}
+35 -6
View File
@@ -3,6 +3,10 @@
{% macro field(key, label, type='text', placeholder='', full=false) %}
<div class="{{ 'col-12' if full else 'col-md-6 col-xl-4' }}"><label class="form-label" for="{{ key }}">{{ label }}</label><input class="form-control" id="{{ key }}" type="{{ type }}" name="{{ key }}" value="{{ s.get(key, '') }}" placeholder="{{ placeholder }}"></div>
{% endmacro %}
{% macro security(key) %}
<div class="col-md-6 col-xl-4"><label class="form-label" for="{{ key }}">Beveiliging</label>
<select class="form-select" id="{{ key }}" name="{{ key }}">{% for v, label in SECURITY %}<option value="{{ v }}" {{ 'selected' if s.get(key, '') == v }}>{{ label }}</option>{% endfor %}</select></div>
{% endmacro %}
{% block content %}
<div class="head"><div><div class="kicker">Beheer</div><h1>Instellingen</h1></div></div>
@@ -35,24 +39,39 @@
<div class="card" id="mail"><div class="card-body">
<h2>E-mail koppelen</h2>
<p class="hint" style="margin:-.4rem 0 1rem">Gebruik voor Gmail of Microsoft 365 een app-wachtwoord. Wachtwoorden staan in de database: houd de map <code>data/</code> privé.</p>
<p class="hint" style="margin:-.4rem 0 1rem">Eén gedeelde mailbox (bijv. info@) voor de inbox en het versturen vanuit de CRM. Bij OVHcloud
(Zimbra of MX Plan) is de gebruikersnaam het volledige e-mailadres; de afzender moet dat adres (of een alias ervan) zijn.
Gmail werkt met een app-wachtwoord. Wachtwoorden staan in de database: houd de map <code>data/</code> privé.</p>
<div class="row g-3">
<div class="col-md-6 col-xl-4"><label class="form-label" for="mail-provider">Aanbieder</label>
<select class="form-select" id="mail-provider" aria-describedby="mail-provider-hint">
<option value="">Kies om de servers in te vullen…</option>
{% for key, p in PROVIDERS.items() %}<option value="{{ key }}" data-preset="{{ p|tojson|forceescape }}">{{ p.label }}</option>{% endfor %}
</select>
<p class="hint" id="mail-provider-hint">Vult server, poort en beveiliging in; daarna kun je alles nog aanpassen.</p>
</div>
</div>
<h3>Ontvangen (IMAP)</h3>
<div class="row g-3">
{{ field('imap_host', 'Server', placeholder='imap.gmail.com') }}
{{ field('imap_host', 'Server', placeholder='imap.mail.ovh.net') }}
{{ field('imap_port', 'Poort', 'number', '993') }}
{{ field('imap_user', 'Gebruikersnaam', placeholder='info@vanemous.nl') }}
{{ security('imap_security') }}
{{ field('imap_user', 'Gebruikersnaam', placeholder='info@vanemouswoningen.nl') }}
<div class="col-md-6 col-xl-4"><label class="form-label" for="imap_pass">Wachtwoord</label><input class="form-control" id="imap_pass" type="password" name="imap_pass" placeholder="{{ '•••••••• (ingesteld)' if s.imap_pass else '' }}" autocomplete="new-password"></div>
{{ field('imap_folder', 'Map', placeholder='INBOX') }}
{{ field('imap_days', 'Aantal dagen terug ophalen', 'number', '30') }}
</div>
<h3>Versturen (SMTP)</h3>
<div class="row g-3">
{{ field('smtp_host', 'Server', placeholder='smtp.gmail.com') }}
{{ field('smtp_port', 'Poort', 'number', '587') }}
{{ field('smtp_host', 'Server', placeholder='smtp.mail.ovh.net') }}
{{ field('smtp_port', 'Poort', 'number', '465') }}
{{ security('smtp_security') }}
{{ field('smtp_user', 'Gebruikersnaam', placeholder='leeg = zelfde als IMAP') }}
<div class="col-md-6 col-xl-4"><label class="form-label" for="smtp_pass">Wachtwoord</label><input class="form-control" id="smtp_pass" type="password" name="smtp_pass" placeholder="{{ '•••••••• (ingesteld)' if s.smtp_pass else 'leeg = zelfde als IMAP' }}" autocomplete="new-password"></div>
{{ field('smtp_from', 'Afzender', placeholder='Van Emous <info@vanemous.nl>', full=true) }}
<div class="col-md-6 col-xl-4"><label class="form-label" for="smtp_sent_folder">Kopie in map</label><input class="form-control" id="smtp_sent_folder" name="smtp_sent_folder" value="{{ s.smtp_sent_folder or '' }}" placeholder="leeg = geen kopie" aria-describedby="sent-hint"><p class="hint" id="sent-hint">Bij Zimbra/MX Plan <code>Sent</code>, zodat verstuurde mail ook in de webmail staat. Gmail doet dit zelf.</p></div>
{{ field('smtp_from', 'Afzender', placeholder='Van Emous <info@vanemouswoningen.nl>', full=true) }}
</div>
<div class="actions"><button class="btn btn-outline-primary" name="action" value="mailtest">Opslaan en verbinding testen</button></div>
</div></div>
<div class="card"><div class="card-body">
@@ -126,4 +145,14 @@ const { ok, id } = await res.json();{% endfilter %}</pre>
<button class="btn btn-outline-secondary btn-sm">Nieuwe API-sleutel maken</button>
</form>
</div></div>
<script>
document.getElementById('mail-provider').addEventListener('change', (e) => {
const opt = e.target.selectedOptions[0];
if (!opt || !opt.dataset.preset) return;
Object.entries(JSON.parse(opt.dataset.preset)).forEach(([key, value]) => {
const el = document.getElementById(key);
if (el) el.value = value;
});
});
</script>
{% endblock %}