From db3e26d258052905b2bc48fb2e50beb426498f69 Mon Sep 17 00:00:00 2001 From: Machiel Date: Fri, 16 Oct 2026 19:12:39 +0200 Subject: [PATCH] v0.0.6: massa-acties en CSV-export in de lijsten van contacten, relaties, producten en offertes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 10 +- app.py | 395 ++++++++++++++++++++++++++++++++------- static/bulk.js | 57 ++++++ static/style.css | 21 +++ templates/_bulk.html | 22 +++ templates/contacts.html | 12 +- templates/products.html | 9 +- templates/quotes.html | 9 +- templates/relations.html | 10 +- 9 files changed, 471 insertions(+), 74 deletions(-) create mode 100644 static/bulk.js create mode 100644 templates/_bulk.html diff --git a/README.md b/README.md index 2bce28d..adbee28 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app.py b/app.py index 638b2af..2c4922d 100644 --- a/app.py +++ b/app.py @@ -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//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//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//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,24 +2641,59 @@ 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//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)') - db().commit() - flash('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,)) + deleted = delete_product(p) db().commit() - flash('Product 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')) +@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(msg) + return bulk_back(url_for('products')) + + # ---------------------------------------------------------------- offertes def next_quote_number(): @@ -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) diff --git a/static/bulk.js b/static/bulk.js new file mode 100644 index 0000000..cbc0ec0 --- /dev/null +++ b/static/bulk.js @@ -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(); +})(); diff --git a/static/style.css b/static/style.css index bab7bfb..c955263 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } +} diff --git a/templates/_bulk.html b/templates/_bulk.html new file mode 100644 index 0000000..e807d46 --- /dev/null +++ b/templates/_bulk.html @@ -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() %}{% endmacro %} + +{% macro bulk_cell(id, label) %}{% endmacro %} + +{# actions: lijst van (waarde, label) of (waarde, label, bevestigingsvraag met {n}); velden met data-show="" + in de caller verschijnen alleen bij die actie #} +{% macro bulk_bar(action_url, actions) %} + + +{% endmacro %} diff --git a/templates/contacts.html b/templates/contacts.html index 06f0986..900ac66 100644 --- a/templates/contacts.html +++ b/templates/contacts.html @@ -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 %}
@@ -8,7 +9,7 @@ {% for k in ('type', 'status', 'owner') %}{% if a[k] %}{% endif %}{% endfor %} - Exporteer CSV + Exporteer CSV {{ 'Nieuwe klant' if a.type == 'klant' else 'Nieuwe lead' }}
@@ -33,10 +34,11 @@
{% if rows %}
- + {{ bulk_head() }} {% for c in rows %} + {{ bulk_cell(c.id, c.name) }} @@ -52,4 +54,10 @@
NaamRelatieTelefoonPlaatsModelBronToegewezenStatusAangemaakt
NaamRelatieTelefoonPlaatsModelBronToegewezenStatusAangemaakt
{% else %}

Geen contacten gevonden.

{% endif %}
+{% 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.')]) %} + + +{% endcall %} {% endblock %} diff --git a/templates/products.html b/templates/products.html index 49fde58..e2ef780 100644 --- a/templates/products.html +++ b/templates/products.html @@ -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 %} @@ -22,10 +23,11 @@
{% if rows %}
- + {{ bulk_head() }} {% for p in rows %} + {{ bulk_cell(p.id, p.name) }} @@ -41,4 +43,7 @@
NaamSKULeverancierEenheidPrijs excl.BtwBijgewerktStatus
NaamSKULeverancierEenheidPrijs excl.BtwBijgewerktStatus
{% else %}

{{ '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.' }}

{% endif %}
+{% 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 %} diff --git a/templates/quotes.html b/templates/quotes.html index 71ef876..59b65d0 100644 --- a/templates/quotes.html +++ b/templates/quotes.html @@ -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 %}
Verkoop

Offertes

+ Exporteer CSV
@@ -14,10 +16,11 @@
{% if rows %}
- + {{ bulk_head() }} {% for qt in rows %} + {{ bulk_cell(qt.id, qt.number) }} @@ -30,4 +33,8 @@
NummerKlantOnderwerpDatumStatusTotaal incl.
NummerKlantOnderwerpDatumStatusTotaal incl.
{% else %}

Nog geen offertes.

{% endif %}
+{% 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.')]) %} + +{% endcall %} {% endblock %} diff --git a/templates/relations.html b/templates/relations.html index 46eddcc..f0754b5 100644 --- a/templates/relations.html +++ b/templates/relations.html @@ -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 %} @@ -28,10 +30,11 @@
{% if rows %}
- {% if kind == 'partner' %}{% endif %} + {{ bulk_head() }}{% if kind == 'partner' %}{% endif %} {% for r in rows %} + {{ bulk_cell(r.id, r.name) }} @@ -44,4 +47,9 @@
NaamTypeContactpersonenPlaatsContactProducten
NaamTypeContactpersonenPlaatsContactProducten
{% else %}

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.' }}

{% endif %}
+{% 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.')]) %} + + +{% endcall %} {% endblock %}