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:
@@ -0,0 +1,13 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Mijn account{% endblock %}
|
||||
{% block content %}
|
||||
<div class="head"><div><div class="kicker">{{ ROLE.get(g.user.role) }}</div><h1>{{ g.user.name }}</h1><div class="muted">{{ g.user.email }}</div></div></div>
|
||||
<form method="post" class="card" style="max-width:560px">
|
||||
<h2>Wachtwoord wijzigen</h2>
|
||||
<div class="fields">
|
||||
<div class="full"><label for="old">Huidig wachtwoord</label><input id="old" type="password" name="old_password" required></div>
|
||||
<div class="full"><label for="new">Nieuw wachtwoord</label><input id="new" type="password" name="new_password" minlength="8" required autocomplete="new-password"></div>
|
||||
</div>
|
||||
<div class="actions"><button class="btn">Opslaan</button></div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,33 @@
|
||||
{% extends 'base.html' %}
|
||||
{% set titles = {'setup': 'Welkom', 'login': 'Inloggen', 'portal_login': 'Klantportaal', 'invite': 'Wachtwoord instellen'} %}
|
||||
{% block title %}{{ titles[mode] }}{% endblock %}
|
||||
{% block body %}
|
||||
<div class="auth">
|
||||
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous">
|
||||
{% with msgs = get_flashed_messages(with_categories=true) %}
|
||||
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
|
||||
{% endwith %}
|
||||
<div class="card">
|
||||
<form method="post">
|
||||
<h1>{{ titles[mode] }}</h1>
|
||||
{% if mode == 'setup' %}
|
||||
<p class="muted">Maak het eerste beheerdersaccount aan.</p>
|
||||
<label for="name">Naam</label><input id="name" name="name" required autofocus>
|
||||
<label for="email" style="margin-top:1rem">E-mailadres</label><input id="email" type="email" name="email" required>
|
||||
{% elif mode == 'invite' %}
|
||||
<p class="muted">Welkom {{ c.name }}. Kies een wachtwoord voor het klantportaal. Daarna logt u in met {{ c.email }}.</p>
|
||||
{% else %}
|
||||
{% if mode == 'portal_login' %}<p class="muted">Bekijk uw offertes en deel bestanden met ons.</p>{% endif %}
|
||||
<label for="email">E-mailadres</label><input id="email" type="email" name="email" required autofocus>
|
||||
{% endif %}
|
||||
<label for="pw" style="margin-top:1rem">Wachtwoord</label>
|
||||
<input id="pw" type="password" name="password" {{ 'minlength=8' if mode in ('setup', 'invite') }} required>
|
||||
{% if mode in ('setup', 'invite') %}
|
||||
<label for="pw2" style="margin-top:1rem">Herhaal wachtwoord</label>
|
||||
<input id="pw2" type="password" name="password2" minlength="8" required>
|
||||
{% endif %}
|
||||
<div class="actions"><button class="btn">{{ 'Inloggen' if mode in ('login', 'portal_login') else 'Opslaan en doorgaan' }}</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,54 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}CRM{% endblock %} · Van Emous CRM</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Kantumruy+Pro:wght@500;600&family=Mukta:wght@400;600;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
<script>
|
||||
/* inhoud verbergen tot de GSAP-intro klaar staat; na 3 s altijd zichtbaar */
|
||||
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
document.documentElement.classList.add('fx-wait');
|
||||
setTimeout(() => document.documentElement.classList.remove('fx-wait'), 3000);
|
||||
}
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js" defer></script>
|
||||
<script src="{{ url_for('static', filename='fx.js') }}" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}
|
||||
{% set ep = request.endpoint or '' %}
|
||||
<aside class="side">
|
||||
<a href="{{ url_for('dashboard') }}" class="brand"><img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous"></a>
|
||||
{% set ctype = request.args.get('type') or (c.type if c is defined and c and c.type is defined else '') %}
|
||||
{% set rkind = request.args.get('kind') or (r.kind if r is defined and r and r.kind is defined else '') %}
|
||||
<nav>
|
||||
<a href="{{ url_for('dashboard') }}" class="{{ 'active' if ep == 'dashboard' }}">Dashboard</a>
|
||||
<a href="{{ url_for('contacts', type='lead') }}" class="{{ 'active' if ep.startswith('contact') and ctype == 'lead' }}">Leads</a>
|
||||
<a href="{{ url_for('relations', kind='klant') }}" class="{{ 'active' if ep.startswith('relation') and rkind == 'klant' }}">Klanten</a>
|
||||
<a href="{{ url_for('relations', kind='partner') }}" class="{{ 'active' if ep.startswith('relation') and rkind == 'partner' }}">Partners</a>
|
||||
<a href="{{ url_for('contacts') }}" class="{{ 'active' if ep.startswith('contact') and ctype != 'lead' }}">Contactpersonen</a>
|
||||
<a href="{{ url_for('inbox') }}" class="{{ 'active' if ep.startswith('inbox') }}">Inbox {% if unread %}<span class="count">{{ unread }}</span>{% endif %}</a>
|
||||
<a href="{{ url_for('products') }}" class="{{ 'active' if ep.startswith('product') }}">Producten</a>
|
||||
<a href="{{ url_for('quotes') }}" class="{{ 'active' if ep.startswith('quote') }}">Offertes</a>
|
||||
<a href="{{ url_for('import_start') }}" class="{{ 'active' if ep.startswith('import') }}">Importeren</a>
|
||||
{% if g.user.role == 'admin' %}
|
||||
<a href="{{ url_for('users') }}" class="{{ 'active' if ep.startswith('user') }}">Gebruikers</a>
|
||||
<a href="{{ url_for('settings') }}" class="{{ 'active' if ep == 'settings' }}">Instellingen</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<a href="{{ url_for('account') }}" class="logout">{{ g.user.name }}</a>
|
||||
<a href="{{ url_for('logout') }}" class="logout">Uitloggen</a>
|
||||
</aside>
|
||||
<main>
|
||||
{% with msgs = get_flashed_messages(with_categories=true) %}
|
||||
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,56 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ 'Nieuw contact' if new else c.name }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="head">
|
||||
<div>
|
||||
<a class="label" href="{{ url_for('contacts') if new else url_for('contact_view', cid=c.id) }}">← Terug</a>
|
||||
<h1>{{ ('Nieuwe ' ~ TYPE.get(c.type, 'lead')|lower) if new else 'Contact bewerken' }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" class="card">
|
||||
<div class="fields">
|
||||
<div><label for="name">Naam *</label><input id="name" name="name" value="{{ c.name }}" required></div>
|
||||
<div><label for="company">Bedrijf</label><input id="company" name="company" value="{{ c.company }}"></div>
|
||||
<div><label for="email">E-mail</label><input id="email" type="email" name="email" value="{{ c.email }}"></div>
|
||||
<div><label for="phone">Telefoon</label><input id="phone" name="phone" value="{{ c.phone }}"></div>
|
||||
<div class="full"><label for="address">Adres</label><input id="address" name="address" value="{{ c.address }}"></div>
|
||||
<div><label for="postcode">Postcode</label><input id="postcode" name="postcode" value="{{ c.postcode }}"></div>
|
||||
<div><label for="city">Plaats</label><input id="city" name="city" value="{{ c.city }}"></div>
|
||||
<div><label for="status">Status</label>
|
||||
<select id="status" name="status">
|
||||
{% for k, l in STATUSES %}<option value="{{ k }}" {{ 'selected' if c.status == k }}>{{ l }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div><label for="type">Soort</label>
|
||||
<select id="type" name="type">
|
||||
{% for k, l in TYPES %}<option value="{{ k }}" {{ 'selected' if c.type == k }}>{{ l }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div><label for="assigned">Toegewezen aan</label>
|
||||
<select id="assigned" name="assigned_user_id">
|
||||
<option value="">— Niemand —</option>
|
||||
{% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if c.assigned_user_id|string == u.id|string }}>{{ u.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div><label for="relation">Relatie</label>
|
||||
<select id="relation" name="relation_id">
|
||||
<option value="">— Geen —</option>
|
||||
{% for kind, label in RELATION_KINDS %}
|
||||
<optgroup label="{{ label }}s">
|
||||
{% for r in relations if r.kind == kind %}<option value="{{ r.id }}" {{ 'selected' if c.relation_id|string == r.id|string }}>{{ r.name }}{% if r.type %} · {{ r.type }}{% endif %}</option>{% endfor %}
|
||||
</optgroup>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="hint">Huishouden, familie of bedrijf van de klant, of de partner waar deze persoon werkt.</p>
|
||||
</div>
|
||||
<div><label for="role">Rol binnen relatie</label><input id="role" name="relation_role" value="{{ c.relation_role }}" placeholder="Bijv. bewoner, mantelzorger, inkoper"></div>
|
||||
<div><label for="value">Verwachte waarde (€)</label><input id="value" type="number" step="0.01" name="value" value="{{ c.value or '' }}"></div>
|
||||
<div><label for="source">Bron</label><input id="source" name="source" value="{{ c.source }}" placeholder="Bijv. website, beurs, telefoon"></div>
|
||||
<div class="full"><label for="notes">Vaste notities</label><textarea id="notes" name="notes">{{ c.notes }}</textarea></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn">Opslaan</button>
|
||||
<a class="btn quiet" href="{{ url_for('contacts') if new else url_for('contact_view', cid=c.id) }}">Annuleren</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,54 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Contacten{% endblock %}
|
||||
{% block content %}
|
||||
<div class="head">
|
||||
<div><div class="kicker">{{ 'Verkoopkansen' if a.type == 'lead' else 'Relaties' }}</div><h1>{{ {'lead': 'Leads', 'klant': 'Klantcontacten', 'partner': 'Partnercontacten'}.get(a.type, 'Contactpersonen') }}</h1></div>
|
||||
<div class="actions">
|
||||
<form class="search" method="get">
|
||||
{% for k in ('type', 'status', 'owner') %}{% if a[k] %}<input type="hidden" name="{{ k }}" value="{{ a[k] }}">{% endif %}{% endfor %}
|
||||
<input type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, e-mail, plaats…" aria-label="Zoeken">
|
||||
</form>
|
||||
<a class="btn ghost" href="{{ url_for('contacts_export') }}">Exporteer CSV</a>
|
||||
<a class="btn" href="{{ url_for('contact_new', type=a.type or 'lead') }}">{{ 'Nieuwe klant' if a.type == 'klant' else 'Nieuwe lead' }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if a.type != 'lead' %}
|
||||
<div class="filters">
|
||||
<a href="{{ url_for('contacts', q=term or None) }}" class="{{ 'on' if not a.type }}">Alle</a>
|
||||
{% for k, l in TYPES %}<a href="{{ url_for('contacts', type=k, q=term or None) }}" class="{{ 'on' if a.type == k }}">{{ l }}</a>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="filters">
|
||||
{% set base = {'type': a.type, 'q': term or None} %}
|
||||
<a href="{{ url_for('contacts', **base) }}" class="{{ 'on' if not a.status and not a.owner }}">Alle</a>
|
||||
{% for k, l in STATUSES %}
|
||||
<a href="{{ url_for('contacts', status=k, **base) }}" class="{{ 'on' if a.status == k }}">{{ l }}</a>
|
||||
{% endfor %}
|
||||
<span class="muted">·</span>
|
||||
<a href="{{ url_for('contacts', owner='me', **base) }}" class="{{ 'on' if a.owner == 'me' }}">Mijn {{ 'leads' if a.type == 'lead' else 'contacten' }}</a>
|
||||
<a href="{{ url_for('contacts', owner='none', **base) }}" class="{{ 'on' if a.owner == 'none' }}">Niet toegewezen</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
{% if rows %}
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Naam</th><th>Relatie</th><th>Telefoon</th><th>Plaats</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) }}'">
|
||||
<td><a href="{{ url_for('contact_view', cid=c.id) }}">{{ c.name }}</a><div class="muted">{{ c.email }}</div></td>
|
||||
<td>{% if c.relation_id %}<a href="{{ url_for('relation_view', rid=c.relation_id) }}" onclick="event.stopPropagation()">{{ c.relation_name }}</a>{% if c.relation_role %}<div class="muted">{{ c.relation_role }}</div>{% endif %}{% else %}{{ c.company }}{% endif %}</td>
|
||||
<td>{{ c.phone }}</td>
|
||||
<td>{{ c.city }}</td>
|
||||
<td>{{ c.source }}</td>
|
||||
<td>{{ c.user_name or '—' }}</td>
|
||||
<td>{% if not a.type %}<span class="chip">{{ TYPE.get(c.type) }}</span> {% endif %}<span class="chip {{ c.status }}">{{ STATUS.get(c.status, c.status) }}</span></td>
|
||||
<td class="muted">{{ c.created_at|dt('%d-%m-%Y') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% else %}<p class="empty">Geen contacten gevonden.</p>{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,103 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block content %}
|
||||
<div class="head">
|
||||
<div><div class="kicker">Overzicht</div><h1>Dashboard</h1></div>
|
||||
<a class="btn" href="{{ url_for('contact_new') }}">Nieuw contact</a>
|
||||
</div>
|
||||
|
||||
<div class="kpis">
|
||||
<div class="kpi"><div class="label">Nieuwe leads · 30 dagen</div><div class="value">{{ stats.new30 }}</div><div class="muted">{{ stats.customers }} klantrelaties · {{ stats.partners }} partners</div></div>
|
||||
<div class="kpi"><div class="label">Open leads</div><div class="value">{{ stats.open_leads }}</div>
|
||||
<div class="muted">{{ stats.pipeline_value|euro }} verwacht{% if stats.unassigned %} · <a href="{{ url_for('contacts', type='lead', owner='none') }}">{{ stats.unassigned }} niet toegewezen</a>{% endif %}</div></div>
|
||||
<div class="kpi"><div class="label">Open offertes</div><div class="value">{{ stats.open_quotes }}</div><div class="muted">{{ stats.open_value|euro }} excl. btw</div></div>
|
||||
<div class="kpi"><div class="label">Gewonnen · 30 dagen</div><div class="value">{{ stats.won_value|euro }}</div><div class="muted">excl. btw</div></div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>Leads per status</h2>
|
||||
<div class="bars">
|
||||
{% for key, label, n in pipeline %}
|
||||
<a class="bar" href="{{ url_for('contacts', type='lead', status=key) }}">
|
||||
<span>{{ label }}</span>
|
||||
<span class="track"><span class="fill" style="width: {{ (n / pipe_max * 100)|round(1) }}%"></span></span>
|
||||
<span class="num">{{ n }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Leads per week</h2>
|
||||
<div class="cols">
|
||||
{% for wk, n in weekly %}
|
||||
<div title="Week {{ wk }}: {{ n }} lead(s)">
|
||||
<span>{{ n or '' }}</span>
|
||||
<span class="c" style="height: {{ (n / week_max * 75)|round(1) }}%"></span>
|
||||
<span>{{ wk }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Bronnen</h2>
|
||||
{% if sources %}
|
||||
<div class="bars">
|
||||
{% for s in sources %}
|
||||
<div class="bar">
|
||||
<span title="{{ s.s }}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ s.s }}</span>
|
||||
<span class="track"><span class="fill" style="width: {{ (s.n / source_max * 100)|round(1) }}%"></span></span>
|
||||
<span class="num">{{ s.n }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}<p class="empty">Nog geen leads.</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="two">
|
||||
<div class="card">
|
||||
<h2>Laatste leads</h2>
|
||||
{% if recent %}
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Naam</th><th>Bron</th><th>Toegewezen</th><th>Status</th><th>Binnen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in recent %}
|
||||
<tr class="link" onclick="location='{{ url_for('contact_view', cid=c.id) }}'">
|
||||
<td><a href="{{ url_for('contact_view', cid=c.id) }}">{{ c.name }}</a><div class="muted">{{ c.email }}</div></td>
|
||||
<td>{{ c.source }}</td>
|
||||
<td>{{ c.user_name or '—' }}</td>
|
||||
<td><span class="chip {{ c.status }}">{{ STATUS.get(c.status, c.status) }}</span></td>
|
||||
<td class="muted">{{ c.created_at|dt }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% else %}<p class="empty">Nog geen contacten. Voeg er een toe of koppel een formulier via Instellingen.</p>{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<div class="card">
|
||||
<h2>Mijn taken</h2>
|
||||
{% for t in tasks %}
|
||||
<form method="post" action="{{ url_for('task_done', tid=t.id) }}" class="task">
|
||||
<button class="check" aria-label="Afronden"></button>
|
||||
<div><div>{{ t.title }}</div>
|
||||
<div class="muted">{% if t.contact_id %}<a href="{{ url_for('contact_view', cid=t.contact_id) }}">{{ t.contact_name }}</a>{% endif %}
|
||||
{% if t.due_date %}· <span class="{{ 'overdue' if t.due_date < today }}">{{ t.due_date|dt('%d-%m-%Y') }}</span>{% endif %}</div></div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="empty">Geen openstaande taken.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Ongelezen e-mail</h2>
|
||||
{% for e in mails %}
|
||||
<p><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a><br><span class="muted">{{ e.from_name or e.from_addr }} · {{ e.date|dt }}</span></p>
|
||||
{% else %}
|
||||
<p class="empty">Geen ongelezen berichten.</p>
|
||||
{% endfor %}
|
||||
<a href="{{ url_for('inbox') }}">Naar inbox</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ e.subject }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="head">
|
||||
<div>
|
||||
<a class="label" href="{{ url_for('inbox') }}">← Inbox</a>
|
||||
<h1>{{ e.subject }}</h1>
|
||||
<div class="muted">
|
||||
{% if e.direction == 'in' %}Van {{ e.from_name }} <{{ e.from_addr }}>{% else %}Aan {{ e.to_addr }}{% endif %}
|
||||
· {{ e.date|dt }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
{% if contact %}
|
||||
<a class="btn ghost" href="{{ url_for('contact_view', cid=contact.id) }}">Naar {{ contact.name }}</a>
|
||||
{% elif e.direction == 'in' %}
|
||||
<form method="post" action="{{ url_for('inbox_contact', eid=e.id) }}" class="inline"><button class="btn ghost">Maak contact aan</button></form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card"><div class="mailbody">{{ e.body }}</div></div>
|
||||
|
||||
{% if e.direction == 'in' %}
|
||||
<div class="card">
|
||||
<h2>Beantwoorden</h2>
|
||||
<form method="post" action="{{ url_for('mail_send') }}">
|
||||
<input type="hidden" name="reply_to" value="{{ e.id }}">
|
||||
{% if contact %}<input type="hidden" name="contact_id" value="{{ contact.id }}">{% endif %}
|
||||
<div class="fields">
|
||||
<div><label>Aan</label><input type="email" name="to" value="{{ e.from_addr }}" required></div>
|
||||
<div><label>Onderwerp</label><input name="subject" value="{{ e.subject if e.subject.lower().startswith('re:') else 'Re: ' ~ e.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>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Importeren{% endblock %}
|
||||
{% block content %}
|
||||
<div class="head"><div><div class="kicker">Gegevens</div><h1>Importeren</h1></div></div>
|
||||
<div class="two">
|
||||
<form method="post" enctype="multipart/form-data" class="card">
|
||||
<h2>Bestand kiezen</h2>
|
||||
<div class="fields">
|
||||
<div class="full"><label for="kind">Wat importeer je?</label>
|
||||
<select id="kind" name="kind">
|
||||
<option value="contacts">Leads / klanten</option>
|
||||
<option value="relations" {{ 'selected' if request.args.get('kind') == 'relations' }}>Relaties (klanten of partners)</option>
|
||||
<option value="products" {{ 'selected' if request.args.get('kind') == 'products' }}>Producten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="full"><label for="file">CSV- of JSON-bestand</label><input id="file" type="file" name="file" accept=".csv,.json,.txt" required></div>
|
||||
</div>
|
||||
<div class="actions"><button class="btn">Volgende: kolommen controleren</button></div>
|
||||
</form>
|
||||
<div class="card">
|
||||
<h2>Formaten</h2>
|
||||
<p><strong>CSV</strong>: scheidingsteken <code>;</code>, <code>,</code> of tab, de eerste regel met kolomnamen. Regels die beginnen met <code>#</code> worden overgeslagen. Bedragen als <code>6,00</code>, <code>1.234,50</code> of <code>€ 6</code>.</p>
|
||||
<p><strong>JSON</strong>: een lijst met objecten, of een object met een lijst erin, zoals <code>{"leads": [{"naam": "…", "email": "…"}]}</code>.</p>
|
||||
<p><strong>Configurator</strong>: <code>kosten/leveranciers.csv</code> en <code>intern.csv</code> worden als producten herkend (code, omschrijving, eenheid, prijs, leverancier). Een woningontwerp (<code>house-design</code>-JSON) upload je bij een contact onder Bestanden.</p>
|
||||
<p class="muted" style="margin-bottom:0">Je ziet eerst een voorbeeld en kiest per kolom waar die heen moet. Bestaande contacten (zelfde e-mail) en producten (zelfde SKU) worden bijgewerkt in plaats van dubbel aangemaakt. Elke productwijziging komt in de mutatiehistorie; offertes houden hun eigen prijs.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Import controleren{% endblock %}
|
||||
{% block content %}
|
||||
<form method="post">
|
||||
<input type="hidden" name="kind" value="{{ kind }}">
|
||||
<div class="head">
|
||||
<div><a class="label" href="{{ url_for('import_start') }}">← Ander bestand</a>
|
||||
<h1>{{ filename }}</h1>
|
||||
<div class="muted">{{ total }} rij(en) · {{ {'contacts': 'leads / contactpersonen', 'relations': 'relaties', 'products': 'producten'}[kind] }}</div></div>
|
||||
<div class="actions"><button class="btn">{{ total }} rij(en) importeren</button></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Kolommen koppelen</h2>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr>
|
||||
{% for col in cols %}
|
||||
<th style="min-width:170px">
|
||||
<div style="margin-bottom:.4rem">{{ col }}</div>
|
||||
<select name="map_{{ loop.index0 }}" aria-label="Doelveld voor {{ col }}">
|
||||
{% for k, l in targets %}<option value="{{ k }}" {{ 'selected' if mapping[loop.index0] == k }}>{{ l }}</option>{% endfor %}
|
||||
</select>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>{% for col in cols %}{% set v = row.get(col) %}<td>{{ (v if v is string else (v|tojson if v is not none else ''))|truncate(60) }}</td>{% endfor %}</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% if total > rows|length %}<p class="hint">Eerste {{ rows|length }} van {{ total }} rijen.</p>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Opties</h2>
|
||||
<div class="fields">
|
||||
{% if kind == 'contacts' %}
|
||||
<div><label>Importeren als</label><select name="type">{% for k, l in TYPES %}<option value="{{ k }}">{{ l }}</option>{% endfor %}</select></div>
|
||||
<div><label>Status (als de kolom ontbreekt)</label><select name="status">{% for k, l in STATUSES %}<option value="{{ k }}">{{ l }}</option>{% endfor %}</select></div>
|
||||
<div><label>Toewijzen aan</label><select name="assigned_user_id"><option value="">— Niemand —</option>{% for u in USERS %}<option value="{{ u.id }}">{{ u.name }}</option>{% endfor %}</select></div>
|
||||
<div><label>Bron (als de kolom ontbreekt)</label><input name="source" value="Import"></div>
|
||||
{% endif %}
|
||||
{% if kind == 'relations' %}
|
||||
<div><label>Soort</label><select name="relation_kind" id="rkind">{% for k, l in RELATION_KINDS %}<option value="{{ k }}" {{ 'selected' if request.args.get('rkind') == k }}>{{ l }}</option>{% endfor %}</select></div>
|
||||
<div><label>Type (als de kolom ontbreekt)</label>
|
||||
<select name="relation_type"><option value="">—</option>
|
||||
{% for k, l in RELATION_KINDS %}<optgroup label="{{ l }}">{% for t in relation_types[k] %}<option>{{ t }}</option>{% endfor %}</optgroup>{% endfor %}
|
||||
</select></div>
|
||||
{% endif %}
|
||||
{% if kind == 'products' %}
|
||||
<div class="full"><label><input type="checkbox" name="deactivate_missing" value="1">Producten met een SKU die niet in dit bestand staan op inactief zetten (volledige prijslijst)</label>
|
||||
<p class="hint">Alleen als er een kolom aan SKU gekoppeld is. Inactieve producten blijven bestaan, met hun mutaties en offertes.</p></div>
|
||||
{% endif %}
|
||||
<div class="full"><label><input type="checkbox" name="update" value="1" checked>Bestaande {{ 'contacten (zelfde e-mail) aanvullen' if kind == 'contacts' else 'relaties (zelfde naam of KvK) aanvullen' if kind == 'relations' else 'producten bijwerken (zelfde SKU, of zelfde naam als er geen SKU is); alleen gekoppelde kolommen worden gewijzigd' }}; uit = overslaan</label></div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Inbox{% endblock %}
|
||||
{% block content %}
|
||||
<div class="head">
|
||||
<div><div class="kicker">E-mail</div><h1>Inbox</h1></div>
|
||||
<div class="actions">
|
||||
{% if last_sync %}<span class="muted">Laatst opgehaald: {{ last_sync|dt }}</span>{% endif %}
|
||||
<form method="post" action="{{ url_for('inbox_sync') }}" class="inline"><button class="btn">Nieuwe e-mail ophalen</button></form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not configured %}
|
||||
<div class="flash">Koppel eerst je mailbox bij <a href="{{ url_for('settings') }}#mail">Instellingen</a> (IMAP voor ontvangen, SMTP voor versturen).</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="filters">
|
||||
<a href="{{ url_for('inbox') }}" class="{{ 'on' if f == 'all' }}">Alle</a>
|
||||
<a href="{{ url_for('inbox', f='unread') }}" class="{{ 'on' if f == 'unread' }}">Ongelezen</a>
|
||||
<a href="{{ url_for('inbox', f='unlinked') }}" class="{{ 'on' if f == 'unlinked' }}">Niet gekoppeld aan contact</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
{% if rows %}
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Van</th><th>Onderwerp</th><th>Contact</th><th class="num">Datum</th></tr></thead>
|
||||
<tbody>
|
||||
{% for e in rows %}
|
||||
<tr class="link {{ 'unread' if not e.is_read }}" onclick="location='{{ url_for('inbox_view', eid=e.id) }}'">
|
||||
<td>{% if not e.is_read %}<span class="dot" aria-label="Ongelezen"></span>{% endif %}{{ e.from_name or e.from_addr }}</td>
|
||||
<td><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td>
|
||||
<td>{{ e.contact_name or '' }}</td>
|
||||
<td class="muted num">{{ e.date|dt }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% else %}<p class="empty">Geen berichten.</p>{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,74 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Klantportaal{% endblock %}
|
||||
{% block body %}
|
||||
<script>document.body.classList.add('portal')</script>
|
||||
<header class="portal-top">
|
||||
<div class="wrap">
|
||||
<img src="{{ url_for('static', filename='logo.svg') }}" alt="{{ s.company_name or 'Van Emous' }}">
|
||||
<a href="{{ url_for('portal_logout') }}">Uitloggen</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="portal-main">
|
||||
{% with msgs = get_flashed_messages(with_categories=true) %}
|
||||
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
|
||||
{% endwith %}
|
||||
<div class="head"><div><div class="kicker">Klantportaal</div><h1>Welkom, {{ c.name }}</h1></div></div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Uw offertes</h2>
|
||||
{% if quotes %}
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Nummer</th><th>Onderwerp</th><th>Datum</th><th>Status</th><th class="num">Totaal incl. btw</th></tr></thead>
|
||||
<tbody>
|
||||
{% for qt in quotes %}
|
||||
<tr class="link" onclick="location='{{ url_for('portal_quote', qid=qt.id) }}'">
|
||||
<td><a href="{{ url_for('portal_quote', qid=qt.id) }}">{{ qt.number }}</a></td>
|
||||
<td>{{ qt.title }}</td>
|
||||
<td>{{ qt.date|dt('%d-%m-%Y') }}</td>
|
||||
<td><span class="chip {{ qt.status }}">{{ 'Wacht op uw reactie' if qt.status == 'verzonden' else QSTATUS.get(qt.status) }}</span></td>
|
||||
<td class="num">{{ qt.total|euro }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% else %}<p class="empty">Er staan nog geen offertes voor u klaar.</p>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>Bestanden</h2>
|
||||
{% if files %}
|
||||
<div class="files">
|
||||
{% for f in files %}
|
||||
<div class="file">
|
||||
<a href="{{ url_for('portal_file', name=f.stored) }}" target="_blank" class="thumb">
|
||||
{% if f.mime.startswith('image/') %}<img src="{{ url_for('portal_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>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('portal_upload') }}" enctype="multipart/form-data">
|
||||
<label for="files">Foto's of documenten delen (bijv. van uw tuin of tekeningen)</label>
|
||||
<input id="files" type="file" name="files" multiple required>
|
||||
<div class="actions"><button class="btn ghost small">Uploaden</button></div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Bericht sturen</h2>
|
||||
<form method="post" action="{{ url_for('portal_message') }}">
|
||||
<textarea name="body" required aria-label="Bericht" placeholder="Uw vraag of opmerking"></textarea>
|
||||
<div class="actions"><button class="btn">Versturen</button></div>
|
||||
</form>
|
||||
{% for m in messages %}
|
||||
<p class="muted" style="margin:.75rem 0 0">{{ m.created_at|dt }}<br><span style="color:var(--ink);white-space:pre-line">{{ m.body[8:] }}</span></p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% if s.company_phone or s.company_email %}
|
||||
<p class="muted">Vragen? Neem contact op: {{ [s.company_phone, s.company_email]|select|join(' · ') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ p.name or 'Nieuw product' }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="head">
|
||||
<div><a class="label" href="{{ url_for('products') }}">← Producten</a><h1>{{ 'Product bewerken' if p.id else 'Nieuw product' }}</h1></div>
|
||||
</div>
|
||||
<form method="post" enctype="multipart/form-data" class="card">
|
||||
<div class="fields">
|
||||
<div class="full"><label for="name">Naam *</label><input id="name" name="name" value="{{ p.name }}" required></div>
|
||||
<div><label for="sku">SKU / artikelnummer</label><input id="sku" name="sku" value="{{ p.sku }}">
|
||||
<p class="hint">Vaste sleutel: imports en de API werken het product met deze SKU bij.</p></div>
|
||||
<div><label for="supplier">Leverancier</label><input id="supplier" name="supplier" value="{{ p.supplier }}" list="suppliers" autocomplete="off">
|
||||
<datalist id="suppliers">{% for r in suppliers %}<option value="{{ r.name }}">{% endfor %}</datalist>
|
||||
<p class="hint">{% if p.supplier_id %}<a href="{{ url_for('relation_view', rid=p.supplier_id) }}">Naar partner</a> · {% endif %}Een nieuwe naam wordt een partner van het type Leverancier.</p></div>
|
||||
<div><label for="unit">Eenheid</label><input id="unit" name="unit" value="{{ p.unit }}" placeholder="stuk, m², uur"></div>
|
||||
<div><label for="price">Prijs excl. btw (€)</label><input id="price" type="number" step="0.01" name="price" value="{{ p.price }}" required></div>
|
||||
<div><label for="vat">Btw %</label>
|
||||
<select id="vat" name="vat">
|
||||
{% for v in [21, 9, 0] %}<option value="{{ v }}" {{ 'selected' if p.vat == v }}>{{ v }}%</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="full"><label for="description">Omschrijving</label><textarea id="description" name="description">{{ p.description }}</textarea>
|
||||
<p class="hint">Wordt standaard onder de productnaam op de offerte gezet.</p></div>
|
||||
<div class="full">
|
||||
<label for="image">Afbeelding</label>
|
||||
{% if p.image %}<img src="{{ url_for('upload_file', name=p.image) }}" alt="" style="max-width:220px;border-radius:var(--radius-md);display:block;margin-bottom:.75rem">{% endif %}
|
||||
<input id="image" type="file" name="image" accept="image/*">
|
||||
</div>
|
||||
<div class="full"><label><input type="checkbox" name="active" value="1" {{ 'checked' if p.active }}>Actief (beschikbaar in offertes)</label></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn">Opslaan</button>
|
||||
<a class="btn quiet" href="{{ url_for('products') }}">Annuleren</a>
|
||||
</div>
|
||||
</form>
|
||||
{% if p.id %}
|
||||
<div class="card">
|
||||
<h2>Mutaties</h2>
|
||||
<p class="hint" style="margin-top:-.4rem">Bestaande offertes houden hun eigen prijs; wijzigingen gelden voor nieuwe offerteregels.{% if used %} Gebruikt in {{ used }} offerte(s).{% endif %}</p>
|
||||
{% if history %}
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Datum</th><th>Veld</th><th>Oud</th><th>Nieuw</th><th>Bron</th></tr></thead>
|
||||
<tbody>
|
||||
{% for h in history %}
|
||||
<tr>
|
||||
<td class="muted">{{ h.created_at|dt }}</td>
|
||||
<td>{{ LABELS.get(h.field, h.field) }}</td>
|
||||
{% if h.field == 'price' %}<td class="num">{{ h.old_value|float|euro }}</td><td class="num">{{ h.new_value|float|euro }}
|
||||
{% set diff = h.new_value|float - h.old_value|float %}{% if h.old_value|float %}<span class="muted">({{ '+' if diff > 0 }}{{ (diff / h.old_value|float * 100)|round(1)|nr }}%)</span>{% endif %}</td>
|
||||
{% elif h.field == 'created' %}<td></td><td class="num">{{ h.new_value|float|euro }}</td>
|
||||
{% elif h.field == 'active' %}<td>{{ 'ja' if h.old_value == '1' else 'nee' }}</td><td>{{ 'ja' if h.new_value == '1' else 'nee' }}</td>
|
||||
{% else %}<td>{{ h.old_value|truncate(60) }}</td><td>{{ h.new_value|truncate(60) }}</td>{% endif %}
|
||||
<td class="muted">{{ h.source }}{% if h.user_name %} · {{ h.user_name }}{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% else %}<p class="empty">Nog geen mutaties.</p>{% endif %}
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('product_delete', pid=p.id) }}" onsubmit="return confirm('{{ 'Dit product staat in offertes en wordt op inactief gezet.' if used else 'Product verwijderen?' }}')">
|
||||
<button class="btn quiet small">{{ 'Archiveren' if used else 'Product verwijderen' }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Producten{% endblock %}
|
||||
{% block content %}
|
||||
<div class="head">
|
||||
<div><div class="kicker">Assortiment</div><h1>Producten</h1></div>
|
||||
<div class="actions">
|
||||
<form class="search" method="get">
|
||||
<input type="hidden" name="show" value="{{ show }}">
|
||||
<input type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, SKU, leverancier…" aria-label="Zoeken">
|
||||
</form>
|
||||
<a class="btn ghost" href="{{ url_for('products_export') }}">Exporteer CSV</a>
|
||||
<a class="btn ghost" href="{{ url_for('import_start', kind='products') }}">Importeren</a>
|
||||
<a class="btn" href="{{ url_for('product_edit') }}">Nieuw product</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filters">
|
||||
{% for k, l in [('active', 'Actief'), ('inactive', 'Inactief'), ('all', 'Alle')] %}
|
||||
<a href="{{ url_for('products', show=k, q=term or None) }}" class="{{ 'on' if show == k }}">{{ l }}</a>
|
||||
{% endfor %}
|
||||
<span class="muted">{{ rows|length }} product(en)</span>
|
||||
</div>
|
||||
<div class="card">
|
||||
{% if rows %}
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th></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) }}'">
|
||||
<td 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><a href="{{ url_for('product_edit', pid=p.id) }}">{{ p.name }}</a><div class="muted">{{ p.description|truncate(90) }}</div></td>
|
||||
<td><code>{{ p.sku }}</code></td>
|
||||
<td>{% if p.supplier_id %}<a href="{{ url_for('relation_view', rid=p.supplier_id) }}" onclick="event.stopPropagation()">{{ p.supplier }}</a>{% else %}{{ p.supplier }}{% endif %}</td>
|
||||
<td>{{ p.unit }}</td>
|
||||
<td class="num">{{ p.price|euro }}</td>
|
||||
<td class="num">{{ p.vat|nr }}%</td>
|
||||
<td class="muted">{{ (p.updated_at or p.created_at)|dt('%d-%m-%Y') }}</td>
|
||||
<td>{% if not p.active %}<span class="chip verloren">Inactief</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</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>
|
||||
{% endblock %}
|
||||
@@ -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 %} {{ 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 %}
|
||||
@@ -0,0 +1,129 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Offerte {{ qt.number }}{% if c %} · {{ c.name }}{% endif %}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Kantumruy+Pro:wght@500;600&family=Mukta:wght@400;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
@page { size: A4; margin: 16mm 15mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: "Mukta", system-ui, sans-serif; color: #212121; font-size: 14px; line-height: 1.55; background: #f0f1ec; font-variant-numeric: lining-nums tabular-nums; }
|
||||
.bar { position: sticky; top: 0; z-index: 1; background: #fff; border-bottom: 1px solid rgba(0,0,0,.08); padding: 12px 16px; display: flex; gap: 12px; justify-content: center; align-items: center; flex-wrap: wrap; }
|
||||
.bar a { color: #b95913; font-weight: 600; text-decoration: none; }
|
||||
.btn { font: 600 15px/1 "Mukta", sans-serif; padding: 12px 22px; border-radius: 50px; border: 1px solid #b95913; background: #b95913; color: #fff; cursor: pointer; }
|
||||
.btn.ghost { background: #fff; color: #b95913; border-color: rgba(185,89,19,.35); }
|
||||
.page { background: #fff; max-width: 210mm; margin: 24px auto; padding: 16mm 15mm; border-radius: 20px; }
|
||||
header { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; }
|
||||
header img { width: 210px; max-width: 45%; }
|
||||
.company { text-align: right; color: #5c6670; font-size: 13px; }
|
||||
.company strong { color: #212121; }
|
||||
.meta { display: flex; justify-content: space-between; gap: 24px; margin: 40px 0 28px; }
|
||||
.label { font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: #5c6670; }
|
||||
.kv td { padding: 1px 0 1px 16px; }
|
||||
.kv td:first-child { padding-left: 0; }
|
||||
h1 { font-family: "Kantumruy Pro", sans-serif; font-weight: 500; font-size: 26px; line-height: 1.15; margin: 0 0 12px; }
|
||||
h1::before { content: ""; display: block; width: 3rem; height: 3px; background: #b95913; margin-bottom: 14px; }
|
||||
.intro, .notes { white-space: pre-line; }
|
||||
table.lines { width: 100%; border-collapse: collapse; margin: 24px 0 8px; }
|
||||
.lines th { text-align: left; font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: #5c6670; padding: 8px; border-bottom: 1px solid rgba(0,0,0,.15); }
|
||||
.lines td { padding: 10px 8px; border-bottom: 1px solid rgba(0,0,0,.08); vertical-align: top; }
|
||||
.lines tr { break-inside: avoid; }
|
||||
.desc { white-space: pre-line; }
|
||||
.desc::first-line { font-weight: 600; }
|
||||
.num { text-align: right; white-space: nowrap; }
|
||||
.totals { margin-left: auto; width: 300px; border-collapse: collapse; }
|
||||
.totals td { padding: 4px 8px; }
|
||||
.totals .grand td { font-family: "Kantumruy Pro", sans-serif; font-weight: 600; font-size: 18px; border-top: 2px solid #212121; padding-top: 8px; }
|
||||
.notes { margin-top: 32px; }
|
||||
.sign { margin-top: 40px; display: flex; gap: 48px; }
|
||||
.sign div { flex: 1; border-top: 1px solid rgba(0,0,0,.3); padding-top: 6px; color: #5c6670; font-size: 12px; }
|
||||
footer { margin-top: 40px; padding-top: 12px; border-top: 1px solid rgba(0,0,0,.08); color: #5c6670; font-size: 12px; text-align: center; }
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
.bar { display: none; }
|
||||
.page { margin: 0; padding: 0; max-width: none; border-radius: 0; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.page { padding: 20px 16px; margin: 0; border-radius: 0; }
|
||||
header, .meta { flex-direction: column; }
|
||||
.company { text-align: left; }
|
||||
.totals { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bar">
|
||||
{% if portal %}
|
||||
<a href="{{ url_for('portal_home') }}">← Terug naar het portaal</a>
|
||||
{% if qt.status == 'verzonden' %}
|
||||
<form method="post" action="{{ url_for('portal_quote_answer', qid=qt.id, choice='accept') }}" onsubmit="return confirm('Wilt u deze offerte accepteren?')"><button class="btn">Offerte accepteren</button></form>
|
||||
<form method="post" action="{{ url_for('portal_quote_answer', qid=qt.id, choice='decline') }}"><button class="btn ghost">Afwijzen</button></form>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<a href="{{ url_for('quote_edit', qid=qt.id) }}">← Terug naar bewerken</a>
|
||||
{% endif %}
|
||||
<button class="btn {{ 'ghost' if portal and qt.status == 'verzonden' }}" onclick="window.print()">Afdrukken / opslaan als PDF</button>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<img src="{{ url_for('static', filename='logo.svg') }}" alt="{{ s.company_name }}">
|
||||
<div class="company">
|
||||
<strong>{{ s.company_name }}</strong><br>
|
||||
{{ [s.company_address, s.company_postcode_city, s.company_phone, s.company_email, s.company_website]|select|join('<br>'|safe) }}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="meta">
|
||||
<div>
|
||||
<div class="label">Offerte voor</div>
|
||||
{% if c %}
|
||||
<strong>{{ c.name }}</strong><br>
|
||||
{{ [c.relation_name if c.relation_kind == 'klant' and c.relation_name != c.name, c.company, c.address, (c.postcode ~ ' ' ~ c.city)|trim]|select|join('<br>'|safe) }}
|
||||
{% else %}—{% endif %}
|
||||
</div>
|
||||
<table class="kv">
|
||||
<tr><td class="label">Offertenummer</td><td>{{ qt.number }}</td></tr>
|
||||
<tr><td class="label">Datum</td><td>{{ qt.date|dt('%d-%m-%Y') }}</td></tr>
|
||||
{% if qt.valid_until %}<tr><td class="label">Geldig tot</td><td>{{ qt.valid_until|dt('%d-%m-%Y') }}</td></tr>{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h1>Offerte{% if qt.title %}: {{ qt.title }}{% endif %}</h1>
|
||||
{% if qt.intro %}<div class="intro">{{ qt.intro }}</div>{% endif %}
|
||||
|
||||
<table class="lines">
|
||||
<thead><tr><th>Omschrijving</th><th class="num">Aantal</th><th class="num">Prijs</th><th class="num">Btw</th><th class="num">Totaal</th></tr></thead>
|
||||
<tbody>
|
||||
{% for l in lines %}
|
||||
<tr>
|
||||
<td class="desc">{{ l.description }}</td>
|
||||
<td class="num">{{ l.qty|nr }} {{ l.unit }}</td>
|
||||
<td class="num">{{ l.price|euro }}</td>
|
||||
<td class="num">{{ l.vat|nr }}%</td>
|
||||
<td class="num">{{ (l.qty * l.price)|euro }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table class="totals">
|
||||
<tr><td>Subtotaal excl. btw</td><td class="num">{{ t.subtotal|euro }}</td></tr>
|
||||
{% for rate, amount in t.vat %}<tr><td>Btw {{ rate|nr }}%</td><td class="num">{{ amount|euro }}</td></tr>{% endfor %}
|
||||
<tr class="grand"><td>Totaal</td><td class="num">{{ t.total|euro }}</td></tr>
|
||||
</table>
|
||||
|
||||
{% if qt.notes %}<div class="notes"><div class="label">Opmerkingen en voorwaarden</div>{{ qt.notes }}</div>{% endif %}
|
||||
|
||||
<div class="sign"><div>Datum en handtekening opdrachtgever</div><div>Namens {{ s.company_name }}</div></div>
|
||||
|
||||
<footer>
|
||||
{% if s.quote_footer %}<div style="white-space:pre-line;margin-bottom:6px">{{ s.quote_footer }}</div>{% endif %}
|
||||
{{ [('KvK ' ~ s.company_kvk) if s.company_kvk, ('Btw ' ~ s.company_btw) if s.company_btw, ('IBAN ' ~ s.company_iban) if s.company_iban]|select|join(' · ') }}
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Offertes{% endblock %}
|
||||
{% block content %}
|
||||
<div class="head">
|
||||
<div><div class="kicker">Verkoop</div><h1>Offertes</h1></div>
|
||||
<form method="post" action="{{ url_for('quote_new') }}" class="inline"><button class="btn">Nieuwe offerte</button></form>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<a href="{{ url_for('quotes') }}" class="{{ 'on' if not status }}">Alle</a>
|
||||
{% for k, l in QUOTE_STATUSES %}<a href="{{ url_for('quotes', status=k) }}" class="{{ 'on' if status == k }}">{{ l }}</a>{% endfor %}
|
||||
</div>
|
||||
<div class="card">
|
||||
{% if rows %}
|
||||
<div class="table-wrap"><table>
|
||||
<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>
|
||||
<tbody>
|
||||
{% for qt in rows %}
|
||||
<tr class="link" onclick="location='{{ url_for('quote_edit', qid=qt.id) }}'">
|
||||
<td><a href="{{ url_for('quote_edit', qid=qt.id) }}">{{ qt.number }}</a></td>
|
||||
<td>{{ qt.contact_name or '—' }}</td>
|
||||
<td>{{ qt.title }}</td>
|
||||
<td>{{ qt.date|dt('%d-%m-%Y') }}</td>
|
||||
<td><span class="chip {{ qt.status }}">{{ QSTATUS.get(qt.status, qt.status) }}</span></td>
|
||||
<td class="num">{{ qt.total|euro }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% else %}<p class="empty">Nog geen offertes.</p>{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,133 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ r.name }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="head">
|
||||
<div>
|
||||
<a class="label" href="{{ url_for('relations', kind=r.kind) }}">← {{ 'Klanten' if r.kind == 'klant' else 'Partners' }}</a>
|
||||
<h1>{{ r.name }}</h1>
|
||||
<div class="muted"><span class="chip">{{ RKIND.get(r.kind) }}</span> {{ [r.type, r.city]|select|join(' · ') }}</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
{% if r.kind == 'klant' and members %}
|
||||
<form method="post" action="{{ url_for('quote_new') }}" class="inline">
|
||||
<input type="hidden" name="contact_id" value="{{ members[0].id }}">
|
||||
<button class="btn ghost">Nieuwe offerte</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a class="btn" href="{{ url_for('relation_edit', rid=r.id) }}">Bewerken</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="two">
|
||||
<div>
|
||||
<div class="card">
|
||||
<h2>Contactpersonen</h2>
|
||||
<div class="members">
|
||||
{% for m in members %}
|
||||
<div class="member">
|
||||
<div>
|
||||
<a href="{{ url_for('contact_view', cid=m.id) }}">{{ m.name }}</a>
|
||||
{% if m.relation_role %}<span class="muted">· {{ m.relation_role }}</span>{% endif %}
|
||||
<div class="muted">{{ [m.email, m.phone]|select|join(' · ') }}</div>
|
||||
</div>
|
||||
<div class="actions" style="margin:0">
|
||||
{% if m.type != 'partner' %}<span class="chip {{ m.status }}">{{ STATUS.get(m.status) }}</span>{% endif %}
|
||||
<form method="post" action="{{ url_for('relation_member_remove', rid=r.id, cid=m.id) }}" class="inline" onsubmit="return confirm('{{ m.name }} loskoppelen van {{ r.name }}?')">
|
||||
<button class="linkbtn">Loskoppelen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty">Nog geen contactpersonen.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('relation_member', rid=r.id) }}" class="actions">
|
||||
{% if candidates %}
|
||||
<select name="contact_id" style="width:auto;flex:1;min-width:180px" aria-label="Bestaand contact" required>
|
||||
<option value="">Bestaand contact toevoegen…</option>
|
||||
{% for c in candidates %}<option value="{{ c.id }}">{{ c.name }}{% if c.email %} ({{ c.email }}){% endif %}</option>{% endfor %}
|
||||
</select>
|
||||
<input name="relation_role" placeholder="Rol" style="width:160px" aria-label="Rol">
|
||||
<button class="btn ghost small">Toevoegen</button>
|
||||
{% endif %}
|
||||
<a class="btn small" href="{{ url_for('contact_new', relation_id=r.id, company=r.name if r.kind == 'partner' else None) }}">Nieuwe contactpersoon</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if r.kind == 'partner' %}
|
||||
<div class="card">
|
||||
<h2>Producten van deze leverancier</h2>
|
||||
{% if products %}
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>SKU</th><th>Naam</th><th class="num">Prijs excl.</th><th>Bijgewerkt</th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in products %}
|
||||
<tr class="link" onclick="location='{{ url_for('product_edit', pid=p.id) }}'">
|
||||
<td><code>{{ p.sku }}</code></td>
|
||||
<td><a href="{{ url_for('product_edit', pid=p.id) }}">{{ p.name }}</a>{% if not p.active %} <span class="chip verloren">Inactief</span>{% endif %}</td>
|
||||
<td class="num">{{ p.price|euro }}</td>
|
||||
<td class="muted">{{ (p.updated_at or p.created_at)|dt('%d-%m-%Y') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% else %}<p class="empty">Nog geen producten gekoppeld. Vul deze naam in als leverancier bij een product of in een import.</p>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<h2>Recente activiteit</h2>
|
||||
<ul class="timeline">
|
||||
{% for a in activities %}
|
||||
<li>
|
||||
<div class="meta">{{ ACT.get(a.type, a.type) }} · <a href="{{ url_for('contact_view', cid=a.contact_id) }}">{{ a.contact_name }}</a> · {{ 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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="card">
|
||||
<h2>Gegevens</h2>
|
||||
<dl class="props">
|
||||
{% if r.type %}<dt>Type</dt><dd>{{ r.type }}</dd>{% endif %}
|
||||
{% if r.email %}<dt>E-mail</dt><dd><a href="mailto:{{ r.email }}">{{ r.email }}</a></dd>{% endif %}
|
||||
{% if r.phone %}<dt>Telefoon</dt><dd><a href="tel:{{ r.phone }}">{{ r.phone }}</a></dd>{% endif %}
|
||||
{% if r.address or r.city %}<dt>Adres</dt><dd>{{ [r.address, (r.postcode ~ ' ' ~ r.city)|trim]|select|join('\n') }}</dd>{% endif %}
|
||||
{% if r.website %}<dt>Website</dt><dd><a href="{{ r.website if r.website.startswith('http') else 'https://' ~ r.website }}" target="_blank" rel="noopener">{{ r.website }}</a></dd>{% endif %}
|
||||
{% if r.kvk %}<dt>KvK</dt><dd>{{ r.kvk }}</dd>{% endif %}
|
||||
<dt>Aangemaakt</dt><dd>{{ r.created_at|dt('%d-%m-%Y') }}</dd>
|
||||
</dl>
|
||||
{% if r.notes %}<h3>Notities</h3><p style="white-space:pre-line;margin:0">{{ r.notes }}</p>{% endif %}
|
||||
</div>
|
||||
|
||||
{% if tasks %}
|
||||
<div class="card">
|
||||
<h2>Open taken</h2>
|
||||
{% for t in tasks %}
|
||||
<form method="post" action="{{ url_for('task_done', tid=t.id) }}" class="task">
|
||||
<button class="check" aria-label="Afronden"></button>
|
||||
<div><div>{{ t.title }}</div><div class="muted">{{ t.contact_name }}{% if t.due_date %} · <span class="{{ 'overdue' if t.due_date < today }}">{{ t.due_date|dt('%d-%m-%Y') }}</span>{% endif %}</div></div>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if r.kind == 'klant' %}
|
||||
<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.contact_name }} · {{ qt.date|dt('%d-%m-%Y') }} · {{ qt.total|euro }}</span></p>
|
||||
{% else %}
|
||||
<p class="empty">Nog geen offertes.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ r.name or ('Nieuwe ' ~ RKIND.get(r.kind, 'relatie')|lower) }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="head">
|
||||
<div>
|
||||
<a class="label" href="{{ url_for('relation_view', rid=r.id) if r.id else url_for('relations', kind=r.kind) }}">← Terug</a>
|
||||
<h1>{{ 'Relatie bewerken' if r.id else ('Nieuwe ' ~ RKIND.get(r.kind, 'relatie')|lower) }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" class="card">
|
||||
<div class="fields">
|
||||
<div class="full"><label for="name">Naam *</label><input id="name" name="name" value="{{ r.name }}" required placeholder="{{ 'Bijv. Familie Jansen' if r.kind == 'klant' else 'Bijv. Houthandel De Vries' }}"></div>
|
||||
<div><label for="kind">Soort</label>
|
||||
<select id="kind" name="kind">{% for k, l in RELATION_KINDS %}<option value="{{ k }}" {{ 'selected' if r.kind == k }}>{{ l }}</option>{% endfor %}</select>
|
||||
</div>
|
||||
<div><label for="type">Type</label>
|
||||
<input id="type" name="type" value="{{ r.type }}" list="types" autocomplete="off">
|
||||
<datalist id="types">{% for t in types %}<option value="{{ t }}">{% endfor %}</datalist>
|
||||
</div>
|
||||
<div><label for="email">E-mail</label><input id="email" type="email" name="email" value="{{ r.email }}"></div>
|
||||
<div><label for="phone">Telefoon</label><input id="phone" name="phone" value="{{ r.phone }}"></div>
|
||||
<div class="full"><label for="address">Adres</label><input id="address" name="address" value="{{ r.address }}"></div>
|
||||
<div><label for="postcode">Postcode</label><input id="postcode" name="postcode" value="{{ r.postcode }}"></div>
|
||||
<div><label for="city">Plaats</label><input id="city" name="city" value="{{ r.city }}"></div>
|
||||
<div><label for="website">Website</label><input id="website" name="website" value="{{ r.website }}"></div>
|
||||
<div><label for="kvk">KvK-nummer</label><input id="kvk" name="kvk" value="{{ r.kvk }}"></div>
|
||||
<div class="full"><label for="notes">Notities</label><textarea id="notes" name="notes">{{ r.notes }}</textarea></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn">Opslaan</button>
|
||||
<a class="btn quiet" href="{{ url_for('relation_view', rid=r.id) if r.id else url_for('relations', kind=r.kind) }}">Annuleren</a>
|
||||
</div>
|
||||
</form>
|
||||
{% if r.id %}
|
||||
<form method="post" action="{{ url_for('relation_delete', rid=r.id) }}" onsubmit="return confirm('Relatie verwijderen? Contactpersonen en producten blijven bestaan.')">
|
||||
<button class="btn quiet small">Relatie verwijderen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,47 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ 'Klanten' if kind == 'klant' else 'Partners' }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="head">
|
||||
<div>
|
||||
<div class="kicker">Relaties</div>
|
||||
<h1>{{ 'Klanten' if kind == 'klant' else 'Partners' }}</h1>
|
||||
<div class="muted">{{ 'Huishoudens, families en bedrijven met hun contactpersonen.' if kind == 'klant' else 'Leveranciers, aannemers en andere partners.' }}</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<form class="search" method="get">
|
||||
<input type="hidden" name="kind" value="{{ kind }}">
|
||||
{% if type %}<input type="hidden" name="type" value="{{ type }}">{% endif %}
|
||||
<input type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, plaats, persoon…" aria-label="Zoeken">
|
||||
</form>
|
||||
<a class="btn ghost" href="{{ url_for('import_start', kind='relations') }}">Importeren</a>
|
||||
<a class="btn" href="{{ url_for('relation_edit', kind=kind, type=type or None) }}">{{ 'Nieuwe klant' if kind == 'klant' else 'Nieuwe partner' }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<a href="{{ url_for('relations', kind=kind, q=term or None) }}" class="{{ 'on' if not type }}">Alle</a>
|
||||
{% for t in types %}
|
||||
<a href="{{ url_for('relations', kind=kind, type=t, q=term or None) }}" class="{{ 'on' if type == t }}">{{ t }}{% if counts.get(t) %} <span class="muted">{{ counts[t] }}</span>{% endif %}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
{% if rows %}
|
||||
<div class="table-wrap"><table>
|
||||
<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>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr class="link" onclick="location='{{ url_for('relation_view', rid=r.id) }}'">
|
||||
<td><a href="{{ url_for('relation_view', rid=r.id) }}">{{ r.name }}</a></td>
|
||||
<td>{% if r.type %}<span class="chip">{{ r.type }}</span>{% endif %}</td>
|
||||
<td>{{ r.member_names or '—' }}{% if r.members > 4 %} <span class="muted">+{{ r.members - 4 }}</span>{% endif %}</td>
|
||||
<td>{{ r.city }}</td>
|
||||
<td class="muted">{{ [r.phone, r.email]|select|join(' · ') }}</td>
|
||||
{% if kind == 'partner' %}<td class="num">{{ r.products or '' }}</td>{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</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>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,129 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Instellingen{% endblock %}
|
||||
{% macro field(key, label, type='text', placeholder='', full=false) %}
|
||||
<div class="{{ 'full' if full }}"><label for="{{ key }}">{{ label }}</label><input id="{{ key }}" type="{{ type }}" name="{{ key }}" value="{{ s.get(key, '') }}" placeholder="{{ placeholder }}"></div>
|
||||
{% endmacro %}
|
||||
{% block content %}
|
||||
<div class="head"><div><div class="kicker">Beheer</div><h1>Instellingen</h1></div></div>
|
||||
|
||||
<form method="post">
|
||||
<div class="card">
|
||||
<h2>Bedrijfsgegevens</h2>
|
||||
<p class="hint" style="margin-top:-.4rem;margin-bottom:1rem">Deze gegevens staan op de offertes.</p>
|
||||
<div class="fields">
|
||||
{{ field('company_name', 'Bedrijfsnaam', full=true) }}
|
||||
{{ field('company_address', 'Adres') }}
|
||||
{{ field('company_postcode_city', 'Postcode en plaats') }}
|
||||
{{ field('company_phone', 'Telefoon') }}
|
||||
{{ field('company_email', 'E-mail', 'email') }}
|
||||
{{ field('company_website', 'Website') }}
|
||||
{{ field('company_kvk', 'KvK-nummer') }}
|
||||
{{ field('company_btw', 'Btw-nummer') }}
|
||||
{{ field('company_iban', 'IBAN') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Offertes</h2>
|
||||
<div class="fields">
|
||||
{{ field('quote_prefix', 'Voorvoegsel nummer', placeholder='OFF') }}
|
||||
{{ field('quote_valid_days', 'Standaard geldig (dagen)', 'number') }}
|
||||
<div class="full"><label for="quote_intro">Standaard inleiding</label><textarea id="quote_intro" name="quote_intro">{{ s.quote_intro }}</textarea></div>
|
||||
<div class="full"><label for="quote_footer">Voettekst (bijv. betalingsvoorwaarden)</label><textarea id="quote_footer" name="quote_footer">{{ s.quote_footer }}</textarea></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="mail">
|
||||
<h2>E-mail koppelen</h2>
|
||||
<p class="hint" style="margin-top:-.4rem;margin-bottom:1rem">Gebruik voor Gmail of Microsoft 365 een app-wachtwoord. Wachtwoorden staan in de database: houd de map <code>data/</code> privé.</p>
|
||||
<h3>Ontvangen (IMAP)</h3>
|
||||
<div class="fields">
|
||||
{{ field('imap_host', 'Server', placeholder='imap.gmail.com') }}
|
||||
{{ field('imap_port', 'Poort', 'number', '993') }}
|
||||
{{ field('imap_user', 'Gebruikersnaam', placeholder='info@vanemous.nl') }}
|
||||
<div><label for="imap_pass">Wachtwoord</label><input id="imap_pass" type="password" name="imap_pass" placeholder="{{ '•••••••• (ingesteld)' if s.imap_pass else '' }}" autocomplete="new-password"></div>
|
||||
{{ field('imap_folder', 'Map', placeholder='INBOX') }}
|
||||
{{ field('imap_days', 'Aantal dagen terug ophalen', 'number', '30') }}
|
||||
</div>
|
||||
<h3>Versturen (SMTP)</h3>
|
||||
<div class="fields">
|
||||
{{ field('smtp_host', 'Server', placeholder='smtp.gmail.com') }}
|
||||
{{ field('smtp_port', 'Poort', 'number', '587') }}
|
||||
{{ field('smtp_user', 'Gebruikersnaam', placeholder='leeg = zelfde als IMAP') }}
|
||||
<div><label for="smtp_pass">Wachtwoord</label><input id="smtp_pass" type="password" name="smtp_pass" placeholder="{{ '•••••••• (ingesteld)' if s.smtp_pass else 'leeg = zelfde als IMAP' }}" autocomplete="new-password"></div>
|
||||
{{ field('smtp_from', 'Afzender', placeholder='Van Emous <info@vanemous.nl>', full=true) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Formulieren en API</h2>
|
||||
<div class="fields">
|
||||
{{ field('allowed_origins', 'Toegestane websites (komma-gescheiden)', placeholder='https://www.vanemous.nl', full=true) }}
|
||||
<div><label for="api_assign_user">Nieuwe leads toewijzen aan</label>
|
||||
<select id="api_assign_user" name="api_assign_user">
|
||||
<option value="">— Niemand —</option>
|
||||
{% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if s.api_assign_user == u.id|string }}>{{ u.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Relatietypes</h2>
|
||||
<p class="hint" style="margin-top:-.4rem;margin-bottom:1rem">Komma-gescheiden. Bestaande relaties houden hun type als je er een weghaalt.</p>
|
||||
<div class="fields">
|
||||
<div class="full"><label for="klant_types">Klanten (bundels)</label><input id="klant_types" name="klant_types" value="{{ s.klant_types or 'Huishouden / familie, Particulier, Zakelijk, Zorginstelling' }}"></div>
|
||||
<div class="full"><label for="partner_types">Partners</label><input id="partner_types" name="partner_types" value="{{ s.partner_types or 'Leverancier, Aannemer, Installateur, Architect / adviseur, Gemeente, Overig' }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions" style="margin-bottom:1.5rem"><button class="btn">Instellingen opslaan</button></div>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<h2>API-koppeling</h2>
|
||||
<dl class="props">
|
||||
<dt>Endpoint</dt><dd><code>POST {{ api_url }}</code></dd>
|
||||
<dt>API-sleutel</dt><dd><code>{{ s.api_key }}</code></dd>
|
||||
<dt>Klantportaal</dt><dd><code>{{ portal_url }}</code></dd>
|
||||
</dl>
|
||||
<p>Stuur de sleutel mee als header <code>X-API-Key</code> of als <code>?key=</code> in de URL. Herkende velden (ook in het Nederlands): naam, voornaam, achternaam, email, telefoon, bedrijf, adres, postcode, plaats, bericht, bron, waarde. Andere velden worden bewaard als extra gegevens. Bestanden (bijv. foto's) gaan mee als <code>multipart/form-data</code>. Een ontwerp uit de configurator (een object met <code>"type": "house-design"</code>) wordt als bestand bij de lead opgeslagen. Leads met een bekend e-mailadres worden bij het bestaande contact gezet.</p>
|
||||
|
||||
<h3>Gewoon HTML-formulier</h3>
|
||||
<pre>{% filter forceescape %}<form action="{{ api_url }}?key={{ s.api_key }}" method="post" enctype="multipart/form-data">
|
||||
<input name="naam" required>
|
||||
<input name="email" type="email">
|
||||
<input name="telefoon">
|
||||
<textarea name="bericht"></textarea>
|
||||
<input type="file" name="foto" multiple>
|
||||
<input type="hidden" name="bron" value="Contactformulier">
|
||||
<input type="hidden" name="redirect" value="https://www.vanemous.nl/bedankt">
|
||||
<input name="_gotcha" style="display:none" tabindex="-1" autocomplete="off">
|
||||
<button>Versturen</button>
|
||||
</form>{% endfilter %}</pre>
|
||||
|
||||
<h3>JavaScript (fetch)</h3>
|
||||
<pre>{% filter forceescape %}const res = await fetch('{{ api_url }}', {
|
||||
method: 'POST',
|
||||
headers: { 'X-API-Key': '{{ s.api_key }}' },
|
||||
body: new FormData(document.querySelector('form'))
|
||||
});
|
||||
const { ok, id } = await res.json();{% endfilter %}</pre>
|
||||
|
||||
<h3>Vanaf een server (aanbevolen: de sleutel blijft geheim)</h3>
|
||||
<pre>{% filter forceescape %}curl -X POST {{ api_url }} \
|
||||
-H 'X-API-Key: {{ s.api_key }}' -H 'Content-Type: application/json' \
|
||||
-d '{"naam": "Jan Jansen", "email": "jan@example.nl", "bericht": "Graag info", "model": "Amstel"}'{% endfilter %}</pre>
|
||||
|
||||
<h3>Producten bijwerken (prijsmutaties)</h3>
|
||||
<p>Werkt producten bij op SKU, bijvoorbeeld vanuit een prijsscript van een leverancier. Veldnamen zoals bij de import. <code>?deactivate_missing=1</code> zet producten die niet in de lijst staan op inactief.</p>
|
||||
<pre>{% filter forceescape %}curl -X POST '{{ url_for('api_products', _external=True) }}?source=Leverancier%20X' \
|
||||
-H 'X-API-Key: {{ s.api_key }}' -H 'Content-Type: application/json' \
|
||||
-d '[{"sku": "plaat.osbM2", "naam": "OSB", "prijs": "12,50", "eenheid": "m²", "leverancier": "Houthandel Y"}]'{% endfilter %}</pre>
|
||||
|
||||
<form method="post" onsubmit="return confirm('Nieuwe sleutel maken? Bestaande formulieren werken dan niet meer.')">
|
||||
<input type="hidden" name="action" value="apikey">
|
||||
<button class="btn quiet small">Nieuwe API-sleutel maken</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ u.name or 'Nieuwe gebruiker' }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="head">
|
||||
<div><a class="label" href="{{ url_for('users') }}">← Gebruikers</a><h1>{{ 'Gebruiker bewerken' if u.id else 'Nieuwe gebruiker' }}</h1></div>
|
||||
</div>
|
||||
<form method="post" class="card">
|
||||
<div class="fields">
|
||||
<div><label for="name">Naam *</label><input id="name" name="name" value="{{ u.name }}" required></div>
|
||||
<div><label for="email">E-mailadres (inlognaam) *</label><input id="email" type="email" name="email" value="{{ u.email }}" required></div>
|
||||
<div><label for="role">Rol</label>
|
||||
<select id="role" name="role">{% for k, l in ROLES %}<option value="{{ k }}" {{ 'selected' if u.role == k }}>{{ l }}</option>{% endfor %}</select>
|
||||
</div>
|
||||
<div><label for="password">{{ 'Nieuw wachtwoord' if u.id else 'Wachtwoord *' }}</label>
|
||||
<input id="password" type="password" name="password" minlength="8" {{ 'required' if not u.id }} autocomplete="new-password">
|
||||
{% if u.id %}<p class="hint">Leeg laten om het huidige wachtwoord te houden.</p>{% endif %}</div>
|
||||
<div class="full"><label><input type="checkbox" name="active" value="1" {{ 'checked' if u.active }}>Actief (mag inloggen)</label></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn">Opslaan</button>
|
||||
<a class="btn quiet" href="{{ url_for('users') }}">Annuleren</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Gebruikers{% endblock %}
|
||||
{% block content %}
|
||||
<div class="head">
|
||||
<div><div class="kicker">Team</div><h1>Gebruikers</h1></div>
|
||||
<a class="btn" href="{{ url_for('user_edit') }}">Nieuwe gebruiker</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Naam</th><th>E-mail</th><th>Rol</th><th class="num">Contacten</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for u in rows %}
|
||||
<tr class="link" onclick="location='{{ url_for('user_edit', uid=u.id) }}'">
|
||||
<td><a href="{{ url_for('user_edit', uid=u.id) }}">{{ u.name }}</a></td>
|
||||
<td>{{ u.email }}</td>
|
||||
<td>{{ ROLE.get(u.role, u.role) }}</td>
|
||||
<td class="num">{{ u.n }}</td>
|
||||
<td>{% if not u.active %}<span class="chip verloren">Inactief</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
<p class="muted">Beheerders kunnen ook gebruikers en instellingen beheren. Klanten krijgen toegang via het klantportaal (op de pagina van het contact).</p>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user