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:
co-authored by
Claude Opus 5.5
parent
3cf53bc248
commit
db3e26d258
@@ -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,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/<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)')
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user