v0.0.1: eerste versie van de Van Emous CRM

Flask + SQLite, in de Van Emous-huisstijl (tokens uit het design system, GSAP-effecten
volgens de motion-regels, uit bij prefers-reduced-motion).

- Leads en contactpersonen: status, toewijzen, verwachte waarde, omzetten naar klant,
  tijdlijn, bestanden, taken/opvolging
- Relaties (zoals accounts in EspoCRM): klantbundels (huishouden/familie) en partners met
  type (leverancier, aannemer, ...); leveranciers van producten worden automatisch partners
- API voor formulieren (POST /api/leads) met dubbele-leaddetectie, bestanden en
  configurator-ontwerpen (house-design JSON)
- Import van CSV/JSON (leads, relaties, producten) met kolomkoppeling; configurator-formaat
- Producten met SKU als vaste sleutel, mutatiehistorie, POST /api/products, CSV-export;
  offerteregels houden hun eigen prijs
- Offertes met btw per tarief en afdrukversie (PDF via de browser)
- Inbox via IMAP/SMTP, gekoppeld aan contacten
- Gebruikers (beheerder/medewerker) en klantportaal (offertes accepteren, bestanden, berichten)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-14 21:56:55 +02:00
co-authored by Claude Opus 5.5
commit bffa053c05
33 changed files with 4111 additions and 0 deletions
+232
View File
@@ -0,0 +1,232 @@
{% 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 content %}
<div class="head">
<div>
<a class="label" href="{{ url_for('contacts') }}">← Contacten</a>
<h1>{{ c.name }}</h1>
<div class="muted"><span class="chip">{{ TYPE.get(c.type) }}</span>
{% if c.relation_id %}<a href="{{ url_for('relation_view', rid=c.relation_id) }}">{{ c.relation_name }}</a>{% if c.relation_role %} ({{ c.relation_role }}){% endif %} · {% endif %}{{ [c.company, c.city]|select|join(' · ') }}</div>
</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">
{% 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>
{% 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>
</form>
<a class="btn" href="{{ url_for('contact_edit', cid=c.id) }}">Bewerken</a>
</div>
</div>
<div class="two">
<div>
<div class="card">
<h2>Taken</h2>
{% for t in tasks %}
<form method="post" action="{{ url_for('task_done', tid=t.id) }}" class="task {{ 'done' if t.done }}">
<button class="check" aria-label="{{ 'Heropenen' if t.done else 'Afronden' }}"></button>
<div><div class="title">{{ t.title }}</div>
<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">
{% 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>
</form>
</div>
<div class="card">
<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>
</form>
</div>
{% if c.email %}
<div class="card">
<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>
<div class="actions"><button class="btn">Versturen</button></div>
</form>
</details>
</div>
{% endif %}
<div class="card">
<h2>Tijdlijn</h2>
<ul class="timeline">
{% for a in activities %}
<li>
<div class="meta">{{ ACT.get(a.type, a.type) }} · {{ a.created_at|dt }}{% if a.user_name %} · {{ a.user_name }}{% endif %}</div>
<div class="body">{{ a.body }}</div>
</li>
{% else %}
<li class="empty">Nog geen activiteit.</li>
{% endfor %}
</ul>
</div>
{% if emails %}
<div class="card">
<h2>E-mail</h2>
<div class="table-wrap"><table>
<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>
</tr>
{% endfor %}
</tbody>
</table></div>
</div>
{% endif %}
</div>
<div>
<div class="card">
<h2>Gegevens</h2>
<dl class="props">
<dt>Status</dt><dd><span class="chip {{ c.status }}">{{ STATUS.get(c.status, c.status) }}</span></dd>
<dt>Toegewezen</dt><dd>{{ c.user_name or '—' }}</dd>
{% if c.relation_id %}<dt>Relatie</dt><dd><a href="{{ url_for('relation_view', rid=c.relation_id) }}">{{ c.relation_name }}</a><br><span class="muted">{{ RKIND.get(c.relation_kind) }}{% if c.relation_type %} · {{ c.relation_type }}{% endif %}</span>
{% for h in housemates %}<br><a href="{{ url_for('contact_view', cid=h.id) }}">{{ h.name }}</a>{% if h.relation_role %} <span class="muted">({{ h.relation_role }})</span>{% endif %}{% endfor %}</dd>{% endif %}
{% if c.value %}<dt>Verwachte waarde</dt><dd>{{ c.value|euro }}</dd>{% endif %}
{% if c.email %}<dt>E-mail</dt><dd><a href="mailto:{{ c.email }}">{{ c.email }}</a></dd>{% endif %}
{% if c.phone %}<dt>Telefoon</dt><dd><a href="tel:{{ c.phone }}">{{ c.phone }}</a></dd>{% endif %}
{% if c.company %}<dt>Bedrijf</dt><dd>{{ c.company }}</dd>{% endif %}
{% if c.address or c.city %}<dt>Adres</dt><dd>{{ c.address }}{% if c.address %}
{% endif %}{{ c.postcode }} {{ c.city }}</dd>{% endif %}
{% if c.source %}<dt>Bron</dt><dd>{{ c.source }}</dd>{% endif %}
<dt>Aangemaakt</dt><dd>{{ c.created_at|dt }}</dd>
</dl>
{% if c.notes %}<h3>Notities</h3><p style="white-space:pre-line;margin:0">{{ c.notes }}</p>{% endif %}
{% if extra %}
<h3>Formuliervelden</h3>
<dl class="props">
{% for k, v in extra.items() %}<dt>{{ k }}</dt><dd>{{ v if v is string else v|tojson }}</dd>{% endfor %}
</dl>
{% endif %}
</div>
<div class="card">
<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">
{% 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 %}
</a>
<div class="name" title="{{ f.original }}">{{ f.original }}</div>
<form method="post" action="{{ url_for('file_delete', fid=f.id) }}" onsubmit="return confirm('Bestand verwijderen?')">
<button class="linkbtn">Verwijderen</button>
</form>
</div>
{% endfor %}
</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>
</form>
</div>
<div class="card">
<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>
<span class="muted">{{ qt.date|dt('%d-%m-%Y') }} · {{ qt.total|euro }} incl. btw</span></p>
{% else %}
<p class="empty">Nog geen offertes.</p>
{% endfor %}
</div>
<div class="card">
<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>
{% elif portal_link %}
<p class="muted" style="margin-top:0">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>
{% 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 %}
</form>
</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>
</div>
</div>
{% 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 %}