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
+125
View File
@@ -0,0 +1,125 @@
{% extends 'base.html' %}
{% block title %}Offerte {{ qt.number }}{% endblock %}
{% macro row(l) %}
<tr>
<td>
<select name="l_product" class="prod" aria-label="Product">
<option value="">Vrije regel</option>
{% for p in products %}
<option value="{{ p.id }}" {{ 'selected' if l.product_id == p.id }}
data-desc="{{ p.name }}{% if p.description %}&#10;{{ p.description }}{% endif %}"
data-price="{{ p.price }}" data-vat="{{ p.vat|nr|replace(',', '.') }}" data-unit="{{ p.unit }}">{{ p.name }}</option>
{% endfor %}
</select>
</td>
<td><textarea name="l_desc" rows="1" aria-label="Omschrijving">{{ l.description }}</textarea></td>
<td><input type="number" step="any" name="l_qty" value="{{ l.qty|default(1) }}" aria-label="Aantal"></td>
<td><input name="l_unit" value="{{ l.unit }}" aria-label="Eenheid"></td>
<td><input type="number" step="0.01" name="l_price" value="{{ l.price }}" aria-label="Prijs">
{% if l.product_id and prices.get(l.product_id) is not none and prices[l.product_id] != l.price %}<p class="hint" style="margin:.2rem 0 0">Actueel: {{ prices[l.product_id]|euro }}</p>{% endif %}</td>
<td><select name="l_vat" aria-label="Btw">{% for v in [21, 9, 0] %}<option value="{{ v }}" {{ 'selected' if l.vat|default(21) == v }}>{{ v }}%</option>{% endfor %}</select></td>
<td class="num amt"></td>
<td><button type="button" class="linkbtn del" aria-label="Regel verwijderen">✕</button></td>
</tr>
{% endmacro %}
{% block content %}
<form method="post">
<div class="head">
<div><a class="label" href="{{ url_for('quotes') }}">← Offertes</a><h1>Offerte {{ qt.number }}</h1></div>
<div class="actions">
<button class="btn ghost" name="then" value="print">Opslaan en afdrukken</button>
<button class="btn">Opslaan</button>
</div>
</div>
<div class="card">
<div class="fields">
<div><label for="contact">Klant</label>
<select id="contact" name="contact_id">
<option value="">— Kies een contact —</option>
{% for c in contacts %}<option value="{{ c.id }}" {{ 'selected' if c.id == qt.contact_id }}>{{ c.name }}{% if c.company %} ({{ c.company }}){% endif %}</option>{% endfor %}
</select>
</div>
<div><label for="status">Status</label>
<select id="status" name="status">{% for k, l in QUOTE_STATUSES %}<option value="{{ k }}" {{ 'selected' if qt.status == k }}>{{ l }}</option>{% endfor %}</select>
<p class="hint">Op "Verzonden" zetten = zichtbaar in het klantportaal.</p>
</div>
<div><label for="date">Datum</label><input id="date" type="date" name="date" value="{{ qt.date }}"></div>
<div><label for="valid">Geldig tot</label><input id="valid" type="date" name="valid_until" value="{{ qt.valid_until or '' }}"></div>
<div class="full"><label for="title">Onderwerp</label><input id="title" name="title" value="{{ qt.title }}" placeholder="Bijv. Mantelzorgwoning model Amstel"></div>
<div class="full"><label for="intro">Inleiding</label><textarea id="intro" name="intro">{{ qt.intro }}</textarea></div>
</div>
</div>
<div class="card">
<h2>Regels</h2>
<div class="table-wrap"><table class="lines">
<thead><tr><th style="min-width:170px">Product</th><th style="min-width:240px">Omschrijving</th><th style="width:90px">Aantal</th><th style="width:100px">Eenheid</th><th style="width:130px">Prijs excl.</th><th style="width:90px">Btw</th><th class="num" style="width:120px">Totaal</th><th></th></tr></thead>
<tbody id="rows">{% for l in lines %}{{ row(l) }}{% endfor %}</tbody>
</table></div>
<div class="actions"><button type="button" class="btn ghost small" id="add">Regel toevoegen</button></div>
<table class="totals">
<tr><td>Subtotaal</td><td class="num" id="t-sub"></td></tr>
<tbody id="t-vat"></tbody>
<tr class="grand"><td>Totaal</td><td class="num" id="t-total"></td></tr>
</table>
</div>
<div class="card">
<label for="notes">Opmerkingen en voorwaarden</label>
<textarea id="notes" name="notes">{{ qt.notes }}</textarea>
</div>
</form>
<form method="post" action="{{ url_for('quote_delete', qid=qt.id) }}" onsubmit="return confirm('Offerte verwijderen?')">
<button class="btn quiet small">Offerte verwijderen</button>
</form>
<template id="tpl">{{ row({}) }}</template>
<script>
const rows = document.getElementById('rows'), tpl = document.getElementById('tpl');
const fmt = n => '€ ' + n.toLocaleString('nl-NL', {minimumFractionDigits: 2, maximumFractionDigits: 2});
const val = (tr, name) => parseFloat(tr.querySelector(`[name=${name}]`).value) || 0;
function recalc() {
let sub = 0; const vat = {};
rows.querySelectorAll('tr').forEach(tr => {
const amount = val(tr, 'l_qty') * val(tr, 'l_price'), rate = val(tr, 'l_vat');
sub += amount;
vat[rate] = (vat[rate] || 0) + amount * rate / 100;
tr.querySelector('.amt').textContent = fmt(amount);
});
document.getElementById('t-sub').textContent = fmt(sub);
const tv = document.getElementById('t-vat');
tv.innerHTML = '';
let total = sub;
Object.keys(vat).sort((a, b) => b - a).forEach(rate => {
total += vat[rate];
const r = tv.insertRow();
r.insertCell().textContent = `Btw ${rate}%`;
const c = r.insertCell(); c.className = 'num'; c.textContent = fmt(vat[rate]);
});
document.getElementById('t-total').textContent = fmt(total);
}
rows.addEventListener('input', recalc);
rows.addEventListener('change', e => {
if (e.target.classList.contains('prod') && e.target.value) {
const o = e.target.selectedOptions[0], tr = e.target.closest('tr');
tr.querySelector('[name=l_desc]').value = o.dataset.desc;
tr.querySelector('[name=l_price]').value = o.dataset.price;
tr.querySelector('[name=l_vat]').value = o.dataset.vat;
tr.querySelector('[name=l_unit]').value = o.dataset.unit;
}
recalc();
});
rows.addEventListener('click', e => {
if (e.target.closest('.del')) { e.target.closest('tr').remove(); recalc(); }
});
document.getElementById('add').addEventListener('click', () => {
const tr = tpl.content.firstElementChild.cloneNode(true);
rows.appendChild(tr);
document.dispatchEvent(new CustomEvent('crm:row', { detail: tr }));
recalc();
});
if (!rows.children.length) document.getElementById('add').click(); else recalc();
</script>
{% endblock %}