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:
co-authored by
Claude Opus 5.5
parent
81fc27fa86
commit
366a3f9d17
@@ -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.
|
- **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.
|
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),
|
- **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.
|
- **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
|
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
|
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
|
app.py routes en logica
|
||||||
importer.py CSV/JSON inlezen en kolommen herkennen
|
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
|
schema.sql database
|
||||||
templates/ pagina's (Jinja)
|
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)
|
static/configurator/ engine van de configurator (tools/configurator-sync.sh, niet in git)
|
||||||
tools/ configurator-sync.sh, export-models.js (standaardmodellen als JSON)
|
tools/ configurator-sync.sh, export-models.js (standaardmodellen als JSON)
|
||||||
demo/modellen standaardmodellen als house-design-JSON voor de demoleads
|
demo/modellen standaardmodellen als house-design-JSON voor de demoleads
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ Starten: python app.py
|
|||||||
E-mail ophalen: python app.py sync (bijv. elke 10 minuten via cron)
|
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)
|
Demoleads: python app.py demo (standaardmodellen uit demo/modellen als gekozen model)
|
||||||
"""
|
"""
|
||||||
__version__ = '0.0.3'
|
__version__ = '0.0.4'
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
@@ -17,13 +18,17 @@ import sqlite3
|
|||||||
import sys
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import date, datetime, timedelta
|
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,
|
from flask import (Flask, abort, flash, g, jsonify, make_response, redirect,
|
||||||
render_template, request, send_from_directory, session, url_for)
|
render_template, request, send_from_directory, session, url_for)
|
||||||
from werkzeug.security import check_password_hash, generate_password_hash
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
import ical
|
||||||
import importer
|
import importer
|
||||||
import mail
|
import mail
|
||||||
|
|
||||||
@@ -46,7 +51,8 @@ QUOTE_STATUSES = [('concept', 'Concept'), ('verzonden', 'Verzonden'),
|
|||||||
('geaccepteerd', 'Geaccepteerd'), ('afgewezen', 'Afgewezen')]
|
('geaccepteerd', 'Geaccepteerd'), ('afgewezen', 'Afgewezen')]
|
||||||
ROLES = [('admin', 'Beheerder'), ('user', 'Medewerker')]
|
ROLES = [('admin', 'Beheerder'), ('user', 'Medewerker')]
|
||||||
ACTIVITY_TYPES = {'note': 'Notitie', 'form': 'Formulier', 'email': 'E-mail', 'status': 'Status',
|
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']
|
CONTACT_FIELDS = ['name', 'email', 'phone', 'company', 'address', 'postcode', 'city', 'source', 'notes']
|
||||||
PRODUCT_FIELDS = ['name', 'sku', 'description', 'unit', 'supplier', 'price', 'vat']
|
PRODUCT_FIELDS = ['name', 'sku', 'description', 'unit', 'supplier', 'price', 'vat']
|
||||||
PRODUCT_LABELS = {'name': 'Naam', 'sku': 'SKU', 'description': 'Omschrijving', 'unit': 'Eenheid',
|
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',
|
SETTING_KEYS = ['company_name', 'company_address', 'company_postcode_city', 'company_phone',
|
||||||
'company_email', 'company_website', 'company_kvk', 'company_btw', 'company_iban',
|
'company_email', 'company_website', 'company_kvk', 'company_btw', 'company_iban',
|
||||||
'quote_prefix', 'quote_valid_days', 'quote_intro', 'quote_footer',
|
'quote_prefix', 'quote_valid_days', 'quote_intro', 'quote_footer',
|
||||||
'imap_host', 'imap_port', 'imap_user', 'imap_folder', 'imap_days',
|
'imap_host', 'imap_port', 'imap_user', 'imap_folder', 'imap_days', 'imap_security',
|
||||||
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_from', 'allowed_origins', 'api_assign_user',
|
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_from', 'smtp_security', 'smtp_sent_folder', 'allowed_origins', 'api_assign_user',
|
||||||
'klant_types', 'partner_types']
|
'klant_types', 'partner_types']
|
||||||
SECRET_KEYS = ['imap_pass', 'smtp_pass']
|
SECRET_KEYS = ['imap_pass', 'smtp_pass']
|
||||||
|
|
||||||
FIELD_LOOKUP = importer.lookup(importer.FIELD_ALIASES)
|
FIELD_LOOKUP = importer.lookup(importer.FIELD_ALIASES)
|
||||||
API_SKIP = {'api_key', 'redirect', '_gotcha', 'website_hp', 'g-recaptcha-response'}
|
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'}
|
PORTAL_PUBLIC = {'portal_login', 'portal_invite'}
|
||||||
ADMIN_ONLY = {'settings', 'users', 'user_edit'}
|
ADMIN_ONLY = {'settings', 'users', 'user_edit'}
|
||||||
|
|
||||||
@@ -132,6 +138,8 @@ MIGRATIONS = {
|
|||||||
'design_file_id': 'INTEGER REFERENCES files(id) ON DELETE SET NULL'},
|
'design_file_id': 'INTEGER REFERENCES files(id) ON DELETE SET NULL'},
|
||||||
'files': {'title': "TEXT DEFAULT ''", 'kind': "TEXT DEFAULT ''", 'design_id': 'INTEGER'},
|
'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'},
|
'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'])
|
@app.route('/account', methods=['GET', 'POST'])
|
||||||
def account():
|
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':
|
if request.method == 'POST':
|
||||||
f = request.form
|
f = request.form
|
||||||
if not check_password_hash(g.user['password_hash'], f.get('old_password', '')):
|
if not check_password_hash(g.user['password_hash'], f.get('old_password', '')):
|
||||||
@@ -610,7 +631,7 @@ def account():
|
|||||||
db().commit()
|
db().commit()
|
||||||
flash('Wachtwoord gewijzigd.')
|
flash('Wachtwoord gewijzigd.')
|
||||||
return redirect(url_for('account'))
|
return redirect(url_for('account'))
|
||||||
return render_template('account.html')
|
return render_template('account.html', dav=dav)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/users')
|
@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'])
|
'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(
|
return render_template(
|
||||||
'dashboard.html', stats=stats, pipeline=pipeline, weekly=weekly, sources=sources, tasks=tasks,
|
'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]),
|
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]),
|
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 "
|
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 ''}
|
'note': project.get('opmerking') or '', 'version': d.get('configurator') or ''}
|
||||||
return render_template(
|
return render_template(
|
||||||
'contact.html', c=c, extra=json.loads(c['extra'] or '{}'),
|
'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 '
|
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),
|
'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),
|
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,
|
designs=designs, design=design, design_meta=design_meta,
|
||||||
pdfs=design_pdfs([design['id']]).get(design['id'], {}) if design else {}, PDF_KINDS=PDF_KINDS,
|
pdfs=design_pdfs([design['id']]).get(design['id'], {}) if design else {}, PDF_KINDS=PDF_KINDS,
|
||||||
quotes=quotes_with_totals('WHERE qu.contact_id=?', cid),
|
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 '',
|
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"),
|
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',
|
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')
|
@app.route('/inbox')
|
||||||
def inbox():
|
def inbox():
|
||||||
flt = request.args.get('f', 'all')
|
flt = request.args.get('f', 'all')
|
||||||
where = {'unread': 'AND e.is_read=0', 'unlinked': 'AND e.contact_id IS NULL'}.get(flt, '')
|
term = request.args.get('q', '').strip()
|
||||||
rows = q(f"SELECT e.*, c.name AS contact_name FROM emails e LEFT JOIN contacts c ON c.id=e.contact_id "
|
where = {'unread': "e.direction='in' AND e.archived=0 AND e.is_read=0",
|
||||||
f"WHERE e.direction='in' {where} ORDER BY e.date DESC LIMIT 300")
|
'unlinked': "e.direction='in' AND e.archived=0 AND e.contact_id IS NULL",
|
||||||
return render_template('inbox.html', rows=rows, f=flt, last_sync=get_setting('last_sync'),
|
'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')))
|
configured=bool(get_setting('imap_host')))
|
||||||
|
|
||||||
|
|
||||||
@@ -1304,6 +1343,14 @@ def inbox_sync():
|
|||||||
return redirect(url_for('inbox'))
|
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>')
|
@app.route('/inbox/<int:eid>')
|
||||||
def inbox_view(eid):
|
def inbox_view(eid):
|
||||||
e = get_or_404('emails', 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().execute('UPDATE emails SET is_read=1 WHERE id=?', (eid,))
|
||||||
db().commit()
|
db().commit()
|
||||||
contact = q1('SELECT * FROM contacts WHERE id=?', e['contact_id']) if e['contact_id'] else None
|
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')
|
@app.post('/inbox/<int:eid>/contact')
|
||||||
@@ -1329,29 +1395,143 @@ def inbox_contact(eid):
|
|||||||
return redirect(url_for('contact_view', cid=cid))
|
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')
|
@app.post('/mail/send')
|
||||||
def mail_send():
|
def mail_send():
|
||||||
f = request.form
|
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)
|
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
|
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:
|
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:
|
except Exception as e:
|
||||||
flash(f'Versturen mislukt: {e}', 'error')
|
flash(f'Versturen mislukt: {e}' + (' Voeg de bijlagen opnieuw toe.' if files else ''), 'error')
|
||||||
return redirect(target)
|
return compose_page({'to': to, 'cc': cc, 'subject': subject, 'body': body, 'next': nxt,
|
||||||
if not cid:
|
'contact_id': contact['id'] if contact else None,
|
||||||
c = q1('SELECT id FROM contacts WHERE email=? COLLATE NOCASE', to)
|
'reply_to': reply['id'] if reply else None}, contact)
|
||||||
cid = c['id'] if c else None
|
if warning:
|
||||||
db().execute("INSERT INTO emails (message_id, direction, contact_id, from_addr, to_addr, subject, body, date, "
|
flash(warning, 'error')
|
||||||
"is_read) VALUES (?, 'out', ?, ?, ?, ?, ?, ?, 1)",
|
cid = contact['id'] if contact else None
|
||||||
(message_id, cid, get_setting('smtp_from') or get_setting('smtp_user'), to, subject, body, now()))
|
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:
|
if cid:
|
||||||
log(cid, 'email', f'E-mail verstuurd: {subject}')
|
log(cid, 'email', f'E-mail verstuurd: {subject}')
|
||||||
db().commit()
|
db().commit()
|
||||||
flash('E-mail verstuurd.')
|
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
|
# ---------------------------------------------------------------- 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'])
|
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 '
|
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
|
'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))
|
s=settings_dict(), portal=portal, bestek=quote_bestek_data(qt))
|
||||||
|
|
||||||
|
|
||||||
@@ -1813,7 +1993,8 @@ def quote_delete(qid):
|
|||||||
@app.route('/settings', methods=['GET', 'POST'])
|
@app.route('/settings', methods=['GET', 'POST'])
|
||||||
def settings():
|
def settings():
|
||||||
if request.method == 'POST':
|
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))
|
set_setting('api_key', secrets.token_urlsafe(24))
|
||||||
flash('Nieuwe API-sleutel aangemaakt. Pas je formulieren aan.')
|
flash('Nieuwe API-sleutel aangemaakt. Pas je formulieren aan.')
|
||||||
else:
|
else:
|
||||||
@@ -1822,11 +2003,17 @@ def settings():
|
|||||||
for key in SECRET_KEYS:
|
for key in SECRET_KEYS:
|
||||||
if request.form.get(key):
|
if request.form.get(key):
|
||||||
set_setting(key, request.form[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.')
|
flash('Instellingen opgeslagen.')
|
||||||
db().commit()
|
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),
|
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
|
# ---------------------------------------------------------------- klantportaal
|
||||||
@@ -2054,6 +2241,594 @@ def api_products():
|
|||||||
return jsonify(ok=True, **result)
|
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
|
||||||
|
|
||||||
DEMO_LEADS = [
|
DEMO_LEADS = [
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 email
|
||||||
import html
|
import html
|
||||||
import imaplib
|
import imaplib
|
||||||
import re
|
import re
|
||||||
import smtplib
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from email.policy import default as policy
|
from email.policy import default as policy
|
||||||
@@ -28,17 +35,98 @@ def _text(msg):
|
|||||||
return re.sub(r'\n{3,}', '\n\n', content).strip()
|
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):
|
def _connect_imap(cfg):
|
||||||
host, port = cfg['imap_host'], int(cfg.get('imap_port') or 993)
|
host, port = cfg['imap_host'], int(cfg.get('imap_port') or 993)
|
||||||
if port == 993:
|
sec = _security(cfg, 'imap', 993)
|
||||||
conn = imaplib.IMAP4_SSL(host, port, timeout=30)
|
if sec == 'ssl':
|
||||||
|
conn = imaplib.IMAP4_SSL(host, port, ssl_context=ssl.create_default_context(), timeout=30)
|
||||||
else:
|
else:
|
||||||
conn = imaplib.IMAP4(host, port, timeout=30)
|
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', ''))
|
conn.login(cfg['imap_user'], cfg.get('imap_pass', ''))
|
||||||
return conn
|
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):
|
def fetch(cfg, known, limit=200):
|
||||||
"""Haal berichten van de laatste `imap_days` dagen op die nog niet in `known` staan."""
|
"""Haal berichten van de laatste `imap_days` dagen op die nog niet in `known` staan."""
|
||||||
folder = cfg.get('imap_folder') or 'INBOX'
|
folder = cfg.get('imap_folder') or 'INBOX'
|
||||||
@@ -48,7 +136,7 @@ def fetch(cfg, known, limit=200):
|
|||||||
|
|
||||||
conn = _connect_imap(cfg)
|
conn = _connect_imap(cfg)
|
||||||
try:
|
try:
|
||||||
typ, _ = conn.select(f'"{folder}"', readonly=True)
|
typ, _ = conn.select(_quote(folder), readonly=True)
|
||||||
if typ != 'OK':
|
if typ != 'OK':
|
||||||
raise RuntimeError(f'Map "{folder}" niet gevonden')
|
raise RuntimeError(f'Map "{folder}" niet gevonden')
|
||||||
_, data = conn.uid('search', None, 'SINCE', since_str)
|
_, data = conn.uid('search', None, 'SINCE', since_str)
|
||||||
@@ -85,32 +173,42 @@ def fetch(cfg, known, limit=200):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def send(cfg, to, subject, body, in_reply_to=None):
|
def send(cfg, to, subject, body, in_reply_to=None, cc='', attachments=()):
|
||||||
host = cfg.get('smtp_host')
|
"""Versturen; geeft (Message-ID, waarschuwing of None). Een mislukte kopie in Verzonden is alleen een waarschuwing.
|
||||||
if not host:
|
attachments = [(bestandsnaam, bytes, mimetype)]."""
|
||||||
raise RuntimeError('Geen SMTP-server ingesteld')
|
|
||||||
user = cfg.get('smtp_user') or cfg.get('imap_user', '')
|
user = cfg.get('smtp_user') or cfg.get('imap_user', '')
|
||||||
password = cfg.get('smtp_pass') or cfg.get('imap_pass', '')
|
sender = cfg.get('smtp_from') or user
|
||||||
port = int(cfg.get('smtp_port') or 587)
|
|
||||||
|
|
||||||
msg = EmailMessage()
|
msg = EmailMessage()
|
||||||
msg['From'] = cfg.get('smtp_from') or user
|
msg['From'] = sender
|
||||||
msg['To'] = to
|
msg['To'] = to
|
||||||
|
if cc:
|
||||||
|
msg['Cc'] = cc
|
||||||
msg['Subject'] = subject
|
msg['Subject'] = subject
|
||||||
msg['Date'] = formatdate(localtime=True)
|
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:
|
if in_reply_to:
|
||||||
msg['In-Reply-To'] = in_reply_to
|
msg['In-Reply-To'] = in_reply_to
|
||||||
msg['References'] = in_reply_to
|
msg['References'] = in_reply_to
|
||||||
msg.set_content(body)
|
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:
|
with _connect_smtp(cfg) as server:
|
||||||
server = smtplib.SMTP_SSL(host, port, timeout=30)
|
|
||||||
else:
|
|
||||||
server = smtplib.SMTP(host, port, timeout=30)
|
|
||||||
server.starttls()
|
|
||||||
with server:
|
|
||||||
if user:
|
|
||||||
server.login(user, password)
|
|
||||||
server.send_message(msg)
|
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
@@ -145,10 +145,24 @@ CREATE TABLE IF NOT EXISTS emails (
|
|||||||
subject TEXT DEFAULT '',
|
subject TEXT DEFAULT '',
|
||||||
body TEXT DEFAULT '',
|
body TEXT DEFAULT '',
|
||||||
date TEXT NOT NULL,
|
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);
|
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 (
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE,
|
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,
|
done INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL
|
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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
@@ -72,15 +72,41 @@ a:focus-visible, button:focus-visible, .btn:focus-visible { outline: 3px solid v
|
|||||||
.active .count { background: var(--white); }
|
.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 { 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 { 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 .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; }
|
.app-main { flex: 1; min-width: 0; }
|
||||||
.content { padding: 1.25rem 1rem 2rem; max-width: 1400px; }
|
.content { padding: 1.25rem 1rem 2rem; max-width: 1400px; }
|
||||||
|
|
||||||
@media (min-width: 992px) {
|
@media (min-width: 992px) {
|
||||||
|
.topbar { padding: 0.75rem 2.5rem; }
|
||||||
.app { display: flex; min-height: 100vh; }
|
.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 { width: 240px; flex: none; position: sticky; top: 0; height: 100vh; background: var(--white); border-right: 1px solid var(--line); padding: 1.5rem 1rem; display: flex; flex-direction: column; }
|
||||||
.side-panel { display: flex; flex-direction: column; }
|
.side-panel { display: flex; flex-direction: column; }
|
||||||
@@ -301,3 +327,47 @@ tr.link td { transition: background-color 0.2s; }
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after { transition: none !important; animation: none !important; }
|
*, *::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; }
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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
@@ -2,7 +2,9 @@
|
|||||||
{% block title %}Mijn account{% endblock %}
|
{% block title %}Mijn account{% endblock %}
|
||||||
{% block content %}
|
{% 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>
|
<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>
|
<h2>Wachtwoord wijzigen</h2>
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-12"><label class="form-label" for="old">Huidig wachtwoord</label><input class="form-control" id="old" type="password" name="old_password" required></div>
|
<div class="col-12"><label class="form-label" for="old">Huidig wachtwoord</label><input class="form-control" id="old" type="password" name="old_password" required></div>
|
||||||
@@ -10,4 +12,34 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="actions"><button class="btn btn-primary">Opslaan</button></div>
|
<div class="actions"><button class="btn btn-primary">Opslaan</button></div>
|
||||||
</div></form>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
+44
-5
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
{% block head %}{% endblock %}
|
||||||
<title>{% block title %}CRM{% endblock %} · Van Emous CRM</title>
|
<title>{% block title %}CRM{% endblock %} · Van Emous CRM</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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://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="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>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% block 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='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('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('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('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('quotes') }}" class="{{ 'active' if ep.startswith('quote') }}">Offertes</a>
|
||||||
<a href="{{ url_for('import_start') }}" class="{{ 'active' if ep.startswith('import') }}">Importeren</a>
|
<a href="{{ url_for('import_start') }}" class="{{ 'active' if ep.startswith('import') }}">Importeren</a>
|
||||||
@@ -55,12 +60,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="app-main">
|
<div class="app-main">
|
||||||
<header class="topbar d-lg-none">
|
<header class="topbar">
|
||||||
<button class="menu-btn" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidenav" aria-controls="sidenav">
|
<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
|
<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>
|
</button>
|
||||||
<a href="{{ url_for('dashboard') }}"><img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous"></a>
|
<a href="{{ url_for('dashboard') }}" class="topbar-logo d-lg-none"><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 %}
|
<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>
|
</header>
|
||||||
<main class="content">
|
<main class="content">
|
||||||
{% with msgs = get_flashed_messages(with_categories=true) %}
|
{% with msgs = get_flashed_messages(with_categories=true) %}
|
||||||
@@ -71,5 +109,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+13
-18
@@ -1,18 +1,12 @@
|
|||||||
<!doctype html>
|
{% extends 'base.html' %}
|
||||||
<html lang="nl">
|
{% block head %}
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
{# De engine haalt kosten/*.csv en assets/ relatief op: base = de gekopieerde configurator. Alle CRM-links zijn absoluut. #}
|
{# 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/') }}">
|
<base href="{{ url_for('static', filename='configurator/') }}">
|
||||||
<title>Bestek · Offerte {{ qt.number }} · Van Emous CRM</title>
|
{% endblock %}
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
{% block title %}Bestek · Offerte {{ qt.number }}{% endblock %}
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
{% block styles %}
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Kantumruy+Pro:wght@500;600&family=Mukta:wght@400;600;800&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
|
||||||
<style>
|
<style>
|
||||||
.content { margin: 0 auto; max-width: 980px; }
|
.bestek-page { max-width: 980px; }
|
||||||
.bs-ch { margin-bottom: 1.5rem; }
|
.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 { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; border-bottom: 2px solid var(--ink); padding-bottom: 0.3rem; margin-bottom: 0.25rem; }
|
||||||
.bs-ch-head h2 { margin: 0; font-size: 1.1rem; }
|
.bs-ch-head h2 { margin: 0; font-size: 1.1rem; }
|
||||||
@@ -28,9 +22,9 @@
|
|||||||
.bs-opts .form-check { margin-bottom: 0.4rem; }
|
.bs-opts .form-check { margin-bottom: 0.4rem; }
|
||||||
@media (max-width: 640px) { .bs-ch td.num { width: auto; } }
|
@media (max-width: 640px) { .bs-ch td.num { width: auto; } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
{% endblock %}
|
||||||
<body>
|
{% block content %}
|
||||||
<main class="content">
|
<div class="bestek-page">
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<div>
|
<div>
|
||||||
<a class="label" href="{{ url_for('quote_edit', qid=qt.id) }}">← Offerte {{ qt.number }}</a>
|
<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>
|
<a class="btn btn-outline-secondary" href="{{ url_for('quote_edit', qid=qt.id) }}">Annuleren</a></div>
|
||||||
</div></form>
|
</div></form>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
{% if engine %}
|
{% if engine %}
|
||||||
<script type="application/json" id="design">{{ design|tojson }}</script>
|
<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>
|
<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>
|
{% for src in scripts %}<script src="{{ src }}"></script>
|
||||||
{% endfor %}<script src="{{ url_for('static', filename='bestek.js') }}"></script>
|
{% endfor %}<script src="{{ url_for('static', filename='bestek.js') }}"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</body>
|
{% endblock %}
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -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
@@ -1,4 +1,5 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
{% from '_compose.html' import compose %}
|
||||||
{% block title %}{{ c.name }}{% endblock %}
|
{% block title %}{{ c.name }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="head">
|
<div class="head">
|
||||||
@@ -101,6 +102,18 @@
|
|||||||
</form>
|
</form>
|
||||||
</div></div>
|
</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">
|
<div class="card o-3"><div class="card-body">
|
||||||
<h2>Notitie toevoegen</h2>
|
<h2>Notitie toevoegen</h2>
|
||||||
<form method="post" action="{{ url_for('contact_note', cid=c.id) }}">
|
<form method="post" action="{{ url_for('contact_note', cid=c.id) }}">
|
||||||
@@ -113,15 +126,7 @@
|
|||||||
<div class="card"><div class="card-body">
|
<div class="card"><div class="card-body">
|
||||||
<details>
|
<details>
|
||||||
<summary>E-mail sturen</summary>
|
<summary>E-mail sturen</summary>
|
||||||
<form method="post" action="{{ url_for('mail_send') }}">
|
{{ compose({'to': c.email, 'contact_id': c.id}, mail_templates, mail_vars) }}
|
||||||
<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>
|
|
||||||
</details>
|
</details>
|
||||||
</div></div>
|
</div></div>
|
||||||
{% endif %}
|
{% 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.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.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.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 %}
|
{% endif %}{{ c.postcode }} {{ c.city }}</dd>{% endif %}
|
||||||
{% if c.source %}<dt>Bron</dt><dd>{{ c.source }}</dd>{% endif %}
|
{% if c.source %}<dt>Bron</dt><dd>{{ c.source }}</dd>{% endif %}
|
||||||
<dt>Aangemaakt</dt><dd>{{ c.created_at|dt }}</dd>
|
<dt>Aangemaakt</dt><dd>{{ c.created_at|dt }}</dd>
|
||||||
|
|||||||
@@ -97,6 +97,15 @@
|
|||||||
<p class="empty">Geen openstaande taken.</p>
|
<p class="empty">Geen openstaande taken.</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div></div>
|
</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">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Ongelezen e-mail</h2>
|
<h2>Ongelezen e-mail</h2>
|
||||||
{% for e in mails %}
|
{% for e in mails %}
|
||||||
|
|||||||
+15
-14
@@ -1,39 +1,40 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
{% from '_compose.html' import compose %}
|
||||||
{% block title %}{{ e.subject }}{% endblock %}
|
{% block title %}{{ e.subject }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% set incoming = e.direction == 'in' %}
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<div>
|
<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>
|
<h1>{{ e.subject }}</h1>
|
||||||
<div class="muted" style="overflow-wrap:anywhere">
|
<div class="muted" style="overflow-wrap:anywhere">
|
||||||
{% if e.direction == 'in' %}Van {{ e.from_name }} <{{ e.from_addr }}>{% else %}Aan {{ e.to_addr }}{% endif %}
|
{% if incoming %}Van {{ e.from_name }} <{{ e.from_addr }}>{% else %}Aan {{ e.to_addr }}{% endif %}
|
||||||
|
{% if e.cc %}· CC {{ e.cc }}{% endif %}
|
||||||
· {{ e.date|dt }}
|
· {{ e.date|dt }}
|
||||||
</div>
|
</div>
|
||||||
|
{% if e.attachments %}<div class="muted" style="overflow-wrap:anywhere">Bijlagen: {{ e.attachments }}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{% if contact %}
|
{% if contact %}
|
||||||
<a class="btn btn-outline-primary" href="{{ url_for('contact_view', cid=contact.id) }}">Naar {{ contact.name }}</a>
|
<a class="btn btn-outline-primary" href="{{ url_for('contact_view', cid=contact.id) }}">Naar {{ contact.name }}</a>
|
||||||
{% elif e.direction == 'in' %}
|
{% 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>
|
<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 %}
|
{% 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>
|
</div>
|
||||||
|
|
||||||
<div class="card"><div class="card-body"><div class="mailbody">{{ e.body }}</div></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">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Beantwoorden</h2>
|
<h2>Beantwoorden</h2>
|
||||||
<form method="post" action="{{ url_for('mail_send') }}">
|
{{ compose({'to': e.from_addr, 'subject': e.subject if e.subject.lower().startswith('re:') else 'Re: ' ~ e.subject,
|
||||||
<input type="hidden" name="reply_to" value="{{ e.id }}">
|
'reply_to': e.id, 'contact_id': contact.id if contact else None}, templates, vars) }}
|
||||||
{% 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>
|
|
||||||
</div></div>
|
</div></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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
@@ -1,10 +1,17 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% block title %}Inbox{% endblock %}
|
{% block title %}Inbox{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% set sent = f == 'sent' %}
|
||||||
<div class="head">
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -13,26 +20,30 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<a href="{{ url_for('inbox') }}" class="{{ 'on' if f == 'all' }}">Alle</a>
|
{% for k, l in [('all', 'Inbox'), ('unread', 'Ongelezen'), ('unlinked', 'Niet gekoppeld aan contact'), ('sent', 'Verzonden'), ('archived', 'Archief')] %}
|
||||||
<a href="{{ url_for('inbox', f='unread') }}" class="{{ 'on' if f == 'unread' }}">Ongelezen</a>
|
<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>
|
||||||
<a href="{{ url_for('inbox', f='unlinked') }}" class="{{ 'on' if f == 'unlinked' }}">Niet gekoppeld aan contact</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>
|
||||||
|
|
||||||
<div class="card"><div class="card-body">
|
<div class="card"><div class="card-body">
|
||||||
{% if rows %}
|
{% if rows %}
|
||||||
<div class="table-responsive"><table class="table table-hover table-stack">
|
<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>
|
<tbody>
|
||||||
{% for e in rows %}
|
{% for e in rows %}
|
||||||
<tr class="link {{ 'unread' if not e.is_read }}" onclick="location='{{ url_for('inbox_view', eid=e.id) }}'">
|
<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="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></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="Contact">{{ e.contact_name or '' }}</td>
|
||||||
<td data-label="Datum" class="muted num">{{ e.date|dt }}</td>
|
<td data-label="Datum" class="muted num">{{ e.date|dt }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table></div>
|
</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>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
+17
-147
@@ -1,152 +1,22 @@
|
|||||||
<!doctype html>
|
{% extends 'base.html' %}
|
||||||
<html lang="nl">
|
{% block title %}Offerte {{ qt.number }}{% if c %} · {{ c.name }}{% endif %}{% endblock %}
|
||||||
<head>
|
{% block styles %}
|
||||||
<meta charset="utf-8">
|
<style>
|
||||||
<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; }
|
@page { size: A4; margin: 16mm 15mm; }
|
||||||
* { box-sizing: border-box; }
|
.qdoc .page { margin: 0 auto 2rem; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); }
|
||||||
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; }
|
@media print { .qdoc .page { margin: 0; } }
|
||||||
.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; }
|
</style>
|
||||||
.bar a { color: #b95913; font-weight: 600; text-decoration: none; }
|
{% endblock %}
|
||||||
.btn { font: 600 15px/1 "Mukta", sans-serif; padding: 12px 22px; border-radius: 50px; border: 1px solid #b95913; background: #b95913; color: #fff; cursor: pointer; }
|
{% block content %}
|
||||||
.btn.ghost { background: #fff; color: #b95913; border-color: rgba(185,89,19,.35); }
|
<div class="head no-print">
|
||||||
.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%; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="bar">
|
|
||||||
{% if portal %}
|
|
||||||
<a href="{{ url_for('portal_home') }}">← Terug naar het portaal</a>
|
|
||||||
{% if qt.status == 'verzonden' %}
|
|
||||||
<form method="post" action="{{ url_for('portal_quote_answer', qid=qt.id, choice='accept') }}" onsubmit="return confirm('Wilt u deze offerte accepteren?')"><button class="btn">Offerte accepteren</button></form>
|
|
||||||
<form method="post" action="{{ url_for('portal_quote_answer', qid=qt.id, choice='decline') }}"><button class="btn ghost">Afwijzen</button></form>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<a href="{{ url_for('quote_edit', qid=qt.id) }}">← Terug naar bewerken</a>
|
|
||||||
{% endif %}
|
|
||||||
<button class="btn {{ 'ghost' if portal and qt.status == 'verzonden' }}" onclick="window.print()">Afdrukken / opslaan als PDF</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
<header>
|
|
||||||
<img src="{{ url_for('static', filename='logo.svg') }}" alt="{{ s.company_name }}">
|
|
||||||
<div class="company">
|
|
||||||
<strong>{{ s.company_name }}</strong><br>
|
|
||||||
{{ [s.company_address, s.company_postcode_city, s.company_phone, s.company_email, s.company_website]|select|join('<br>'|safe) }}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="meta">
|
|
||||||
<div>
|
<div>
|
||||||
<div class="label">Offerte voor</div>
|
<a class="label" href="{{ url_for('quote_edit', qid=qt.id) }}">← Offerte {{ qt.number }}</a>
|
||||||
{% if c %}
|
<h1>Offerte bekijken</h1>
|
||||||
<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>
|
</div>
|
||||||
<table class="kv">
|
<div class="actions">
|
||||||
<tr><td class="label">Offertenummer</td><td>{{ qt.number }}</td></tr>
|
<a class="btn btn-outline-secondary" href="{{ url_for('quote_edit', qid=qt.id) }}">Bewerken</a>
|
||||||
<tr><td class="label">Datum</td><td>{{ qt.date|dt('%d-%m-%Y') }}</td></tr>
|
<button class="btn btn-primary" type="button" onclick="window.print()">Afdrukken / opslaan als PDF</button>
|
||||||
{% if qt.valid_until %}<tr><td class="label">Geldig tot</td><td>{{ qt.valid_until|dt('%d-%m-%Y') }}</td></tr>{% endif %}
|
|
||||||
</table>
|
|
||||||
</div>
|
</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>
|
||||||
</body>
|
{% include '_quote_doc.html' %}
|
||||||
</html>
|
{% endblock %}
|
||||||
|
|||||||
@@ -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
@@ -3,6 +3,10 @@
|
|||||||
{% macro field(key, label, type='text', placeholder='', full=false) %}
|
{% 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>
|
<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 %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="head"><div><div class="kicker">Beheer</div><h1>Instellingen</h1></div></div>
|
<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">
|
<div class="card" id="mail"><div class="card-body">
|
||||||
<h2>E-mail koppelen</h2>
|
<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>
|
<h3>Ontvangen (IMAP)</h3>
|
||||||
<div class="row g-3">
|
<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_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>
|
<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_folder', 'Map', placeholder='INBOX') }}
|
||||||
{{ field('imap_days', 'Aantal dagen terug ophalen', 'number', '30') }}
|
{{ field('imap_days', 'Aantal dagen terug ophalen', 'number', '30') }}
|
||||||
</div>
|
</div>
|
||||||
<h3>Versturen (SMTP)</h3>
|
<h3>Versturen (SMTP)</h3>
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
{{ field('smtp_host', 'Server', placeholder='smtp.gmail.com') }}
|
{{ field('smtp_host', 'Server', placeholder='smtp.mail.ovh.net') }}
|
||||||
{{ field('smtp_port', 'Poort', 'number', '587') }}
|
{{ field('smtp_port', 'Poort', 'number', '465') }}
|
||||||
|
{{ security('smtp_security') }}
|
||||||
{{ field('smtp_user', 'Gebruikersnaam', placeholder='leeg = zelfde als IMAP') }}
|
{{ 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>
|
<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>
|
||||||
|
<div class="actions"><button class="btn btn-outline-primary" name="action" value="mailtest">Opslaan en verbinding testen</button></div>
|
||||||
</div></div>
|
</div></div>
|
||||||
|
|
||||||
<div class="card"><div class="card-body">
|
<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>
|
<button class="btn btn-outline-secondary btn-sm">Nieuwe API-sleutel maken</button>
|
||||||
</form>
|
</form>
|
||||||
</div></div>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user