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,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user