v0.0.2: pre-productie (Bootstrap 5.3.3, LightPdf klant-PDF, server-setup en release-script)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-15 11:58:52 +02:00
co-authored by Claude Opus 5.5
parent bffa053c05
commit 7d302dc405
48 changed files with 2769 additions and 698 deletions
+125 -84
View File
@@ -1,25 +1,5 @@
{% extends 'base.html' %}
{% block title %}{{ c.name }}{% if c.type == 'lead' %}
<dialog id="convert" class="dialog">
<form method="post" action="{{ url_for('contact_convert', cid=c.id) }}">
<h2>Omzetten naar klant</h2>
<p class="muted">De contactpersoon komt in een klantrelatie, bijvoorbeeld een huishouden of familie.</p>
<label><input type="radio" name="mode" value="new" checked onchange="this.form.relation_id.disabled=true;this.form.new_relation.disabled=false"> Nieuwe klantrelatie</label>
<input name="new_relation" value="{{ c.company or ('Familie ' ~ c.name.split(' ')[-1]) }}" aria-label="Naam relatie" style="margin:.4rem 0 1rem">
{% if klant_relations %}
<label><input type="radio" name="mode" value="existing" onchange="this.form.relation_id.disabled=false;this.form.new_relation.disabled=true"> Bestaande klantrelatie</label>
<select name="relation_id" disabled aria-label="Bestaande relatie" style="margin-top:.4rem">
{% for r in klant_relations %}<option value="{{ r.id }}">{{ r.name }}{% if r.type %} · {{ r.type }}{% endif %}</option>{% endfor %}
</select>
{% endif %}
<div class="actions">
<button class="btn">Omzetten</button>
<button class="btn quiet" type="button" onclick="this.closest('dialog').close()">Annuleren</button>
</div>
</form>
</dialog>
{% endif %}
{% endblock %}
{% block title %}{{ c.name }}{% endblock %}
{% block content %}
<div class="head">
<div>
@@ -30,24 +10,77 @@
</div>
<div class="actions">
<form method="post" action="{{ url_for('contact_status', cid=c.id) }}" class="inline">
<select name="status" onchange="this.form.submit()" aria-label="Status" style="width:auto">
<select class="form-select w-auto" name="status" onchange="this.form.submit()" aria-label="Status">
{% for k, l in STATUSES %}<option value="{{ k }}" {{ 'selected' if c.status == k }}>{{ l }}</option>{% endfor %}
</select>
</form>
{% if c.type == 'lead' %}
<button class="btn ghost" type="button" onclick="document.getElementById('convert').showModal()">Omzetten naar klant</button>
<button class="btn btn-outline-primary" type="button" onclick="document.getElementById('convert').showModal()">Omzetten naar klant</button>
{% endif %}
<form method="post" action="{{ url_for('quote_new') }}" class="inline">
<input type="hidden" name="contact_id" value="{{ c.id }}">
<button class="btn ghost">Nieuwe offerte</button>
<button class="btn btn-outline-primary">Nieuwe offerte</button>
</form>
<a class="btn" href="{{ url_for('contact_edit', cid=c.id) }}">Bewerken</a>
<a class="btn btn-primary" href="{{ url_for('contact_edit', cid=c.id) }}">Bewerken</a>
</div>
</div>
<div class="two">
<div>
<div class="card">
<div class="row gx-4 stack">
<div class="col-lg-7 col-xl-8">
<div class="card"><div class="card-body">
<div class="card-top">
<h2>Gekozen model</h2>
{% if design %}
<div class="actions">
{% if designs|length > 1 %}
<form method="post" action="{{ url_for('contact_design', cid=c.id) }}" class="inline">
<select class="form-select form-select-sm w-auto" name="file_id" onchange="this.form.submit()" aria-label="Ander ontwerp kiezen">
{% for d in designs %}<option value="{{ d.id }}" {{ 'selected' if d.id == design.id }}>{{ d.title }} · {{ d.created_at|dt('%d-%m-%Y') }}</option>{% endfor %}
</select>
</form>
{% endif %}
<form method="post" action="{{ url_for('quote_new') }}" class="inline">
<input type="hidden" name="contact_id" value="{{ c.id }}"><input type="hidden" name="bestek" value="1">
<button class="btn btn-primary btn-sm">Offerte met bestek</button>
</form>
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('design_viewer', fid=design.id) }}" target="_blank">Openen</a>
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('upload_file', name=design.stored) }}" download="{{ design.original }}">JSON</a>
</div>
{% endif %}
</div>
{% if design %}
<div class="viewer-frame"><iframe src="{{ url_for('design_viewer', fid=design.id) }}" title="3D-weergave {{ design.title }}" loading="lazy" allowfullscreen></iframe></div>
<p class="muted" style="margin:.75rem 0 0"><strong style="color:var(--ink)">{{ design.title }}</strong>
· {{ design_meta.units }} unit{{ 's' if design_meta.units != 1 }}{% if design_meta.address %} · {{ design_meta.address }}{% endif %}
· ontvangen {{ design.created_at|dt }}{% if design_meta.version %} · configurator {{ design_meta.version }}{% endif %}</p>
{% if design_meta.note %}<p style="white-space:pre-line;margin:.5rem 0 0">{{ design_meta.note }}</p>{% endif %}
<p class="pdf-links" style="margin:.5rem 0 0">Tekeningen:
{% for k, label in PDF_KINDS.items() %}{% set p = pdfs.get(k) %}
<a data-pdf="{{ k }}" href="{{ p.url if p else '#' }}" target="_blank" {{ 'hidden' if not p }}>{{ (label[:1]|upper) ~ label[1:] }}</a><span class="muted" data-pdf-wait="{{ k }}" {{ 'hidden' if p }}>{{ label }} wordt gemaakt…</span>{% if k == 'werk' %} <span class="muted">(intern)</span> ·{% endif %}
{% endfor %}
<span class="muted">· opnieuw maken via PDF in de weergave</span></p>
<script>
addEventListener('message', (e) => {
if (e.origin !== location.origin || !e.data || e.data.type !== 'tectum-pdf') return;
const a = document.querySelector(`[data-pdf="${e.data.kind}"]`), w = document.querySelector(`[data-pdf-wait="${e.data.kind}"]`);
if (a) { a.href = e.data.url; a.hidden = false; }
if (w) w.hidden = true;
});
</script>
{% elif designs %}
<p class="muted">Er is geen model gekozen.</p>
<form method="post" action="{{ url_for('contact_design', cid=c.id) }}" class="d-flex flex-wrap gap-2">
<select class="form-select w-auto" name="file_id" aria-label="Ontwerp">
{% for d in designs %}<option value="{{ d.id }}">{{ d.title }} · {{ d.created_at|dt('%d-%m-%Y') }}</option>{% endfor %}
</select>
<button class="btn btn-outline-primary">Kiezen</button>
</form>
{% else %}
<p class="empty">Nog geen model. Een ontwerp uit de configurator (Bestand › Opslaan, JSON) komt hier vanzelf als je het bij Bestanden uploadt of als het met het formulier meekomt.</p>
{% endif %}
</div></div>
<div class="card o-2"><div class="card-body">
<h2>Taken</h2>
{% for t in tasks %}
<form method="post" action="{{ url_for('task_done', tid=t.id) }}" class="task {{ 'done' if t.done }}">
@@ -56,46 +89,44 @@
<div class="muted">{{ t.user_name or '' }}{% if t.due_date %} · <span class="{{ 'overdue' if not t.done and t.due_date < today }}">{{ t.due_date|dt('%d-%m-%Y') }}</span>{% endif %}</div></div>
</form>
{% endfor %}
<form method="post" action="{{ url_for('contact_task', cid=c.id) }}" style="margin-top:1rem">
<div class="inline-fields">
<input name="title" placeholder="Bijv. terugbellen, offerte nabellen" required aria-label="Taak">
<input type="date" name="due_date" aria-label="Datum">
</div>
<div class="actions" style="margin-top:.5rem">
<select name="user_id" style="width:auto" aria-label="Voor">
<form method="post" action="{{ url_for('contact_task', cid=c.id) }}" class="row g-2 mt-2">
<div class="col-12 col-md"><input class="form-control" name="title" placeholder="Bijv. terugbellen, offerte nabellen" required aria-label="Taak"></div>
<div class="col-6 col-md-auto"><input class="form-control" type="date" name="due_date" aria-label="Datum"></div>
<div class="col-6 col-md-auto">
<select class="form-select" name="user_id" aria-label="Voor">
{% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if u.id == g.user.id }}>{{ u.name }}</option>{% endfor %}
</select>
<button class="btn ghost small">Taak toevoegen</button>
</div>
<div class="col-12 col-md-auto"><button class="btn btn-outline-primary w-100">Taak toevoegen</button></div>
</form>
</div>
</div></div>
<div class="card">
<div class="card o-3"><div class="card-body">
<h2>Notitie toevoegen</h2>
<form method="post" action="{{ url_for('contact_note', cid=c.id) }}">
<textarea name="body" required placeholder="Gebeld, afspraak gemaakt, wensen…" aria-label="Notitie"></textarea>
<div class="actions"><button class="btn">Opslaan</button></div>
<textarea class="form-control" name="body" required placeholder="Gebeld, afspraak gemaakt, wensen…" aria-label="Notitie"></textarea>
<div class="actions"><button class="btn btn-primary">Opslaan</button></div>
</form>
</div>
</div></div>
{% if c.email %}
<div class="card">
<div class="card"><div class="card-body">
<details>
<summary>E-mail sturen</summary>
<form method="post" action="{{ url_for('mail_send') }}">
<input type="hidden" name="contact_id" value="{{ c.id }}">
<div class="fields">
<div class="full"><label>Aan</label><input type="email" name="to" value="{{ c.email }}" required></div>
<div class="full"><label>Onderwerp</label><input name="subject" required></div>
<div class="full"><label>Bericht</label><textarea name="body" rows="8" required></textarea></div>
<div class="row g-3">
<div class="col-12"><label class="form-label" for="m-to">Aan</label><input class="form-control" id="m-to" type="email" name="to" value="{{ c.email }}" required></div>
<div class="col-12"><label class="form-label" for="m-subject">Onderwerp</label><input class="form-control" id="m-subject" name="subject" required></div>
<div class="col-12"><label class="form-label" for="m-body">Bericht</label><textarea class="form-control" id="m-body" name="body" rows="8" required></textarea></div>
</div>
<div class="actions"><button class="btn">Versturen</button></div>
<div class="actions"><button class="btn btn-primary">Versturen</button></div>
</form>
</details>
</div>
</div></div>
{% endif %}
<div class="card">
<div class="card"><div class="card-body">
<h2>Tijdlijn</h2>
<ul class="timeline">
{% for a in activities %}
@@ -107,28 +138,28 @@
<li class="empty">Nog geen activiteit.</li>
{% endfor %}
</ul>
</div>
</div></div>
{% if emails %}
<div class="card">
<div class="card"><div class="card-body">
<h2>E-mail</h2>
<div class="table-wrap"><table>
<div class="table-responsive"><table class="table table-hover table-stack">
<tbody>
{% for e in emails %}
<tr class="link" onclick="location='{{ url_for('inbox_view', eid=e.id) }}'">
<td><span class="chip">{{ 'Ontvangen' if e.direction == 'in' else 'Verstuurd' }}</span></td>
<td><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td>
<td class="muted num">{{ e.date|dt }}</td>
<td class="stack-title"><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td>
<td data-label="Richting"><span class="chip">{{ 'Ontvangen' if e.direction == 'in' else 'Verstuurd' }}</span></td>
<td data-label="Datum" class="muted num">{{ e.date|dt }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
</div>
</div></div>
{% endif %}
</div>
<div>
<div class="card">
<div class="col-lg-5 col-xl-4">
<div class="card o-1"><div class="card-body">
<h2>Gegevens</h2>
<dl class="props">
<dt>Status</dt><dd><span class="chip {{ c.status }}">{{ STATUS.get(c.status, c.status) }}</span></dd>
@@ -151,19 +182,23 @@
{% for k, v in extra.items() %}<dt>{{ k }}</dt><dd>{{ v if v is string else v|tojson }}</dd>{% endfor %}
</dl>
{% endif %}
</div>
</div></div>
<div class="card">
<div class="card"><div class="card-body">
<h2>Bestanden</h2>
{% if files %}
<div class="files">
{% for f in files %}
<div class="file">
<a href="{{ url_for('upload_file', name=f.stored) }}" target="_blank" class="thumb">
<a href="{{ url_for('design_viewer', fid=f.id) if f.title else url_for('upload_file', name=f.stored) }}" target="_blank" class="thumb">
{% if f.mime.startswith('image/') %}<img src="{{ url_for('upload_file', name=f.stored) }}" alt="{{ f.original }}" loading="lazy">
{% else %}{{ f.original.rsplit('.', 1)[-1][:4] if '.' in f.original else 'bestand' }}{% endif %}
{% elif f.title %}3D{% else %}{{ f.original.rsplit('.', 1)[-1][:4] if '.' in f.original else 'bestand' }}{% endif %}
</a>
<div class="name" title="{{ f.original }}">{{ f.original }}</div>
<div class="name" title="{{ f.original }}">{{ f.title or f.original }}</div>
{% if f.kind == 'werk' %}<div class="muted">Intern, niet in portaal</div>{% endif %}
{% if f.title %}{% if c.design_file_id == f.id %}<div class="muted">Gekozen model</div>{% else %}
<form method="post" action="{{ url_for('contact_design', cid=c.id) }}"><input type="hidden" name="file_id" value="{{ f.id }}"><button class="linkbtn">Kies als model</button></form>
{% endif %}{% endif %}
<form method="post" action="{{ url_for('file_delete', fid=f.id) }}" onsubmit="return confirm('Bestand verwijderen?')">
<button class="linkbtn">Verwijderen</button>
</form>
@@ -172,12 +207,12 @@
</div>
{% endif %}
<form method="post" action="{{ url_for('contact_upload', cid=c.id) }}" enctype="multipart/form-data">
<input type="file" name="files" multiple required aria-label="Bestanden kiezen">
<div class="actions"><button class="btn ghost small">Uploaden</button></div>
<input class="form-control" type="file" name="files" multiple required aria-label="Bestanden kiezen">
<div class="actions"><button class="btn btn-outline-primary btn-sm">Uploaden</button></div>
</form>
</div>
</div></div>
<div class="card">
<div class="card"><div class="card-body">
<h2>Offertes</h2>
{% for qt in quotes %}
<p style="margin:0 0 .6rem"><a href="{{ url_for('quote_edit', qid=qt.id) }}">{{ qt.number }}</a> <span class="chip {{ qt.status }}">{{ QSTATUS.get(qt.status) }}</span><br>
@@ -185,27 +220,27 @@
{% else %}
<p class="empty">Nog geen offertes.</p>
{% endfor %}
</div>
</div></div>
<div class="card">
<div class="card"><div class="card-body">
<h2>Klantportaal</h2>
{% if c.portal_password_hash %}
<p class="muted" style="margin-top:0">Actief{% if c.portal_last_login %} · laatst ingelogd {{ c.portal_last_login|dt }}{% endif %}</p>
<p class="muted">Actief{% if c.portal_last_login %} · laatst ingelogd {{ c.portal_last_login|dt }}{% endif %}</p>
{% elif portal_link %}
<p class="muted" style="margin-top:0">Uitnodiging klaar, nog niet geactiveerd:</p>
<p class="muted">Uitnodiging klaar, nog niet geactiveerd:</p>
<p><code>{{ portal_link }}</code></p>
{% else %}
<p class="muted" style="margin-top:0">De klant kan via het portaal offertes bekijken en accepteren, en bestanden en berichten sturen.</p>
<p class="muted">De klant kan via het portaal offertes bekijken en accepteren, en bestanden en berichten sturen.</p>
{% endif %}
<form method="post" action="{{ url_for('contact_portal', cid=c.id) }}" class="actions">
{% if portal_link and c.email %}<button class="btn small" name="action" value="mail">Mail uitnodiging</button>{% endif %}
{% if not c.portal_password_hash %}<button class="btn ghost small" name="action" value="invite">{{ 'Nieuwe link' if portal_link else 'Uitnodiging maken' }}</button>{% endif %}
{% if c.portal_password_hash or portal_link %}<button class="btn quiet small" name="action" value="revoke">Toegang intrekken</button>{% endif %}
{% if portal_link and c.email %}<button class="btn btn-primary btn-sm" name="action" value="mail">Mail uitnodiging</button>{% endif %}
{% if not c.portal_password_hash %}<button class="btn btn-outline-primary btn-sm" name="action" value="invite">{{ 'Nieuwe link' if portal_link else 'Uitnodiging maken' }}</button>{% endif %}
{% if c.portal_password_hash or portal_link %}<button class="btn btn-outline-secondary btn-sm" name="action" value="revoke">Toegang intrekken</button>{% endif %}
</form>
</div>
</div></div>
<form method="post" action="{{ url_for('contact_delete', cid=c.id) }}" onsubmit="return confirm('Contact met alle notities en bestanden verwijderen?')">
<button class="btn quiet small">Contact verwijderen</button>
<form method="post" action="{{ url_for('contact_delete', cid=c.id) }}" class="mb-4" onsubmit="return confirm('Contact met alle notities en bestanden verwijderen?')">
<button class="btn btn-outline-secondary btn-sm">Contact verwijderen</button>
</form>
</div>
</div>
@@ -214,17 +249,23 @@
<form method="post" action="{{ url_for('contact_convert', cid=c.id) }}">
<h2>Omzetten naar klant</h2>
<p class="muted">De contactpersoon komt in een klantrelatie, bijvoorbeeld een huishouden of familie.</p>
<label><input type="radio" name="mode" value="new" checked onchange="this.form.relation_id.disabled=true;this.form.new_relation.disabled=false"> Nieuwe klantrelatie</label>
<input name="new_relation" value="{{ c.company or ('Familie ' ~ c.name.split(' ')[-1]) }}" aria-label="Naam relatie" style="margin:.4rem 0 1rem">
<div class="form-check">
<input class="form-check-input" type="radio" id="mode-new" name="mode" value="new" checked onchange="this.form.relation_id && (this.form.relation_id.disabled=true);this.form.new_relation.disabled=false">
<label class="form-check-label" for="mode-new">Nieuwe klantrelatie</label>
</div>
<input class="form-control" name="new_relation" value="{{ c.company or ('Familie ' ~ c.name.split(' ')[-1]) }}" aria-label="Naam relatie" style="margin:.4rem 0 1rem">
{% if klant_relations %}
<label><input type="radio" name="mode" value="existing" onchange="this.form.relation_id.disabled=false;this.form.new_relation.disabled=true"> Bestaande klantrelatie</label>
<select name="relation_id" disabled aria-label="Bestaande relatie" style="margin-top:.4rem">
<div class="form-check">
<input class="form-check-input" type="radio" id="mode-existing" name="mode" value="existing" onchange="this.form.relation_id.disabled=false;this.form.new_relation.disabled=true">
<label class="form-check-label" for="mode-existing">Bestaande klantrelatie</label>
</div>
<select class="form-select" name="relation_id" disabled aria-label="Bestaande relatie" style="margin-top:.4rem">
{% for r in klant_relations %}<option value="{{ r.id }}">{{ r.name }}{% if r.type %} · {{ r.type }}{% endif %}</option>{% endfor %}
</select>
{% endif %}
<div class="actions">
<button class="btn">Omzetten</button>
<button class="btn quiet" type="button" onclick="this.closest('dialog').close()">Annuleren</button>
<button class="btn btn-primary">Omzetten</button>
<button class="btn btn-outline-secondary" type="button" onclick="this.closest('dialog').close()">Annuleren</button>
</div>
</form>
</dialog>