v0.0.6: massa-acties en CSV-export in de lijsten van contacten, relaties, producten en offertes

Rijen aanvinken (shift-klik voor een reeks, of alles op de pagina) en in de balk onderin een actie kiezen
(templates/_bulk.html, static/bulk.js). Contacten: status wijzigen, toewijzen, omzetten naar klant, exporteren,
verwijderen; relaties: type wijzigen, exporteren, verwijderen; producten: actief/inactief, exporteren, verwijderen
(in offertes gebruikt → inactief); offertes: status wijzigen (met de gevolgen voor het contact), exporteren,
verwijderen. Exporteer CSV neemt de filters van de lijst mee en levert alle velden; bij contacten ook labels,
toegewezen gebruiker, relatie, model, portaaltoegang en de extra formuliervelden als eigen kolom. Nieuwe
leadstatus Vervallen (telt als afgesloten).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-16 19:12:39 +02:00
co-authored by Claude Opus 5.5
parent 3cf53bc248
commit db3e26d258
9 changed files with 471 additions and 74 deletions
+9 -1
View File
@@ -4,9 +4,17 @@ Eenvoudige CRM in Python (Flask) met SQLite, in de Van Emous-huisstijl. Versie 0
## Functies
- **Leads en klanten** (zoals in EspoCRM): status (nieuw → in behandeling → offerte → gewonnen/verloren),
- **Leads en klanten** (zoals in EspoCRM): status (nieuw → in behandeling → offerte → gewonnen/verloren/vervallen),
toewijzen aan een gebruiker, verwachte waarde, bron, omzetten van lead naar klant, tijdlijn met
notities, e-mail, statuswissels en formulierinzendingen, bestanden en afbeeldingen, CSV-export.
- **Massa-acties en CSV-export** in de lijsten van contacten/leads, relaties, producten en offertes: rijen aanvinken
(shift-klik voor een reeks, of alles op de pagina) en in de balk onderin een actie kiezen. Contacten: status
wijzigen, toewijzen, omzetten naar klant, exporteren, verwijderen; relaties: type wijzigen, exporteren, verwijderen;
producten: actief/inactief, exporteren, verwijderen (in offertes gebruikt → inactief); offertes: status wijzigen
(met dezelfde gevolgen voor het contact als één voor één), exporteren, verwijderen. Statuswissels en toewijzingen
komen per contact in de tijdlijn. **Exporteer CSV** bovenaan neemt de filters en zoekterm van de lijst mee en levert
alle velden (`;`, BOM, voor Excel): bij contacten ook soort, statuslabel, toegewezen gebruiker, relatie, model,
portaaltoegang en elk extra formulierveld als eigen kolom (portaaltoken en wachtwoordhash nooit).
- **Relaties** (zoals accounts in EspoCRM), in twee soorten: **klanten** (bundels zoals een huishouden,
familie of bedrijf, met meerdere contactpersonen en hun rol) en **partners** met een type (leverancier,
aannemer, installateur, ...; de typen pas je aan bij Instellingen). Een lead omzetten naar klant zet de
+327 -66
View File
@@ -4,7 +4,7 @@ Starten: python app.py
E-mail ophalen: python app.py sync (bijv. elke 10 minuten via cron)
Demoleads: python app.py demo (standaardmodellen uit demo/modellen als gekozen model)
"""
__version__ = '0.0.5'
__version__ = '0.0.6'
import csv
import hashlib
@@ -48,7 +48,8 @@ DEFAULT_RELATION_TYPES = {'klant': 'Huishouden / familie, Particulier, Zakelijk,
'partner': 'Leverancier, Aannemer, Installateur, Architect / adviseur, Gemeente, Overig'}
RELATION_FIELDS = ['name', 'type', 'email', 'phone', 'website', 'address', 'postcode', 'city', 'kvk', 'notes']
STATUSES = [('nieuw', 'Nieuw'), ('contact', 'In behandeling'), ('offerte', 'Offerte'),
('gewonnen', 'Gewonnen'), ('verloren', 'Verloren')]
('gewonnen', 'Gewonnen'), ('verloren', 'Verloren'), ('vervallen', 'Vervallen')]
CLOSED_STATUSES = ('gewonnen', 'verloren', 'vervallen')
QUOTE_STATUSES = [('concept', 'Concept'), ('verzonden', 'Verzonden'),
('geaccepteerd', 'Geaccepteerd'), ('afgewezen', 'Afgewezen')]
ROLES = [('admin', 'Beheerder'), ('user', 'Medewerker')]
@@ -135,6 +136,43 @@ def back(default):
return ref if ref.startswith(request.host_url) else default
def csv_cell(value):
if value is None:
return ''
if isinstance(value, float):
return fmt_number(value)
return value
def csv_response(name, header, rows):
"""CSV met ; en BOM, zodat Excel kolommen en tekens direct goed leest (en de import hem terugneemt)."""
out = io.StringIO()
writer = csv.writer(out, delimiter=';')
writer.writerow(header)
writer.writerows([csv_cell(v) for v in row] for row in rows)
resp = make_response('\ufeff' + out.getvalue())
resp.headers['Content-Type'] = 'text/csv; charset=utf-8'
resp.headers['Content-Disposition'] = f'attachment; filename={name}-{date.today()}.csv'
return resp
def table_columns(table, hidden=()):
return [r[1] for r in q(f'PRAGMA table_info({table})') if r[1] not in hidden]
def bulk_ids():
"""De aangevinkte rijen van een massa-actie."""
return sorted({i for i in request.form.getlist('ids', type=int) if i})
def id_list(ids):
return f"({', '.join('?' * len(ids))})"
def bulk_back(default):
return redirect(safe_next(request.form.get('next', '')) or default)
# ---------------------------------------------------------------- database
# Kolommen die later zijn bijgekomen; bestaande databases krijgen ze bij het starten.
@@ -712,7 +750,7 @@ def user_edit(uid):
def dashboard():
since = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')
line_sum = 'SELECT COALESCE(SUM(l.qty*l.price), 0) FROM quote_lines l JOIN quotes qu ON qu.id=l.quote_id'
open_leads = "type='lead' AND status NOT IN ('gewonnen', 'verloren')"
open_leads = f"type='lead' AND status NOT IN {CLOSED_STATUSES}"
stats = {
'new30': scalar("SELECT COUNT(*) FROM contacts WHERE type='lead' AND created_at >= ?", since),
'open_leads': scalar(f'SELECT COUNT(*) FROM contacts WHERE {open_leads}'),
@@ -756,14 +794,16 @@ def dashboard():
# ---------------------------------------------------------------- contacten
@app.route('/contacts')
def contacts():
term = request.args.get('q', '').strip()
args = {k: request.args.get(k, '') for k in ('type', 'status', 'owner')}
sql = ('SELECT c.*, u.name AS user_name, r.name AS relation_name, r.kind AS relation_kind, f.title AS design_title '
'FROM contacts c LEFT JOIN users u ON u.id=c.assigned_user_id LEFT JOIN relations r ON r.id=c.relation_id '
'LEFT JOIN files f ON f.id=c.design_file_id WHERE 1=1')
params = []
CONTACT_FROM = ('FROM contacts c LEFT JOIN users u ON u.id=c.assigned_user_id '
'LEFT JOIN relations r ON r.id=c.relation_id LEFT JOIN files f ON f.id=c.design_file_id')
CONTACT_HIDDEN = ('portal_token', 'portal_password_hash')
def contacts_filter(source):
"""De filters van de contactenlijst (zoekterm, soort, status, eigenaar) als SQL; ook voor de export."""
term = source.get('q', '').strip()
args = {k: source.get(k, '') for k in ('type', 'status', 'owner')}
sql, params = '', []
if term:
sql += (' AND (c.name LIKE ? OR c.email LIKE ? OR c.company LIKE ? OR c.phone LIKE ? OR c.city LIKE ?'
' OR r.name LIKE ?)')
@@ -779,22 +819,97 @@ def contacts():
params.append(g.user['id'])
elif args['owner'] == 'none':
sql += ' AND c.assigned_user_id IS NULL'
rows = db().execute(sql + ' ORDER BY c.created_at DESC LIMIT 500', params).fetchall()
return term, args, sql, params
@app.route('/contacts')
def contacts():
term, args, where, params = contacts_filter(request.args)
rows = db().execute('SELECT c.*, u.name AS user_name, r.name AS relation_name, r.kind AS relation_kind, '
f'f.title AS design_title {CONTACT_FROM} WHERE 1=1{where} ORDER BY c.created_at DESC LIMIT 500',
params).fetchall()
return render_template('contacts.html', rows=rows, term=term, a=args)
def contacts_csv(where, params):
"""Alle velden van de contacten, met de namen bij de koppelingen en de extra formuliervelden als eigen kolom."""
rows = db().execute(f'SELECT c.*, u.name AS user_name, r.name AS relation_name, f.title AS design_title '
f'{CONTACT_FROM} WHERE 1=1{where} ORDER BY c.id', params).fetchall()
cols = [c for c in table_columns('contacts', CONTACT_HIDDEN) if c != 'extra']
extras = []
for r in rows:
try:
e = json.loads(r['extra'] or '{}')
except ValueError:
e = {}
extras.append(e if isinstance(e, dict) else {})
keys = list(dict.fromkeys(k for e in extras for k in e))
taken = set(cols) | {'soort', 'status_label', 'toegewezen', 'relatie', 'model', 'portaal'}
header = [*cols, 'soort', 'status_label', 'toegewezen', 'relatie', 'model', 'portaal',
*[f'extra_{k}' if k in taken else k for k in keys]]
data = []
for r, e in zip(rows, extras):
data.append([*(r[c] for c in cols), dict(TYPES).get(r['type'], r['type']), dict(STATUSES).get(r['status'], r['status']),
r['user_name'], r['relation_name'], r['design_title'],
'ja' if r['portal_password_hash'] else ('uitgenodigd' if r['portal_token'] else ''),
*(e.get(k) if isinstance(e.get(k), (str, int, float)) or e.get(k) is None
else json.dumps(e[k], ensure_ascii=False) for k in keys)])
return csv_response('contacten', header, data)
@app.route('/contacts/export.csv')
def contacts_export():
out = io.StringIO()
writer = csv.writer(out, delimiter=';')
cols = ['id', 'type', *CONTACT_FIELDS, 'status', 'value', 'created_at']
writer.writerow(cols)
for r in q(f'SELECT {", ".join(cols)} FROM contacts ORDER BY id'):
writer.writerow(list(r))
resp = make_response('' + out.getvalue())
resp.headers['Content-Type'] = 'text/csv; charset=utf-8'
resp.headers['Content-Disposition'] = f'attachment; filename=contacten-{date.today()}.csv'
return resp
_, _, where, params = contacts_filter(request.args)
return contacts_csv(where, params)
def delete_contact(cid):
for f in q('SELECT stored FROM files WHERE contact_id=?', cid):
remove_upload(f['stored'])
db().execute('DELETE FROM contacts WHERE id=?', (cid,))
@app.post('/contacts/bulk')
def contacts_bulk():
ids, action = bulk_ids(), request.form.get('action')
if not ids:
flash('Vink eerst een of meer contacten aan.', 'error')
return bulk_back(url_for('contacts'))
if action == 'export':
return contacts_csv(f' AND c.id IN {id_list(ids)}', ids)
existing = [r['id'] for r in q(f'SELECT id FROM contacts WHERE id IN {id_list(ids)}', *ids)]
if not existing:
flash('Die contacten bestaan niet meer.', 'error')
return bulk_back(url_for('contacts'))
if action == 'status' and request.form.get('status') in dict(STATUSES):
status = request.form['status']
for cid in existing:
set_contact_status(cid, status)
msg = f"{len(existing)} contact(en) op {dict(STATUSES)[status]} gezet."
elif action == 'assign':
uid = request.form.get('user_id', type=int)
user = q1('SELECT id, name FROM users WHERE id=?', uid) if uid else None
changed = [r['id'] for r in q(f'SELECT id FROM contacts WHERE id IN {id_list(existing)} '
'AND assigned_user_id IS NOT ?', *existing, user['id'] if user else None)]
for cid in changed:
db().execute('UPDATE contacts SET assigned_user_id=?, updated_at=? WHERE id=?',
(user['id'] if user else None, now(), cid))
log(cid, 'status', f"Toegewezen aan {user['name'] if user else 'niemand'}")
msg = f"{len(changed)} contact(en) toegewezen aan {user['name'] if user else 'niemand'}."
elif action == 'convert':
for cid in existing:
convert_contact(cid)
msg = f'{len(existing)} contact(en) omgezet naar klant.'
elif action == 'delete':
for cid in existing:
delete_contact(cid)
msg = f'{len(existing)} contact(en) verwijderd.'
else:
flash('Kies een actie.', 'error')
return bulk_back(url_for('contacts'))
db().commit()
flash(msg)
return bulk_back(url_for('contacts'))
def contact_form_values():
@@ -991,9 +1106,7 @@ def contact_portal(cid):
@app.post('/contacts/<int:cid>/delete')
def contact_delete(cid):
for f in q('SELECT stored FROM files WHERE contact_id=?', cid):
remove_upload(f['stored'])
db().execute('DELETE FROM contacts WHERE id=?', (cid,))
delete_contact(cid)
db().commit()
flash('Contact verwijderd.')
return redirect(url_for('contacts'))
@@ -1118,15 +1231,10 @@ def upsert_relation(raw, kind, update_existing=True):
return cur.lastrowid, 'created'
@app.route('/relations')
def relations():
kind = request.args.get('kind') if request.args.get('kind') in dict(RELATION_KINDS) else 'klant'
type_, term = request.args.get('type', ''), request.args.get('q', '').strip()
sql = ('SELECT r.*, (SELECT COUNT(*) FROM contacts WHERE relation_id=r.id) AS members, '
'(SELECT GROUP_CONCAT(name, ", ") FROM (SELECT name FROM contacts WHERE relation_id=r.id LIMIT 4)) AS member_names, '
'(SELECT COUNT(*) FROM products WHERE supplier_id=r.id) AS products '
'FROM relations r WHERE r.kind=?')
params = [kind]
def relations_filter(source):
kind = source.get('kind') if source.get('kind') in dict(RELATION_KINDS) else 'klant'
type_, term = source.get('type', ''), source.get('q', '').strip()
sql, params = ' AND r.kind=?', [kind]
if type_:
sql += ' AND r.type=?'
params.append(type_)
@@ -1134,7 +1242,17 @@ def relations():
sql += (' AND (r.name LIKE ? OR r.city LIKE ? OR r.email LIKE ? OR EXISTS '
'(SELECT 1 FROM contacts c WHERE c.relation_id=r.id AND c.name LIKE ?))')
params += [f'%{term}%'] * 4
rows = db().execute(sql + ' ORDER BY r.name LIMIT 500', params).fetchall()
return kind, type_, term, sql, params
@app.route('/relations')
def relations():
kind, type_, term, where, params = relations_filter(request.args)
sql = ('SELECT r.*, (SELECT COUNT(*) FROM contacts WHERE relation_id=r.id) AS members, '
'(SELECT GROUP_CONCAT(name, ", ") FROM (SELECT name FROM contacts WHERE relation_id=r.id LIMIT 4)) AS member_names, '
'(SELECT COUNT(*) FROM products WHERE supplier_id=r.id) AS products '
'FROM relations r WHERE 1=1')
rows = db().execute(sql + where + ' ORDER BY r.name LIMIT 500', params).fetchall()
counts = {r[0]: r[1] for r in q('SELECT type, COUNT(*) FROM relations WHERE kind=? GROUP BY type', kind)}
return render_template('relations.html', rows=rows, kind=kind, type=type_, term=term,
types=relation_types(kind), counts=counts)
@@ -1210,18 +1328,73 @@ def relation_member_remove(rid, cid):
return redirect(url_for('relation_view', rid=rid))
@app.post('/relations/<int:rid>/delete')
def relation_delete(rid):
r = get_or_404('relations', rid)
def delete_relation(rid):
for c in q('SELECT id FROM contacts WHERE relation_id=?', rid):
link_relation(c['id'], None, '')
db().execute("UPDATE products SET supplier_id=NULL WHERE supplier_id=?", (rid,))
db().execute('DELETE FROM relations WHERE id=?', (rid,))
@app.post('/relations/<int:rid>/delete')
def relation_delete(rid):
r = get_or_404('relations', rid)
delete_relation(rid)
db().commit()
flash('Relatie verwijderd; de contactpersonen en producten zijn bewaard.')
return redirect(url_for('relations', kind=r['kind']))
def relations_csv(where, params):
"""Alle velden van de relaties, met hun contactpersonen (naam, rol, e-mail) en het aantal producten."""
cols = table_columns('relations')
rows = db().execute(
'SELECT r.*, (SELECT GROUP_CONCAT(name || CASE WHEN COALESCE(relation_role, \'\') <> \'\' '
'THEN \' (\' || relation_role || \')\' ELSE \'\' END || CASE WHEN COALESCE(email, \'\') <> \'\' '
'THEN \' <\' || email || \'>\' ELSE \'\' END, \', \') FROM contacts WHERE relation_id=r.id) AS member_list, '
'(SELECT COUNT(*) FROM contacts WHERE relation_id=r.id) AS members, '
'(SELECT COUNT(*) FROM products WHERE supplier_id=r.id) AS products '
f'FROM relations r WHERE 1=1{where} ORDER BY r.kind, r.name', params).fetchall()
header = [*cols, 'aantal_contactpersonen', 'contactpersonen', 'aantal_producten']
return csv_response('relaties', header, [[*(r[c] for c in cols), r['members'], r['member_list'], r['products']]
for r in rows])
@app.route('/relations/export.csv')
def relations_export():
*_, where, params = relations_filter(request.args)
return relations_csv(where, params)
@app.post('/relations/bulk')
def relations_bulk():
ids, action = bulk_ids(), request.form.get('action')
default = url_for('relations', kind=request.form.get('kind') or None)
if not ids:
flash('Vink eerst een of meer relaties aan.', 'error')
return bulk_back(default)
if action == 'export':
return relations_csv(f' AND r.id IN {id_list(ids)}', ids)
rows = q(f'SELECT id, kind FROM relations WHERE id IN {id_list(ids)}', *ids)
if action == 'type':
type_ = request.form.get('type', '').strip()
n = 0
for r in rows:
if type_ in relation_types(r['kind']) or not type_:
db().execute('UPDATE relations SET type=?, updated_at=? WHERE id=?', (type_, now(), r['id']))
n += 1
msg = f"{n} relatie(s) op type {type_ or '(geen)'} gezet."
elif action == 'delete':
for r in rows:
delete_relation(r['id'])
msg = f'{len(rows)} relatie(s) verwijderd; de contactpersonen en producten zijn bewaard.'
else:
flash('Kies een actie.', 'error')
return bulk_back(default)
db().commit()
flash(msg)
return bulk_back(default)
# ---------------------------------------------------------------- import
IMPORT_NAME = re.compile(r'[0-9a-f]{32}\.(csv|json)')
@@ -2379,34 +2552,43 @@ def import_summary(r):
return 'Import klaar: ' + ', '.join(parts) + '.'
@app.route('/products')
def products():
term = request.args.get('q', '').strip()
show = request.args.get('show', 'active')
sql, params = 'SELECT * FROM products WHERE 1=1', []
def products_filter(source):
term = source.get('q', '').strip()
show = source.get('show', 'active')
sql, params = '', []
if term:
sql += ' AND (name LIKE ? OR sku LIKE ? OR supplier LIKE ?)'
params += [f'%{term}%'] * 3
if show in ('active', 'inactive'):
sql += ' AND active=?'
params.append(1 if show == 'active' else 0)
rows = db().execute(sql + ' ORDER BY name', params).fetchall()
return term, show, sql, params
@app.route('/products')
def products():
term, show, where, params = products_filter(request.args)
rows = db().execute(f'SELECT * FROM products WHERE 1=1{where} ORDER BY name', params).fetchall()
return render_template('products.html', rows=rows, term=term, show=show)
def products_csv(where, params):
"""In het formaat van de import (op SKU terug te lezen), met daarachter de overige velden."""
header = ['sku', 'naam', 'omschrijving', 'eenheid', 'prijs', 'btw', 'leverancier', 'actief', 'bijgewerkt',
'id', 'leverancier_id', 'afbeelding', 'aangemaakt', 'in_offertes']
rows = db().execute('SELECT p.*, (SELECT COUNT(DISTINCT quote_id) FROM quote_lines WHERE product_id=p.id) AS used '
f'FROM products p WHERE 1=1{where} ORDER BY sku, name', params).fetchall()
return csv_response('producten', header, [
[p['sku'], p['name'], p['description'], p['unit'], f"{p['price']:.2f}".replace('.', ','),
fmt_number(p['vat']), p['supplier'], 'ja' if p['active'] else 'nee', (p['updated_at'] or p['created_at'])[:10],
p['id'], p['supplier_id'], p['image'], p['created_at'], p['used']] for p in rows])
@app.route('/products/export.csv')
def products_export():
out = io.StringIO()
writer = csv.writer(out, delimiter=';')
writer.writerow(['sku', 'naam', 'omschrijving', 'eenheid', 'prijs', 'btw', 'leverancier', 'actief', 'bijgewerkt'])
for p in q('SELECT * FROM products ORDER BY sku, name'):
writer.writerow([p['sku'], p['name'], p['description'], p['unit'], f"{p['price']:.2f}".replace('.', ','),
fmt_number(p['vat']), p['supplier'], 'ja' if p['active'] else 'nee',
(p['updated_at'] or p['created_at'])[:10]])
resp = make_response('' + out.getvalue())
resp.headers['Content-Type'] = 'text/csv; charset=utf-8'
resp.headers['Content-Disposition'] = f'attachment; filename=producten-{date.today()}.csv'
return resp
# zonder filters (zoals vroeger) alle producten, ook de inactieve
_, _, where, params = products_filter({'show': 'all', **request.args.to_dict()})
return products_csv(where, params)
@app.route('/products/new', methods=['GET', 'POST'], defaults={'pid': None})
@@ -2459,22 +2641,57 @@ def history_for(pid):
'WHERE h.product_id=? ORDER BY h.created_at DESC, h.id DESC LIMIT 100', pid)
def set_product_active(p, active, source):
if bool(p['active']) != active:
db().execute('UPDATE products SET active=?, updated_at=? WHERE id=?', (int(active), now(), p['id']))
product_history(p['id'], 'active', p['active'], int(active), source)
return True
return False
def delete_product(p):
"""Verwijder een product; staat het in offertes, dan wordt het gearchiveerd (koppeling en historie blijven)."""
if scalar('SELECT COUNT(*) FROM quote_lines WHERE product_id=?', p['id']):
set_product_active(p, False, 'Handmatig (gearchiveerd)')
return False
remove_upload(p['image'])
db().execute('DELETE FROM products WHERE id=?', (p['id'],))
return True
@app.post('/products/<int:pid>/delete')
def product_delete(pid):
p = get_or_404('products', pid)
if scalar('SELECT COUNT(*) FROM quote_lines WHERE product_id=?', pid):
# in gebruik in offertes: archiveren zodat de koppeling en de historie blijven bestaan
if p['active']:
db().execute('UPDATE products SET active=0, updated_at=? WHERE id=?', (now(), pid))
product_history(pid, 'active', 1, 0, 'Handmatig (gearchiveerd)')
deleted = delete_product(p)
db().commit()
flash('Dit product staat in offertes en is daarom op inactief gezet in plaats van verwijderd.')
flash('Product verwijderd.' if deleted else
'Dit product staat in offertes en is daarom op inactief gezet in plaats van verwijderd.')
return redirect(url_for('products'))
remove_upload(p['image'])
db().execute('DELETE FROM products WHERE id=?', (pid,))
@app.post('/products/bulk')
def products_bulk():
ids, action = bulk_ids(), request.form.get('action')
if not ids:
flash('Vink eerst een of meer producten aan.', 'error')
return bulk_back(url_for('products'))
if action == 'export':
return products_csv(f' AND p.id IN {id_list(ids)}', ids)
rows = q(f'SELECT * FROM products WHERE id IN {id_list(ids)}', *ids)
if action in ('activate', 'deactivate'):
n = sum(set_product_active(p, action == 'activate', 'Handmatig (massa-actie)') for p in rows)
msg = f"{n} product(en) op {'actief' if action == 'activate' else 'inactief'} gezet."
elif action == 'delete':
deleted = sum(delete_product(p) for p in rows)
msg = f'{deleted} product(en) verwijderd.'
if deleted < len(rows):
msg += f' {len(rows) - deleted} staan in offertes en zijn op inactief gezet.'
else:
flash('Kies een actie.', 'error')
return bulk_back(url_for('products'))
db().commit()
flash('Product verwijderd.')
return redirect(url_for('products'))
flash(msg)
return bulk_back(url_for('products'))
# ---------------------------------------------------------------- offertes
@@ -2496,6 +2713,50 @@ def quotes():
return render_template('quotes.html', rows=rows, status=status)
def quotes_csv(rows):
"""Alle velden van de offertes (zonder het bestek zelf), met klant, aantal regels en de bedragen."""
cols = table_columns('quotes', ('bestek',))
header = [*cols, 'status_label', 'klant', 'klant_email', 'regels', 'bestek', 'subtotaal', 'btw', 'totaal']
extra = {r['id']: r for r in q('SELECT qu.id, c.email, (SELECT COUNT(*) FROM quote_lines WHERE quote_id=qu.id) AS n, '
"COALESCE(qu.bestek, '') <> '' AS has_bestek "
'FROM quotes qu LEFT JOIN contacts c ON c.id=qu.contact_id')}
return csv_response('offertes', header, [
[*(r[c] for c in cols), dict(QUOTE_STATUSES).get(r['status'], r['status']), r['contact_name'],
extra[r['id']]['email'], extra[r['id']]['n'], 'ja' if extra[r['id']]['has_bestek'] else '',
round(r['subtotal'], 2), round(r['total'] - r['subtotal'], 2), round(r['total'], 2)] for r in rows])
@app.route('/quotes/export.csv')
def quotes_export():
status = request.args.get('status', '')
return quotes_csv(quotes_with_totals('WHERE qu.status=?', status) if status else quotes_with_totals())
@app.post('/quotes/bulk')
def quotes_bulk():
ids, action = bulk_ids(), request.form.get('action')
if not ids:
flash('Vink eerst een of meer offertes aan.', 'error')
return bulk_back(url_for('quotes'))
if action == 'export':
return quotes_csv(quotes_with_totals(f'WHERE qu.id IN {id_list(ids)}', *ids))
rows = q(f'SELECT * FROM quotes WHERE id IN {id_list(ids)}', *ids)
if action == 'status' and request.form.get('status') in dict(QUOTE_STATUSES):
status = request.form['status']
for qt in rows:
change_quote_status(qt, status)
msg = f"{len(rows)} offerte(s) op {dict(QUOTE_STATUSES)[status]} gezet."
elif action == 'delete':
db().execute(f'DELETE FROM quotes WHERE id IN {id_list(ids)}', ids)
msg = f'{len(rows)} offerte(s) verwijderd.'
else:
flash('Kies een actie.', 'error')
return bulk_back(url_for('quotes'))
db().commit()
flash(msg)
return bulk_back(url_for('quotes'))
@app.post('/quotes/new')
def quote_new():
cid = request.form.get('contact_id', type=int)
@@ -2930,7 +3191,7 @@ def api_leads():
body += [f'{k}: {v if isinstance(v, str) else json.dumps(v, ensure_ascii=False)}' for k, v in extra.items()]
log(cid, 'form', '\n'.join(body))
if outcome == 'updated':
db().execute("UPDATE contacts SET status='nieuw' WHERE id=? AND status='verloren'", (cid,))
db().execute("UPDATE contacts SET status='nieuw' WHERE id=? AND status IN ('verloren', 'vervallen')", (cid,))
for design in designs:
save_design_json(design, cid)
+57
View File
@@ -0,0 +1,57 @@
/* Massa-acties in lijsten (templates/_bulk.html): rijen aanvinken, shift-klik voor een reeks,
dan in de balk onderin een actie kiezen. Een klik op het vinkje opent de rij niet. */
(() => {
const form = document.getElementById('bulk');
if (!form) return;
const boxes = Array.from(document.querySelectorAll('input[name="ids"][form="bulk"]'));
const all = document.querySelector('[data-bulk-all]');
const count = form.querySelector('[data-bulk-count]');
const action = form.elements.action;
let last = null;
const update = () => {
const n = boxes.filter(b => b.checked).length;
count.textContent = n;
form.hidden = !n;
document.body.classList.toggle('bulk-open', n > 0);
if (all) {
all.checked = n > 0 && n === boxes.length;
all.indeterminate = n > 0 && n < boxes.length;
}
boxes.forEach(b => b.closest('tr')?.classList.toggle('selected', b.checked));
};
const showFields = () => form.querySelectorAll('[data-show]').forEach(el => {
const on = el.dataset.show === action.value;
el.hidden = !on;
el.disabled = !on;
});
boxes.forEach((box, i) => box.closest('td').addEventListener('click', e => {
e.stopPropagation();
if (e.target !== box) box.checked = !box.checked;
if (e.shiftKey && last !== null) {
boxes.slice(Math.min(last, i), Math.max(last, i) + 1).forEach(b => { b.checked = box.checked; });
}
last = i;
update();
}));
all?.closest('th').addEventListener('click', e => {
if (e.target !== all) all.checked = !all.checked;
boxes.forEach(b => { b.checked = all.checked; });
update();
});
form.querySelector('[data-bulk-clear]').addEventListener('click', () => {
boxes.forEach(b => { b.checked = false; });
update();
});
action.addEventListener('change', showFields);
form.addEventListener('submit', e => {
const ask = action.selectedOptions[0]?.dataset.confirm;
if (ask && !confirm(ask.replace('{n}', count.textContent))) return e.preventDefault();
// een export is een download: de pagina blijft staan, dus de keuze terugzetten
if (action.value === 'export') setTimeout(() => { action.value = ''; showFields(); }, 0);
});
addEventListener('pageshow', update);
showFields();
update();
})();
+21
View File
@@ -390,3 +390,24 @@ tr.link td { transition: background-color 0.2s; }
.chip.campaign-gestopt, .chip.campaign-gepauzeerd { background: var(--light); color: var(--ink-muted); }
.schedule { margin-top: 1.5rem; padding-top: 1.25rem; border-top: 1px solid var(--line); }
.progress-bar { background: var(--orange); }
/* ---------- massa-acties (templates/_bulk.html, static/bulk.js) ---------- */
.chip.vervallen { background: var(--light); color: var(--ink-muted); }
.table > :not(caption) > * > .bulk-col { width: 2.25rem; padding-right: 0; cursor: pointer; }
.bulk-col .form-check-input { margin: 0.2rem 0 0; cursor: pointer; }
.table > tbody > tr.selected > * { --bs-table-bg-state: var(--orange-tint); }
.bulk-bar { position: fixed; z-index: 1030; left: 50%; bottom: 1rem; transform: translateX(-50%); display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; max-width: calc(100% - 2rem); padding: 0.6rem 0.75rem 0.6rem 1.25rem; background: var(--white); border: 1px solid var(--orange-line); border-radius: var(--radius-pill); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.14); }
.bulk-bar[hidden] { display: none; }
.bulk-bar .form-select { width: auto; max-width: 16rem; }
.bulk-count { white-space: nowrap; }
body.bulk-open main.content { padding-bottom: 7rem; }
@media (max-width: 767.98px) {
.bulk-bar { left: 0.75rem; right: 0.75rem; bottom: 0.75rem; transform: none; max-width: none; border-radius: var(--radius-md); padding: 0.75rem; }
.bulk-bar .form-select { flex: 1 1 100%; max-width: none; }
.bulk-bar .btn { flex: 1 1 auto; }
.bulk-count { flex-basis: 100%; }
.table-stack > tbody > tr { position: relative; }
.table-stack > tbody > tr > td.bulk-col { position: absolute; top: 0.8rem; right: 0; padding: 0; width: auto; }
.table-stack > tbody > tr > td.bulk-col::before { content: none; }
.table-stack > tbody > tr > td.bulk-col ~ td.stack-title { padding-right: 2.25rem; }
}
+22
View File
@@ -0,0 +1,22 @@
{# Massa-acties in lijsten: een vinkje per rij (form="bulk") en een balk onderin met de actie (static/bulk.js).
Importeren met: {% from '_bulk.html' import bulk_head, bulk_cell, bulk_bar with context %} #}
{% macro bulk_head() %}<th class="bulk-col"><input class="form-check-input" type="checkbox" data-bulk-all aria-label="Alles op deze pagina selecteren"></th>{% endmacro %}
{% macro bulk_cell(id, label) %}<td class="bulk-col"><input class="form-check-input" type="checkbox" name="ids" value="{{ id }}" form="bulk" aria-label="Selecteer {{ label }}"></td>{% endmacro %}
{# actions: lijst van (waarde, label) of (waarde, label, bevestigingsvraag met {n}); velden met data-show="<waarde>"
in de caller verschijnen alleen bij die actie #}
{% macro bulk_bar(action_url, actions) %}
<form id="bulk" class="bulk-bar" method="post" action="{{ action_url }}" hidden>
<input type="hidden" name="next" value="{{ request.full_path }}">
<span class="bulk-count"><strong data-bulk-count>0</strong> geselecteerd</span>
<select class="form-select form-select-sm" name="action" aria-label="Actie" required>
<option value="">Kies een actie…</option>
{% for a in actions %}<option value="{{ a[0] }}"{% if a|length > 2 %} data-confirm="{{ a[2] }}"{% endif %}>{{ a[1] }}</option>{% endfor %}
</select>
{{ caller() if caller }}
<button class="btn btn-primary btn-sm">Toepassen</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-bulk-clear>Wissen</button>
</form>
<script src="{{ url_for('static', filename='bulk.js') }}" defer></script>
{% endmacro %}
+10 -2
View File
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% from '_bulk.html' import bulk_head, bulk_cell, bulk_bar with context %}
{% block title %}Contacten{% endblock %}
{% block content %}
<div class="head">
@@ -8,7 +9,7 @@
{% for k in ('type', 'status', 'owner') %}{% if a[k] %}<input type="hidden" name="{{ k }}" value="{{ a[k] }}">{% endif %}{% endfor %}
<input class="form-control" type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, e-mail, plaats…" aria-label="Zoeken">
</form>
<a class="btn btn-outline-primary" href="{{ url_for('contacts_export') }}">Exporteer CSV</a>
<a class="btn btn-outline-primary" href="{{ url_for('contacts_export', **request.args) }}" title="Alle velden van de contacten in deze lijst">Exporteer CSV</a>
<a class="btn btn-primary" href="{{ url_for('contact_new', type=a.type or 'lead') }}">{{ 'Nieuwe klant' if a.type == 'klant' else 'Nieuwe lead' }}</a>
</div>
</div>
@@ -33,10 +34,11 @@
<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>Relatie</th><th>Telefoon</th><th>Plaats</th><th>Model</th><th>Bron</th><th>Toegewezen</th><th>Status</th><th>Aangemaakt</th></tr></thead>
<thead><tr>{{ bulk_head() }}<th>Naam</th><th>Relatie</th><th>Telefoon</th><th>Plaats</th><th>Model</th><th>Bron</th><th>Toegewezen</th><th>Status</th><th>Aangemaakt</th></tr></thead>
<tbody>
{% for c in rows %}
<tr class="link" onclick="location='{{ url_for('contact_view', cid=c.id) }}'">
{{ bulk_cell(c.id, c.name) }}
<td class="stack-title"><a href="{{ url_for('contact_view', cid=c.id) }}">{{ c.name }}</a><div class="muted">{{ c.email }}</div></td>
<td data-label="Relatie">{% if c.relation_id %}<span><a href="{{ url_for('relation_view', rid=c.relation_id) }}" onclick="event.stopPropagation()">{{ c.relation_name }}</a>{% if c.relation_role %}<span class="muted d-block">{{ c.relation_role }}</span>{% endif %}</span>{% else %}{{ c.company }}{% endif %}</td>
<td data-label="Telefoon">{{ c.phone }}</td>
@@ -52,4 +54,10 @@
</table></div>
{% else %}<p class="empty">Geen contacten gevonden.</p>{% endif %}
</div></div>
{% call bulk_bar(url_for('contacts_bulk'), [('status', 'Status wijzigen'), ('assign', 'Toewijzen'),
('convert', 'Omzetten naar klant'), ('export', 'Exporteren als CSV'),
('delete', 'Verwijderen', '{n} contact(en) verwijderen, met hun tijdlijn, taken en bestanden? Dit kan niet ongedaan worden gemaakt.')]) %}
<select class="form-select form-select-sm" name="status" data-show="status" aria-label="Nieuwe status">{% for k, l in STATUSES %}<option value="{{ k }}">{{ l }}</option>{% endfor %}</select>
<select class="form-select form-select-sm" name="user_id" data-show="assign" aria-label="Toewijzen aan"><option value="">Niemand</option>{% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if u.id == g.user.id }}>{{ u.name }}</option>{% endfor %}</select>
{% endcall %}
{% endblock %}
+7 -2
View File
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% from '_bulk.html' import bulk_head, bulk_cell, bulk_bar with context %}
{% block title %}Producten{% endblock %}
{% block content %}
<div class="head">
@@ -8,7 +9,7 @@
<input type="hidden" name="show" value="{{ show }}">
<input class="form-control" type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, SKU, leverancier…" aria-label="Zoeken">
</form>
<a class="btn btn-outline-primary" href="{{ url_for('products_export') }}">Exporteer CSV</a>
<a class="btn btn-outline-primary" href="{{ url_for('products_export', **request.args) }}" title="De producten in deze lijst, in het formaat van de import">Exporteer CSV</a>
<a class="btn btn-outline-primary" href="{{ url_for('import_start', kind='products') }}">Importeren</a>
<a class="btn btn-primary" href="{{ url_for('product_edit') }}">Nieuw product</a>
</div>
@@ -22,10 +23,11 @@
<div class="card"><div class="card-body">
{% if rows %}
<div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th class="d-none d-md-table-cell"></th><th>Naam</th><th>SKU</th><th>Leverancier</th><th>Eenheid</th><th class="num">Prijs excl.</th><th class="num">Btw</th><th>Bijgewerkt</th><th>Status</th></tr></thead>
<thead><tr>{{ bulk_head() }}<th class="d-none d-md-table-cell"></th><th>Naam</th><th>SKU</th><th>Leverancier</th><th>Eenheid</th><th class="num">Prijs excl.</th><th class="num">Btw</th><th>Bijgewerkt</th><th>Status</th></tr></thead>
<tbody>
{% for p in rows %}
<tr class="link" onclick="location='{{ url_for('product_edit', pid=p.id) }}'">
{{ bulk_cell(p.id, p.name) }}
<td class="d-none d-md-table-cell" style="width:64px">{% if p.image %}<img class="thumb-sm" src="{{ url_for('upload_file', name=p.image) }}" alt="">{% else %}<span class="thumb-sm"></span>{% endif %}</td>
<td class="stack-title"><a href="{{ url_for('product_edit', pid=p.id) }}">{{ p.name }}</a><div class="muted">{{ p.description|truncate(90) }}</div></td>
<td data-label="SKU"><code>{{ p.sku }}</code></td>
@@ -41,4 +43,7 @@
</table></div>
{% else %}<p class="empty">{{ 'Geen producten gevonden.' if term or show != 'active' else 'Nog geen producten. Voeg ze toe of importeer een CSV; daarna kies je ze in offertes.' }}</p>{% endif %}
</div></div>
{% call bulk_bar(url_for('products_bulk'), [('activate', 'Actief maken'), ('deactivate', 'Inactief maken'),
('export', 'Exporteren als CSV'),
('delete', 'Verwijderen', '{n} product(en) verwijderen? Producten die in offertes staan, worden op inactief gezet.')]) %}{% endcall %}
{% endblock %}
+8 -1
View File
@@ -1,9 +1,11 @@
{% extends 'base.html' %}
{% from '_bulk.html' import bulk_head, bulk_cell, bulk_bar with context %}
{% block title %}Offertes{% endblock %}
{% block content %}
<div class="head">
<div><div class="kicker">Verkoop</div><h1>Offertes</h1></div>
<div class="actions">
<a class="btn btn-outline-primary" href="{{ url_for('quotes_export', **request.args) }}" title="Alle velden van de offertes in deze lijst">Exporteer CSV</a>
<form method="post" action="{{ url_for('quote_new') }}" class="inline"><button class="btn btn-primary">Nieuwe offerte</button></form>
</div>
</div>
@@ -14,10 +16,11 @@
<div class="card"><div class="card-body">
{% if rows %}
<div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th>Nummer</th><th>Klant</th><th>Onderwerp</th><th>Datum</th><th>Status</th><th class="num">Totaal incl.</th></tr></thead>
<thead><tr>{{ bulk_head() }}<th>Nummer</th><th>Klant</th><th>Onderwerp</th><th>Datum</th><th>Status</th><th class="num">Totaal incl.</th></tr></thead>
<tbody>
{% for qt in rows %}
<tr class="link" onclick="location='{{ url_for('quote_edit', qid=qt.id) }}'">
{{ bulk_cell(qt.id, qt.number) }}
<td class="stack-title"><a href="{{ url_for('quote_edit', qid=qt.id) }}">{{ qt.number }}</a></td>
<td data-label="Klant">{{ qt.contact_name or '—' }}</td>
<td data-label="Onderwerp">{{ qt.title }}</td>
@@ -30,4 +33,8 @@
</table></div>
{% else %}<p class="empty">Nog geen offertes.</p>{% endif %}
</div></div>
{% call bulk_bar(url_for('quotes_bulk'), [('status', 'Status wijzigen'), ('export', 'Exporteren als CSV'),
('delete', 'Verwijderen', '{n} offerte(s) verwijderen, met hun regels? Dit kan niet ongedaan worden gemaakt.')]) %}
<select class="form-select form-select-sm" name="status" data-show="status" aria-label="Nieuwe status">{% for k, l in QUOTE_STATUSES %}<option value="{{ k }}">{{ l }}</option>{% endfor %}</select>
{% endcall %}
{% endblock %}
+9 -1
View File
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% from '_bulk.html' import bulk_head, bulk_cell, bulk_bar with context %}
{% block title %}{{ 'Klanten' if kind == 'klant' else 'Partners' }}{% endblock %}
{% block content %}
<div class="head">
@@ -13,6 +14,7 @@
{% if type %}<input type="hidden" name="type" value="{{ type }}">{% endif %}
<input class="form-control" type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, plaats, persoon…" aria-label="Zoeken">
</form>
<a class="btn btn-outline-primary" href="{{ url_for('relations_export', **request.args) }}" title="Alle velden van de relaties in deze lijst">Exporteer CSV</a>
<a class="btn btn-outline-primary" href="{{ url_for('import_start', kind='relations') }}">Importeren</a>
<a class="btn btn-primary" href="{{ url_for('relation_edit', kind=kind, type=type or None) }}">{{ 'Nieuwe klant' if kind == 'klant' else 'Nieuwe partner' }}</a>
</div>
@@ -28,10 +30,11 @@
<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>Type</th><th>Contactpersonen</th><th>Plaats</th><th>Contact</th>{% if kind == 'partner' %}<th class="num">Producten</th>{% endif %}</tr></thead>
<thead><tr>{{ bulk_head() }}<th>Naam</th><th>Type</th><th>Contactpersonen</th><th>Plaats</th><th>Contact</th>{% if kind == 'partner' %}<th class="num">Producten</th>{% endif %}</tr></thead>
<tbody>
{% for r in rows %}
<tr class="link" onclick="location='{{ url_for('relation_view', rid=r.id) }}'">
{{ bulk_cell(r.id, r.name) }}
<td class="stack-title"><a href="{{ url_for('relation_view', rid=r.id) }}">{{ r.name }}</a></td>
<td data-label="Type">{% if r.type %}<span class="chip">{{ r.type }}</span>{% endif %}</td>
<td data-label="Personen"><span>{{ r.member_names or '—' }}{% if r.members > 4 %} <span class="muted">+{{ r.members - 4 }}</span>{% endif %}</span></td>
@@ -44,4 +47,9 @@
</table></div>
{% else %}<p class="empty">Nog geen {{ 'klantrelaties. Een lead die je omzet naar klant komt hier vanzelf.' if kind == 'klant' else 'partners. Leveranciers uit een productimport komen hier vanzelf.' }}</p>{% endif %}
</div></div>
{% call bulk_bar(url_for('relations_bulk'), [('type', 'Type wijzigen'), ('export', 'Exporteren als CSV'),
('delete', 'Verwijderen', '{n} relatie(s) verwijderen? De contactpersonen en producten blijven bewaard.')]) %}
<input type="hidden" name="kind" value="{{ kind }}">
<select class="form-select form-select-sm" name="type" data-show="type" aria-label="Nieuw type"><option value="">(geen type)</option>{% for t in types %}<option value="{{ t }}">{{ t }}</option>{% endfor %}</select>
{% endcall %}
{% endblock %}