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>
126 lines
6.2 KiB
HTML
126 lines
6.2 KiB
HTML
{% 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 %} {{ 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 %}
|