v0.0.4: e-mailtemplates met velden ({voornaam}, {model}, {afzender}…), te kiezen bij nieuwe e-mail, antwoord en contact; inbox met nieuwe e-mail, doorsturen, CC en bijlagen, Verzonden, Archief, zoeken, als ongelezen markeren en alles gelezen; agenda per gebruiker met CalDAV (/dav/, app-wachtwoord in Mijn account); zoeken en Nieuw in de bovenbalk; e-mailinstellingen met voorinstellingen (OVHcloud, Gmail), beveiliging per server, kopie in Verzonden en verbindingstest; offerte in het klantportaal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-16 17:29:47 +02:00
co-authored by Claude Opus 5.5
parent 81fc27fa86
commit 366a3f9d17
26 changed files with 2095 additions and 271 deletions
+29
View File
@@ -0,0 +1,29 @@
{# E-mail opstellen (nieuw, beantwoorden, vanaf een contact). m = {to, cc, subject, body, contact_id, reply_to, next,
template}. Een template zet zijn tekst boven het bericht; velden als {voornaam} vult static/compose.js meteen in met
vars, wat overblijft vult mail_send bij het versturen in. #}
{% macro compose(m, templates, vars, id='m', datalist='', button='Versturen') %}
<form method="post" action="{{ url_for('mail_send') }}" enctype="multipart/form-data" class="compose" data-vars='{{ vars|tojson }}'>
{% if m.reply_to %}<input type="hidden" name="reply_to" value="{{ m.reply_to }}">{% endif %}
{% if m.contact_id %}<input type="hidden" name="contact_id" value="{{ m.contact_id }}">{% endif %}
{% if m.next %}<input type="hidden" name="next" value="{{ m.next }}">{% endif %}
<div class="row g-3">
{% if templates %}
<div class="col-12"><label class="form-label" for="{{ id }}-template">Template</label>
<select class="form-select" id="{{ id }}-template" name="template" autocomplete="off">
<option value="">Geen template</option>
{% for t in templates %}<option value="{{ t.id }}" data-subject="{{ t.subject }}" data-body="{{ t.body }}" {{ 'selected' if m.template == t.id }}>{{ t.name }}</option>{% endfor %}
</select>
<p class="hint">{% if vars.naam is defined %}Velden als {voornaam} worden ingevuld met de gegevens van {{ vars.naam or 'het contact' }}.{% else %}Velden als {voornaam} worden bij het versturen ingevuld met het contact van het adres in Aan.{% endif %}</p>
</div>
{% endif %}
<div class="col-md-6"><label class="form-label" for="{{ id }}-to">Aan</label><input class="form-control" id="{{ id }}-to" type="email" name="to" value="{{ m.to or '' }}" multiple required {% if datalist %}list="{{ datalist }}" autocomplete="off"{% endif %}></div>
<div class="col-md-6"><label class="form-label" for="{{ id }}-cc">CC</label><input class="form-control" id="{{ id }}-cc" type="email" name="cc" value="{{ m.cc or '' }}" multiple {% if datalist %}list="{{ datalist }}" autocomplete="off"{% endif %}></div>
<div class="col-12"><label class="form-label" for="{{ id }}-subject">Onderwerp</label><input class="form-control" id="{{ id }}-subject" name="subject" value="{{ m.subject or '' }}" required></div>
<div class="col-12"><label class="form-label" for="{{ id }}-body">Bericht</label><textarea class="form-control" id="{{ id }}-body" name="body" rows="10" required>
{{ m.body or '' }}</textarea></div>
<div class="col-12"><label class="form-label" for="{{ id }}-files">Bijlagen</label><input class="form-control" id="{{ id }}-files" type="file" name="attachments" multiple></div>
</div>
<div class="actions"><button class="btn btn-primary">{{ button }}</button></div>
</form>
<script src="{{ url_for('static', filename='compose.js') }}" defer></script>
{% endmacro %}
+121
View File
@@ -0,0 +1,121 @@
{# Offerte als document (A4): in de CRM binnen het menu (quote_print.html), in het portaal los (quote_portal.html). #}
<style>
.qdoc { font-family: "Mukta", system-ui, sans-serif; color: #212121; font-size: 14px; line-height: 1.55; font-variant-numeric: lining-nums tabular-nums; }
.qdoc .page { background: #fff; max-width: 210mm; margin: 24px auto; padding: 16mm 15mm; border-radius: 20px; }
.qdoc header { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; }
.qdoc header img { width: 210px; max-width: 45%; }
.qdoc .company { text-align: right; color: #5c6670; font-size: 13px; }
.qdoc .company strong { color: #212121; }
.qdoc .meta { display: flex; justify-content: space-between; gap: 24px; margin: 40px 0 28px; }
.qdoc .label { font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: #5c6670; }
.qdoc .kv td { padding: 1px 0 1px 16px; }
.qdoc .kv td:first-child { padding-left: 0; }
.qdoc h1 { font-family: "Kantumruy Pro", sans-serif; font-weight: 500; font-size: 26px; line-height: 1.15; margin: 0 0 12px; }
.qdoc h1::before { content: ""; display: block; width: 3rem; height: 3px; background: #b95913; margin-bottom: 14px; }
.qdoc .intro, .qdoc .notes { white-space: pre-line; }
.qdoc table.lines { width: 100%; border-collapse: collapse; margin: 24px 0 8px; }
.qdoc .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); }
.qdoc .lines td { padding: 10px 8px; border-bottom: 1px solid rgba(0,0,0,.08); vertical-align: top; }
.qdoc .lines tr { break-inside: avoid; }
.qdoc .desc { white-space: pre-line; }
.qdoc .desc::first-line { font-weight: 600; }
.qdoc .num { text-align: right; white-space: nowrap; }
.qdoc .totals { margin-left: auto; width: 300px; border-collapse: collapse; }
.qdoc .totals td { padding: 4px 8px; }
.qdoc .totals .grand td { font-family: "Kantumruy Pro", sans-serif; font-weight: 600; font-size: 18px; border-top: 2px solid #212121; padding-top: 8px; }
.qdoc .notes { margin-top: 32px; }
.qdoc .sign { margin-top: 40px; display: flex; gap: 48px; }
.qdoc .sign div { flex: 1; border-top: 1px solid rgba(0,0,0,.3); padding-top: 6px; color: #5c6670; font-size: 12px; }
.qdoc .bestek { break-before: page; margin-top: 48px; }
.qdoc .bestek h2 { font-family: "Kantumruy Pro", sans-serif; font-weight: 500; font-size: 15px; margin: 22px 0 4px; display: flex; justify-content: space-between; border-bottom: 2px solid #212121; padding-bottom: 3px; }
.qdoc .bestek table { width: 100%; border-collapse: collapse; }
.qdoc .bestek td { padding: 4px 8px 4px 0; border-bottom: 1px solid rgba(0,0,0,.08); vertical-align: top; }
.qdoc .bestek tr { break-inside: avoid; }
.qdoc .bestek .info td { color: #5c6670; font-style: italic; }
.qdoc .bestek .intro { color: #5c6670; font-size: 13px; }
.qdoc 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 {
.qdoc .page { margin: 0; padding: 0; max-width: none; border-radius: 0; }
}
@media (max-width: 640px) {
.qdoc .page { padding: 20px 16px; margin: 0; border-radius: 0; }
.qdoc header, .qdoc .meta { flex-direction: column; }
.qdoc .company { text-align: left; }
.qdoc .totals { width: 100%; }
}
</style>
<div class="qdoc">
<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>
{% if bestek %}
<section class="bestek" id="bestek">
<h1>Bestek{% if bestek.design.title %}: {{ bestek.design.title }}{% endif %}</h1>
<p class="intro">Bijlage bij offerte {{ qt.number }}. Omschrijving en hoeveelheden van de woning volgens het ontwerp{% if bestek.units %}
({{ bestek.units }} module{{ 's' if bestek.units != 1 }}){% endif %}, berekend op {{ bestek.computedAt[:19]|dt('%d-%m-%Y') }}. Hoeveelheden zijn netto
volgens tekening; de prijzen staan in de offerte.</p>
{% for ch in bestek.chapters %}
<h2><span>{{ loop.index }}. {{ ch.name }}</span></h2>
<table><tbody>
{% for l in ch.lines %}<tr><td>{{ l.label }}</td><td class="num">{{ l.qty|nr }} {{ l.unit }}</td></tr>{% endfor %}
{% for l in ch.info %}<tr class="info"><td>{{ l.label }}</td><td class="num">{{ l.qty|nr }} {{ l.unit }}</td></tr>{% endfor %}
</tbody></table>
{% endfor %}
</section>
{% endif %}
</div>
</div>
+33 -1
View File
@@ -2,7 +2,9 @@
{% 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"><div class="card-body">
<div class="row g-4">
<div class="col-lg-6">
<form method="post" class="card"><div class="card-body">
<h2>Wachtwoord wijzigen</h2>
<div class="row g-3">
<div class="col-12"><label class="form-label" for="old">Huidig wachtwoord</label><input class="form-control" id="old" type="password" name="old_password" required></div>
@@ -10,4 +12,34 @@
</div>
<div class="actions"><button class="btn btn-primary">Opslaan</button></div>
</div></form>
</div>
<div class="col-lg-6">
<div class="card dav-box" id="agenda"><div class="card-body">
<h2>Agenda koppelen</h2>
<p>Zet je CRM-agenda in de agenda van je telefoon of computer (CalDAV). Wijzigingen gaan beide kanten op. Je logt
daar in met een apart <strong>app-wachtwoord</strong>, niet met je CRM-wachtwoord.</p>
{% if dav.password %}
<p class="mb-1">Je nieuwe app-wachtwoord (alleen nu te zien):</p>
<p><span class="dav-pass">{{ dav.password }}</span></p>
{% endif %}
<table class="table table-sm mb-3">
<tr><th class="label" scope="row">Server</th><td><code>{{ dav.host }}</code></td></tr>
<tr><th class="label" scope="row">Volledig adres</th><td><code>{{ dav.url }}</code></td></tr>
<tr><th class="label" scope="row">Gebruikersnaam</th><td><code>{{ g.user.email }}</code></td></tr>
<tr><th class="label" scope="row">Wachtwoord</th><td>{{ 'het app-wachtwoord' if dav.active else 'maak hieronder een app-wachtwoord aan' }}</td></tr>
</table>
<ul class="hint ps-3">
<li><strong>iPhone / iPad:</strong> Instellingen › Agenda › Accounts › Voeg account toe › Anders › Voeg CalDAV-account toe; server <code>{{ dav.host }}</code>.</li>
<li><strong>Mac:</strong> Agenda › Instellingen › Accounts › + › Ander CalDAV-account; type Handmatig, server <code>{{ dav.host }}</code>.</li>
<li><strong>Android:</strong> installeer DAVx⁵, kies "Inloggen met URL en gebruikersnaam" met het volledige adres.</li>
<li><strong>Thunderbird:</strong> Nieuwe agenda › Op het netwerk; het volledige adres.</li>
</ul>
<form method="post" class="actions">
<button class="btn btn-primary" name="action" value="dav" {% if dav.active %}onclick="return confirm('Een nieuw app-wachtwoord maakt het oude ongeldig. Doorgaan?')"{% endif %}>{{ 'Nieuw app-wachtwoord' if dav.active else 'App-wachtwoord aanmaken' }}</button>
{% if dav.active %}<button class="btn btn-outline-secondary" name="action" value="dav-off" onclick="return confirm('Agendakoppeling uitzetten?')">Koppeling uitzetten</button>{% endif %}
</form>
</div></div>
</div>
</div>
{% endblock %}
+44 -5
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{% block head %}{% endblock %}
<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>
@@ -19,6 +20,8 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous" defer></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>
{% if g.user %}<script src="{{ url_for('static', filename='search.js') }}" defer></script>{% endif %}
{% block styles %}{% endblock %}
</head>
<body>
{% block body %}
@@ -40,7 +43,9 @@
<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('calendar_view') }}" class="{{ 'active' if ep.startswith('calendar') }}">Agenda</a>
<a href="{{ url_for('inbox') }}" class="{{ 'active' if ep.startswith('inbox') or ep in ('mail_new', 'mail_send') }}">Inbox {% if unread %}<span class="count">{{ unread }}</span>{% endif %}</a>
<a href="{{ url_for('mail_templates') }}" class="{{ 'active' if ep.startswith('mail_template') }}">E-mailtemplates</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>
@@ -55,12 +60,45 @@
</div>
</aside>
<div class="app-main">
<header class="topbar d-lg-none">
<button class="menu-btn" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidenav" aria-controls="sidenav">
<header class="topbar">
<button class="menu-btn d-lg-none" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidenav" aria-controls="sidenav">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg>Menu
</button>
<a href="{{ url_for('dashboard') }}"><img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous"></a>
{% if unread %}<a href="{{ url_for('inbox') }}" class="count" aria-label="{{ unread }} ongelezen e-mail">{{ unread }}</a>{% endif %}
<a href="{{ url_for('dashboard') }}" class="topbar-logo d-lg-none"><img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous"></a>
<div class="topbar-tools">
{% set sq = request.args.get('q', '') if ep == 'search' else '' %}
{% macro search_form(id, sq) %}
<form class="tsearch" action="{{ url_for('search') }}" method="get" role="search" data-live="{{ url_for('search_live') }}">
<svg class="tsearch-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input class="form-control" id="{{ id }}" type="search" name="q" value="{{ sq }}" placeholder="Zoek lead, klant, offerte…" autocomplete="off" aria-label="Zoeken" aria-controls="{{ id }}-results" aria-autocomplete="list">
<div class="tsearch-results" id="{{ id }}-results" role="listbox" aria-label="Zoekresultaten"></div>
</form>
{% endmacro %}
<div class="d-none d-md-block">{{ search_form('q-top', sq) }}</div>
<div class="dropdown d-md-none">
<button class="icon-btn" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false" aria-label="Zoeken">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
</button>
<div class="dropdown-menu dropdown-menu-end tsearch-pop">{{ search_form('q-pop', sq) }}</div>
</div>
{% if unread %}<a href="{{ url_for('inbox') }}" class="count d-lg-none" aria-label="{{ unread }} ongelezen e-mail">{{ unread }}</a>{% endif %}
<div class="dropdown">
<button class="btn btn-primary btn-sm new-btn" type="button" data-bs-toggle="dropdown" aria-expanded="false" aria-label="Nieuw">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg><span class="d-none d-sm-inline">Nieuw</span>
</button>
<ul class="dropdown-menu dropdown-menu-end new-menu">
<li><a class="dropdown-item" href="{{ url_for('contact_new', type='lead') }}">Lead</a></li>
<li><a class="dropdown-item" href="{{ url_for('contact_new', type='klant') }}">Contactpersoon</a></li>
<li><a class="dropdown-item" href="{{ url_for('relation_edit', kind='klant') }}">Klant</a></li>
<li><a class="dropdown-item" href="{{ url_for('relation_edit', kind='partner') }}">Partner</a></li>
<li><hr class="dropdown-divider"></li>
<li><form method="post" action="{{ url_for('quote_new') }}"><button class="dropdown-item">Offerte</button></form></li>
<li><a class="dropdown-item" href="{{ url_for('product_edit') }}">Product</a></li>
<li><a class="dropdown-item" href="{{ url_for('calendar_edit') }}">Afspraak</a></li>
<li><a class="dropdown-item" href="{{ url_for('mail_new') }}">E-mail</a></li>
</ul>
</div>
</div>
</header>
<main class="content">
{% with msgs = get_flashed_messages(with_categories=true) %}
@@ -71,5 +109,6 @@
</div>
</div>
{% endblock %}
{% block scripts %}{% endblock %}
</body>
</html>
+13 -18
View File
@@ -1,18 +1,12 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{% extends 'base.html' %}
{% block head %}
{# De engine haalt kosten/*.csv en assets/ relatief op: base = de gekopieerde configurator. Alle CRM-links zijn absoluut. #}
<base href="{{ url_for('static', filename='configurator/') }}">
<title>Bestek · Offerte {{ qt.number }} · 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="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% endblock %}
{% block title %}Bestek · Offerte {{ qt.number }}{% endblock %}
{% block styles %}
<style>
.content { margin: 0 auto; max-width: 980px; }
.bestek-page { max-width: 980px; }
.bs-ch { margin-bottom: 1.5rem; }
.bs-ch-head { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; border-bottom: 2px solid var(--ink); padding-bottom: 0.3rem; margin-bottom: 0.25rem; }
.bs-ch-head h2 { margin: 0; font-size: 1.1rem; }
@@ -28,9 +22,9 @@
.bs-opts .form-check { margin-bottom: 0.4rem; }
@media (max-width: 640px) { .bs-ch td.num { width: auto; } }
</style>
</head>
<body>
<main class="content">
{% endblock %}
{% block content %}
<div class="bestek-page">
<div class="head">
<div>
<a class="label" href="{{ url_for('quote_edit', qid=qt.id) }}">← Offerte {{ qt.number }}</a>
@@ -68,12 +62,13 @@
<a class="btn btn-outline-secondary" href="{{ url_for('quote_edit', qid=qt.id) }}">Annuleren</a></div>
</div></form>
</div>
</main>
</div>
{% endblock %}
{% block scripts %}
{% if engine %}
<script type="application/json" id="design">{{ design|tojson }}</script>
<script type="application/json" id="bestek-meta">{{ {'design': {'file_id': design_file.id, 'title': design_file.title}, 'configurator': design.get('configurator') or ''}|tojson }}</script>
{% for src in scripts %}<script src="{{ src }}"></script>
{% endfor %}<script src="{{ url_for('static', filename='bestek.js') }}"></script>
{% endif %}
</body>
</html>
{% endblock %}
+64
View File
@@ -0,0 +1,64 @@
{% extends 'base.html' %}
{% block title %}Agenda · {{ title }}{% endblock %}
{% block content %}
<div class="head">
<div><div class="kicker">Agenda</div><h1>{{ title }}</h1></div>
<div class="actions">
<form method="get" action="{{ url_for('calendar_view') }}">
<input type="hidden" name="m" value="{{ month.strftime('%Y-%m') }}">
<select class="form-select w-auto" name="u" onchange="this.form.submit()" aria-label="Wiens agenda">
<option value="all" {{ 'selected' if who == 'all' }}>Iedereen</option>
<option value="me" {{ 'selected' if who == 'me' }}>Mijn agenda</option>
{% for u in USERS if u.id != g.user.id %}<option value="{{ u.id }}" {{ 'selected' if who == u.id|string }}>{{ u.name }}</option>{% endfor %}
</select>
</form>
<a class="btn btn-outline-secondary" href="{{ url_for('calendar_view', m=prev) }}" aria-label="Vorige maand">‹</a>
{% if month.strftime('%Y-%m') != this_month %}<a class="btn btn-outline-secondary" href="{{ url_for('calendar_view', m=this_month) }}">Vandaag</a>{% endif %}
<a class="btn btn-outline-secondary" href="{{ url_for('calendar_view', m=next) }}" aria-label="Volgende maand">›</a>
<a class="btn btn-primary" href="{{ url_for('calendar_edit') }}">Nieuwe afspraak</a>
</div>
</div>
{% macro item(i) %}
<a class="cal-ev {{ i.color }}{{ ' task' if i.kind == 'task' else (' allday' if i.all_day else '') }}" href="{{ i.url }}"
title="{{ i.title }} · {{ i.when }}{% if i.contact %} · {{ i.contact }}{% endif %}{% if i.user %} · {{ i.user }}{% endif %}">
{%- if i.kind == 'task' %}☐ {% elif not i.all_day %}<span class="t">{{ i.start.strftime('%H:%M') }}</span>{% endif -%}
{{ i.title }}{% if i.repeats %} ↻{% endif %}
</a>
{% endmacro %}
<div class="card"><div class="card-body">
<table class="cal d-none d-md-table">
<thead><tr>{% for d in DAYS %}<th scope="col">{{ d }}</th>{% endfor %}</tr></thead>
<tbody>
{% for week in weeks %}
<tr>
{% for d in week %}
<td class="{{ 'out' if d.month != month.month }}{{ ' today' if d.isoformat() == today }}">
<a class="cal-num" href="{{ url_for('calendar_edit', date=d.isoformat()) }}" aria-label="Afspraak op {{ day_label(d) }}">{{ d.day }}</a>
{% for i in by_day.get(d, []) %}{{ item(i) }}{% endfor %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
<div class="d-md-none">
{% set ns = namespace(any=false) %}
{% for week in weeks %}{% for d in week if d.month == month.month and by_day.get(d) %}
{% set ns.any = true %}
<div class="cal-ld{{ ' today' if d.isoformat() == today }}">
<a class="cal-ldate" href="{{ url_for('calendar_edit', date=d.isoformat()) }}"><b>{{ d.day }}</b><span>{{ DAYS[d.weekday()] }}</span></a>
<div class="cal-items">{% for i in by_day[d] %}{{ item(i) }}{% endfor %}</div>
</div>
{% endfor %}{% endfor %}
{% if not ns.any %}<p class="empty">Geen afspraken of taken in {{ title|lower }}.</p>{% endif %}
</div>
{% if who == 'all' and USERS|length > 1 %}
<div class="cal-legend">{% for u in USERS %}<span class="{{ user_color(u.id) }}">{{ u.name }}</span>{% endfor %}<span style="--u: transparent; ">☐ taak</span></div>
{% endif %}
</div></div>
<p class="hint">Je eigen agenda op je telefoon of computer? Zie <a href="{{ url_for('account', _anchor='agenda') }}">Mijn account › Agenda koppelen</a>.</p>
{% endblock %}
+15 -10
View File
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% from '_compose.html' import compose %}
{% block title %}{{ c.name }}{% endblock %}
{% block content %}
<div class="head">
@@ -101,6 +102,18 @@
</form>
</div></div>
<div class="card"><div class="card-body">
<div class="card-top"><h2>Afspraken</h2>
<div class="actions"><a class="btn btn-outline-primary btn-sm" href="{{ url_for('calendar_edit', contact_id=c.id, next=request.path) }}">Afspraak plannen</a></div></div>
{% for i in appointments %}
<div class="agenda-row"><div class="when">{{ i.when }}</div>
<div><a href="{{ url_for('calendar_edit', eid=i.id, next=request.path) }}">{{ i.title }}</a>{% if i.repeats %} ↻{% endif %}
<div class="muted">{{ i.user }}{% if i.location %} · {{ i.location }}{% endif %}</div></div></div>
{% else %}
<p class="empty">Geen afspraken gepland.</p>
{% endfor %}
</div></div>
<div class="card o-3"><div class="card-body">
<h2>Notitie toevoegen</h2>
<form method="post" action="{{ url_for('contact_note', cid=c.id) }}">
@@ -113,15 +126,7 @@
<div class="card"><div class="card-body">
<details>
<summary>E-mail sturen</summary>
<form method="post" action="{{ url_for('mail_send') }}">
<input type="hidden" name="contact_id" value="{{ c.id }}">
<div class="row g-3">
<div class="col-12"><label class="form-label" for="m-to">Aan</label><input class="form-control" id="m-to" type="email" name="to" value="{{ c.email }}" required></div>
<div class="col-12"><label class="form-label" for="m-subject">Onderwerp</label><input class="form-control" id="m-subject" name="subject" required></div>
<div class="col-12"><label class="form-label" for="m-body">Bericht</label><textarea class="form-control" id="m-body" name="body" rows="8" required></textarea></div>
</div>
<div class="actions"><button class="btn btn-primary">Versturen</button></div>
</form>
{{ compose({'to': c.email, 'contact_id': c.id}, mail_templates, mail_vars) }}
</details>
</div></div>
{% endif %}
@@ -170,7 +175,7 @@
{% 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 %}
{% if c.address or c.postcode 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>
+9
View File
@@ -97,6 +97,15 @@
<p class="empty">Geen openstaande taken.</p>
{% endfor %}
</div></div>
<div class="card"><div class="card-body">
<div class="card-top"><h2>Mijn agenda</h2><div class="actions"><a class="btn btn-outline-primary btn-sm" href="{{ url_for('calendar_view') }}">Agenda</a></div></div>
{% for i in agenda %}
<div class="agenda-row"><div class="when">{{ 'Vandaag' if i.start.date().isoformat() == today else i.when.split(' ')[:3]|join(' ') }}{% if not i.all_day %}<br>{{ i.start.strftime('%H:%M') }}{% endif %}</div>
<div><a href="{{ i.url }}">{{ i.title }}</a>{% if i.contact %}<div class="muted">{{ i.contact }}</div>{% endif %}</div></div>
{% else %}
<p class="empty">Geen afspraken de komende 7 dagen.</p>
{% endfor %}
</div></div>
<div class="card"><div class="card-body">
<h2>Ongelezen e-mail</h2>
{% for e in mails %}
+15 -14
View File
@@ -1,39 +1,40 @@
{% extends 'base.html' %}
{% from '_compose.html' import compose %}
{% block title %}{{ e.subject }}{% endblock %}
{% block content %}
{% set incoming = e.direction == 'in' %}
<div class="head">
<div>
<a class="label" href="{{ url_for('inbox') }}">← Inbox</a>
<a class="label" href="{{ url_for('inbox', f=None if incoming and not e.archived else ('archived' if incoming else 'sent')) }}">← {{ 'Verzonden' if not incoming else ('Archief' if e.archived else 'Inbox') }}</a>
<h1>{{ e.subject }}</h1>
<div class="muted" style="overflow-wrap:anywhere">
{% if e.direction == 'in' %}Van {{ e.from_name }} &lt;{{ e.from_addr }}&gt;{% else %}Aan {{ e.to_addr }}{% endif %}
{% if incoming %}Van {{ e.from_name }} &lt;{{ e.from_addr }}&gt;{% else %}Aan {{ e.to_addr }}{% endif %}
{% if e.cc %}· CC {{ e.cc }}{% endif %}
· {{ e.date|dt }}
</div>
{% if e.attachments %}<div class="muted" style="overflow-wrap:anywhere">Bijlagen: {{ e.attachments }}</div>{% endif %}
</div>
<div class="actions">
{% if contact %}
<a class="btn btn-outline-primary" href="{{ url_for('contact_view', cid=contact.id) }}">Naar {{ contact.name }}</a>
{% elif e.direction == 'in' %}
{% elif incoming %}
<form method="post" action="{{ url_for('inbox_contact', eid=e.id) }}" class="inline"><button class="btn btn-outline-primary">Maak contact aan</button></form>
{% endif %}
<a class="btn btn-outline-primary" href="{{ url_for('mail_new', forward=e.id) }}">Doorsturen</a>
{% if incoming %}
<form method="post" action="{{ url_for('inbox_unread', eid=e.id) }}" class="inline"><button class="btn btn-outline-secondary">Markeer als ongelezen</button></form>
<form method="post" action="{{ url_for('inbox_archive', eid=e.id) }}" class="inline"><button class="btn btn-outline-secondary">{{ 'Terug naar inbox' if e.archived else 'Archiveren' }}</button></form>
{% endif %}
</div>
</div>
<div class="card"><div class="card-body"><div class="mailbody">{{ e.body }}</div></div></div>
{% if e.direction == 'in' %}
{% if incoming %}
<div class="card"><div class="card-body">
<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="row g-3">
<div class="col-md-6"><label class="form-label" for="to">Aan</label><input class="form-control" id="to" type="email" name="to" value="{{ e.from_addr }}" required></div>
<div class="col-md-6"><label class="form-label" for="subject">Onderwerp</label><input class="form-control" id="subject" name="subject" value="{{ e.subject if e.subject.lower().startswith('re:') else 'Re: ' ~ e.subject }}" required></div>
<div class="col-12"><label class="form-label" for="body">Bericht</label><textarea class="form-control" id="body" name="body" rows="8" required></textarea></div>
</div>
<div class="actions"><button class="btn btn-primary">Versturen</button></div>
</form>
{{ compose({'to': e.from_addr, 'subject': e.subject if e.subject.lower().startswith('re:') else 'Re: ' ~ e.subject,
'reply_to': e.id, 'contact_id': contact.id if contact else None}, templates, vars) }}
</div></div>
{% endif %}
{% endblock %}
+74
View File
@@ -0,0 +1,74 @@
{% extends 'base.html' %}
{% block title %}{{ e.title if e else 'Nieuwe afspraak' }}{% endblock %}
{% block content %}
<div class="head">
<div>
<a class="label" href="{{ nxt or url_for('calendar_view', m=v.start_date[:7]) }}">← Agenda</a>
<h1>{{ 'Afspraak bewerken' if e else 'Nieuwe afspraak' }}</h1>
</div>
{% if e and e.contact_id %}<div class="actions"><a class="btn btn-outline-secondary" href="{{ url_for('contact_view', cid=e.contact_id) }}">{{ e.contact_name }}</a></div>{% endif %}
</div>
<form method="post" class="card" style="max-width:860px"><div class="card-body">
<input type="hidden" name="next" value="{{ nxt }}">
<div class="row g-3">
<div class="col-12"><label class="form-label" for="title">Titel *</label><input class="form-control" id="title" name="title" value="{{ v.title }}" required autofocus></div>
<div class="col-12">
<div class="form-check"><input class="form-check-input" type="checkbox" id="all_day" name="all_day" {{ 'checked' if v.all_day }}><label class="form-check-label" for="all_day">Hele dag</label></div>
</div>
<div class="col-7 col-md-4"><label class="form-label" for="start_date">Van</label><input class="form-control" type="date" id="start_date" name="start_date" value="{{ v.start_date }}" required></div>
<div class="col-5 col-md-2 ev-time"><label class="form-label" for="start_time">Tijd</label><input class="form-control" type="time" id="start_time" name="start_time" value="{{ v.start_time }}" step="300"></div>
<div class="col-7 col-md-4"><label class="form-label" for="end_date">Tot en met</label><input class="form-control" type="date" id="end_date" name="end_date" value="{{ v.end_date }}"></div>
<div class="col-5 col-md-2 ev-time"><label class="form-label" for="end_time">Tijd</label><input class="form-control" type="time" id="end_time" name="end_time" value="{{ v.end_time }}" step="300"></div>
<div class="col-md-6"><label class="form-label" for="user_id">Agenda van</label>
<select class="form-select" id="user_id" name="user_id">
{% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if v.user_id|string == u.id|string }}>{{ u.name }}</option>{% endfor %}
</select>
</div>
<div class="col-md-6"><label class="form-label" for="repeat">Herhalen</label>
<select class="form-select" id="repeat" name="repeat">
{% if v.repeat == 'keep' %}<option value="keep" selected>Zoals ingesteld in de agenda-app</option>{% endif %}
{% for k, l in REPEATS %}<option value="{{ k }}" {{ 'selected' if v.repeat == k }}>{{ l }}</option>{% endfor %}
</select>
</div>
<div class="col-md-6"><label class="form-label" for="contact_id">Contact</label>
<select class="form-select" id="contact_id" name="contact_id">
<option value="">— Geen —</option>
{% for c in contacts %}<option value="{{ c.id }}" {{ 'selected' if v.contact_id|string == c.id|string }}>{{ c.name }}{% if c.type == 'lead' %} (lead){% endif %}</option>{% endfor %}
</select>
</div>
<div class="col-md-6"><label class="form-label" for="location">Locatie</label><input class="form-control" id="location" name="location" value="{{ v.location }}"></div>
<div class="col-12"><label class="form-label" for="description">Notitie</label><textarea class="form-control" id="description" name="description" rows="4">{{ v.description }}</textarea></div>
</div>
{% if e and e.ics and 'VALARM' in e.ics %}<p class="hint">Deze afspraak heeft herinneringen uit een agenda-app; opslaan in de CRM haalt die weg.</p>{% endif %}
<div class="actions">
<button class="btn btn-primary">Opslaan</button>
<a class="btn btn-outline-secondary" href="{{ nxt or url_for('calendar_view', m=v.start_date[:7]) }}">Annuleren</a>
{% if e %}<button class="btn btn-outline-secondary ms-auto" form="delete-event" onclick="return confirm('Afspraak verwijderen?')">Verwijderen</button>{% endif %}
</div>
</div></form>
{% if e %}<form id="delete-event" method="post" action="{{ url_for('calendar_delete', eid=e.id) }}"></form>{% endif %}
<script>
(() => {
const allDay = document.getElementById('all_day'), sd = document.getElementById('start_date'), ed = document.getElementById('end_date');
const st = document.getElementById('start_time'), et = document.getElementById('end_time');
const sync = () => document.querySelectorAll('.ev-time').forEach((el) => { el.hidden = allDay.checked; });
allDay.addEventListener('change', sync);
sync();
// einddatum en -tijd schuiven mee met het begin
let lastStart = sd.value;
sd.addEventListener('change', () => {
if (!ed.value || ed.value === lastStart || ed.value < sd.value) ed.value = sd.value;
lastStart = sd.value;
});
let lastTime = st.value;
st.addEventListener('change', () => {
const toMin = (t) => { const [h, m] = t.split(':').map(Number); return h * 60 + m; };
if (lastTime && et.value && st.value) {
const end = Math.min(toMin(et.value) + toMin(st.value) - toMin(lastTime), 23 * 60 + 55);
if (end > toMin(st.value)) et.value = `${String(Math.floor(end / 60)).padStart(2, '0')}:${String(end % 60).padStart(2, '0')}`;
}
lastTime = st.value;
});
})();
</script>
{% endblock %}
+20 -9
View File
@@ -1,10 +1,17 @@
{% extends 'base.html' %}
{% block title %}Inbox{% endblock %}
{% block content %}
{% set sent = f == 'sent' %}
<div class="head">
<div><div class="kicker">E-mail</div><h1>Inbox</h1>{% if last_sync %}<div class="muted">Laatst opgehaald: {{ last_sync|dt }}</div>{% endif %}</div>
<div><div class="kicker">E-mail</div><h1>{{ {'sent': 'Verzonden', 'archived': 'Archief'}.get(f, 'Inbox') }}</h1>{% if last_sync %}<div class="muted">Laatst opgehaald: {{ last_sync|dt }}</div>{% endif %}</div>
<div class="actions">
<form method="post" action="{{ url_for('inbox_sync') }}" class="inline"><button class="btn btn-primary">Nieuwe e-mail ophalen</button></form>
<form class="search" method="get">
<input type="hidden" name="f" value="{{ f }}">
<input class="form-control" type="search" name="q" value="{{ term }}" placeholder="Zoek op afzender, onderwerp, tekst…" aria-label="Zoeken in e-mail">
</form>
<a class="btn btn-outline-primary" href="{{ url_for('mail_templates') }}">Templates</a>
<form method="post" action="{{ url_for('inbox_sync') }}" class="inline"><button class="btn btn-outline-primary">Nieuwe e-mail ophalen</button></form>
<a class="btn btn-primary" href="{{ url_for('mail_new') }}">Nieuwe e-mail</a>
</div>
</div>
@@ -13,26 +20,30 @@
{% 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>
{% for k, l in [('all', 'Inbox'), ('unread', 'Ongelezen'), ('unlinked', 'Niet gekoppeld aan contact'), ('sent', 'Verzonden'), ('archived', 'Archief')] %}
<a href="{{ url_for('inbox', f=k if k != 'all' else None, q=term or None) }}" class="{{ 'on' if f == k }}">{{ l }}{% if k == 'unread' and unread %} <span class="muted">{{ unread }}</span>{% endif %}</a>
{% endfor %}
{% if unread and not sent %}
<form method="post" action="{{ url_for('inbox_read_all') }}" class="inline"><button class="btn btn-link btn-sm">Alles als gelezen markeren</button></form>
{% endif %}
</div>
<div class="card"><div class="card-body">
{% if rows %}
<div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th>Van</th><th>Onderwerp</th><th>Contact</th><th class="num">Datum</th></tr></thead>
<thead><tr><th>{{ 'Aan' if sent else '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 class="stack-title">{% if not e.is_read %}<span class="dot" aria-label="Ongelezen"></span>{% endif %}{{ e.from_name or e.from_addr }}<a class="d-block d-md-none" href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td>
<td class="d-none d-md-table-cell"><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td>
<td class="stack-title">{% if not e.is_read %}<span class="dot" aria-label="Ongelezen"></span>{% endif %}{{ e.to_addr if sent else e.from_name or e.from_addr }}<a class="d-block d-md-none" href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td>
<td class="d-none d-md-table-cell"><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a>{% if e.attachments %} <span class="muted" title="{{ e.attachments }}">· bijlage</span>{% endif %}</td>
<td data-label="Contact">{{ e.contact_name or '' }}</td>
<td data-label="Datum" class="muted num">{{ e.date|dt }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Geen berichten.</p>{% endif %}
{% else %}<p class="empty">{{ 'Niets gevonden.' if term else 'Geen berichten.' }}</p>{% endif %}
</div></div>
{% if f == 'archived' %}<p class="muted">Archiveren haalt een bericht alleen uit de inbox van de CRM; in je mailbox blijft het staan.</p>{% endif %}
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% from '_compose.html' import compose %}
{% block title %}Nieuwe e-mail{% endblock %}
{% block content %}
<div class="head">
<div><a class="label" href="{{ url_for('inbox') }}">← Inbox</a><h1>{{ 'Beantwoorden' if m.reply_to else 'Nieuwe e-mail' }}</h1>
{% if contact %}<div class="muted">Aan {{ contact.name }}</div>{% endif %}</div>
<div class="actions"><a class="btn btn-outline-primary" href="{{ url_for('mail_templates') }}">Templates</a></div>
</div>
<div class="card"><div class="card-body">
{{ compose(m, templates, vars, datalist='mail-contacts') }}
{% if not templates %}<p class="hint">Nog geen templates. <a href="{{ url_for('mail_template_edit') }}">Maak een template</a> voor berichten die je vaker stuurt.</p>{% endif %}
</div></div>
<datalist id="mail-contacts">{% for c in contacts %}<option value="{{ c.email }}">{{ c.name }}</option>{% endfor %}</datalist>
{% endblock %}
+52
View File
@@ -0,0 +1,52 @@
{% extends 'base.html' %}
{% block title %}{{ t.name or 'Nieuwe template' }}{% endblock %}
{% block content %}
<div class="head">
<div><a class="label" href="{{ url_for('mail_templates') }}">← Templates</a><h1>{{ 'Template bewerken' if t.id else 'Nieuwe template' }}</h1></div>
{% if t.id %}<div class="actions"><a class="btn btn-outline-primary" href="{{ url_for('mail_new', template=t.id) }}">Gebruiken</a></div>{% endif %}
</div>
<div class="row g-4">
<div class="col-lg-8">
<form method="post" class="card"><div class="card-body">
<div class="row g-3">
<div class="col-12"><label class="form-label" for="name">Naam *</label><input class="form-control" id="name" name="name" value="{{ t.name or '' }}" placeholder="Bijvoorbeeld: Ontwerp ontvangen" required></div>
<div class="col-12"><label class="form-label" for="subject">Onderwerp</label><input class="form-control" id="subject" name="subject" value="{{ t.subject or '' }}" data-field-target></div>
<div class="col-12"><label class="form-label" for="body">Tekst</label><textarea class="form-control" id="body" name="body" rows="16" data-field-target>
{{ t.body or '' }}</textarea></div>
</div>
<div class="actions">
<button class="btn btn-primary">Opslaan</button>
<a class="btn btn-outline-secondary" href="{{ url_for('mail_templates') }}">Annuleren</a>
</div>
</div></form>
{% if t.id %}
<form method="post" action="{{ url_for('mail_template_delete', tid=t.id) }}" onsubmit="return confirm('Template verwijderen?')">
<button class="btn btn-outline-secondary btn-sm">Template verwijderen</button>
</form>
{% endif %}
</div>
<div class="col-lg-4">
<div class="card"><div class="card-body">
<h2>Velden</h2>
<p class="hint">Klik om een veld in te voegen in het onderwerp of de tekst (waar de cursor stond).</p>
<ul class="mail-fields">
{% for k, label in FIELDS %}<li><button type="button" class="btn btn-outline-primary btn-sm" data-field="{{ k }}">{{ '{' ~ k ~ '}' }}</button><span class="muted">{{ label }}</span></li>{% endfor %}
</ul>
</div></div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Veld invoegen in het laatst gebruikte tekstvak (standaard de tekst).
(() => {
let target = document.getElementById('body');
document.querySelectorAll('[data-field-target]').forEach(el => el.addEventListener('focus', () => { target = el; }));
document.querySelectorAll('[data-field]').forEach(btn => btn.addEventListener('click', () => {
const text = `{${btn.dataset.field}}`, a = target.selectionStart ?? target.value.length, b = target.selectionEnd ?? a;
target.setRangeText(text, a, b, 'end');
target.focus();
}));
})();
</script>
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
{% extends 'base.html' %}
{% block title %}E-mailtemplates{% endblock %}
{% block content %}
<div class="head">
<div><div class="kicker">E-mail</div><h1>Templates</h1></div>
<div class="actions">
<a class="btn btn-outline-primary" href="{{ url_for('inbox') }}">Inbox</a>
<a class="btn btn-primary" href="{{ url_for('mail_template_edit') }}">Nieuwe template</a>
</div>
</div>
<div class="card"><div class="card-body">
{% if rows %}
<div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th>Naam</th><th>Onderwerp</th><th>Bijgewerkt</th><th></th></tr></thead>
<tbody>
{% for t in rows %}
<tr class="link" onclick="location='{{ url_for('mail_template_edit', tid=t.id) }}'">
<td class="stack-title"><a href="{{ url_for('mail_template_edit', tid=t.id) }}">{{ t.name }}</a></td>
<td data-label="Onderwerp">{{ t.subject }}</td>
<td data-label="Bijgewerkt" class="muted">{{ t.updated_at|dt }}</td>
<td class="num"><a class="btn btn-outline-primary btn-sm" href="{{ url_for('mail_new', template=t.id) }}" onclick="event.stopPropagation()">Gebruiken</a></td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Nog geen templates.</p>{% endif %}
</div></div>
<p class="muted">Kies een template bij een nieuwe e-mail, een antwoord of op de pagina van een contact. Velden tussen accolades,
zoals {voornaam}, worden ingevuld met de gegevens van het contact, van jou en van het bedrijf.</p>
{% endblock %}
+36
View File
@@ -0,0 +1,36 @@
<!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); }
@media print {
body { background: #fff; }
.bar { display: none; }
}
</style>
</head>
<body>
<div class="bar">
<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 %}
<button class="btn {{ 'ghost' if qt.status == 'verzonden' }}" onclick="window.print()">Afdrukken / opslaan als PDF</button>
</div>
{% include '_quote_doc.html' %}
</body>
</html>
+20 -150
View File
@@ -1,152 +1,22 @@
<!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; }
.bestek { break-before: page; margin-top: 48px; }
.bestek h2 { font-family: "Kantumruy Pro", sans-serif; font-weight: 500; font-size: 15px; margin: 22px 0 4px; display: flex; justify-content: space-between; border-bottom: 2px solid #212121; padding-bottom: 3px; }
.bestek table { width: 100%; border-collapse: collapse; }
.bestek td { padding: 4px 8px 4px 0; border-bottom: 1px solid rgba(0,0,0,.08); vertical-align: top; }
.bestek tr { break-inside: avoid; }
.bestek .info td { color: #5c6670; font-style: italic; }
.bestek .intro { color: #5c6670; font-size: 13px; }
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>
{% extends 'base.html' %}
{% block title %}Offerte {{ qt.number }}{% if c %} · {{ c.name }}{% endif %}{% endblock %}
{% block styles %}
<style>
@page { size: A4; margin: 16mm 15mm; }
.qdoc .page { margin: 0 auto 2rem; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); }
@media print { .qdoc .page { margin: 0; } }
</style>
{% endblock %}
{% block content %}
<div class="head no-print">
<div>
<a class="label" href="{{ url_for('quote_edit', qid=qt.id) }}">← Offerte {{ qt.number }}</a>
<h1>Offerte bekijken</h1>
</div>
<div class="actions">
<a class="btn btn-outline-secondary" href="{{ url_for('quote_edit', qid=qt.id) }}">Bewerken</a>
<button class="btn btn-primary" type="button" onclick="window.print()">Afdrukken / opslaan als PDF</button>
</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>
{% if bestek %}
<section class="bestek" id="bestek">
<h1>Bestek{% if bestek.design.title %}: {{ bestek.design.title }}{% endif %}</h1>
<p class="intro">Bijlage bij offerte {{ qt.number }}. Omschrijving en hoeveelheden van de woning volgens het ontwerp{% if bestek.units %}
({{ bestek.units }} module{{ 's' if bestek.units != 1 }}){% endif %}, berekend op {{ bestek.computedAt[:19]|dt('%d-%m-%Y') }}. Hoeveelheden zijn netto
volgens tekening; de prijzen staan in de offerte.</p>
{% for ch in bestek.chapters %}
<h2><span>{{ loop.index }}. {{ ch.name }}</span></h2>
<table><tbody>
{% for l in ch.lines %}<tr><td>{{ l.label }}</td><td class="num">{{ l.qty|nr }} {{ l.unit }}</td></tr>{% endfor %}
{% for l in ch.info %}<tr class="info"><td>{{ l.label }}</td><td class="num">{{ l.qty|nr }} {{ l.unit }}</td></tr>{% endfor %}
</tbody></table>
{% endfor %}
</section>
{% endif %}
</div>
</body>
</html>
{% include '_quote_doc.html' %}
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends 'base.html' %}
{% block title %}Zoeken{% if term %}: {{ term }}{% endif %}{% endblock %}
{% block content %}
<div class="head"><div><div class="kicker">Zoeken</div><h1>{{ '“' ~ term ~ '”' if term else 'Zoeken' }}</h1></div></div>
{% if term|length < 2 %}
<div class="card"><div class="card-body"><p class="empty">Typ minstens twee tekens in het zoekveld bovenaan.</p></div></div>
{% elif not groups %}
<div class="card"><div class="card-body"><p class="empty">Niets gevonden. Zoek op naam, e-mailadres, telefoonnummer, plaats, offertenummer of SKU.</p></div></div>
{% else %}
{% for grp in groups %}
<div class="card"><div class="card-body">
<h2>{{ grp.label }} <span class="count">{{ grp['items']|length }}{{ '+' if grp.more }}</span></h2>
{% for i in grp['items'] %}
<div class="agenda-row"><div><a href="{{ i.url }}">{{ i.title }}</a>{% if i.sub %}<div class="muted">{{ i.sub }}</div>{% endif %}</div></div>
{% endfor %}
{% if grp.more %}<p class="hint">Meer dan {{ grp['items']|length }} resultaten; maak de zoekterm specifieker.</p>{% endif %}
</div></div>
{% endfor %}
{% endif %}
{% endblock %}
+35 -6
View File
@@ -3,6 +3,10 @@
{% macro field(key, label, type='text', placeholder='', full=false) %}
<div class="{{ 'col-12' if full else 'col-md-6 col-xl-4' }}"><label class="form-label" for="{{ key }}">{{ label }}</label><input class="form-control" id="{{ key }}" type="{{ type }}" name="{{ key }}" value="{{ s.get(key, '') }}" placeholder="{{ placeholder }}"></div>
{% endmacro %}
{% macro security(key) %}
<div class="col-md-6 col-xl-4"><label class="form-label" for="{{ key }}">Beveiliging</label>
<select class="form-select" id="{{ key }}" name="{{ key }}">{% for v, label in SECURITY %}<option value="{{ v }}" {{ 'selected' if s.get(key, '') == v }}>{{ label }}</option>{% endfor %}</select></div>
{% endmacro %}
{% block content %}
<div class="head"><div><div class="kicker">Beheer</div><h1>Instellingen</h1></div></div>
@@ -35,24 +39,39 @@
<div class="card" id="mail"><div class="card-body">
<h2>E-mail koppelen</h2>
<p class="hint" style="margin:-.4rem 0 1rem">Gebruik voor Gmail of Microsoft 365 een app-wachtwoord. Wachtwoorden staan in de database: houd de map <code>data/</code> privé.</p>
<p class="hint" style="margin:-.4rem 0 1rem">Eén gedeelde mailbox (bijv. info@) voor de inbox en het versturen vanuit de CRM. Bij OVHcloud
(Zimbra of MX Plan) is de gebruikersnaam het volledige e-mailadres; de afzender moet dat adres (of een alias ervan) zijn.
Gmail werkt met een app-wachtwoord. Wachtwoorden staan in de database: houd de map <code>data/</code> privé.</p>
<div class="row g-3">
<div class="col-md-6 col-xl-4"><label class="form-label" for="mail-provider">Aanbieder</label>
<select class="form-select" id="mail-provider" aria-describedby="mail-provider-hint">
<option value="">Kies om de servers in te vullen…</option>
{% for key, p in PROVIDERS.items() %}<option value="{{ key }}" data-preset="{{ p|tojson|forceescape }}">{{ p.label }}</option>{% endfor %}
</select>
<p class="hint" id="mail-provider-hint">Vult server, poort en beveiliging in; daarna kun je alles nog aanpassen.</p>
</div>
</div>
<h3>Ontvangen (IMAP)</h3>
<div class="row g-3">
{{ field('imap_host', 'Server', placeholder='imap.gmail.com') }}
{{ field('imap_host', 'Server', placeholder='imap.mail.ovh.net') }}
{{ field('imap_port', 'Poort', 'number', '993') }}
{{ field('imap_user', 'Gebruikersnaam', placeholder='info@vanemous.nl') }}
{{ security('imap_security') }}
{{ field('imap_user', 'Gebruikersnaam', placeholder='info@vanemouswoningen.nl') }}
<div class="col-md-6 col-xl-4"><label class="form-label" for="imap_pass">Wachtwoord</label><input class="form-control" 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="row g-3">
{{ field('smtp_host', 'Server', placeholder='smtp.gmail.com') }}
{{ field('smtp_port', 'Poort', 'number', '587') }}
{{ field('smtp_host', 'Server', placeholder='smtp.mail.ovh.net') }}
{{ field('smtp_port', 'Poort', 'number', '465') }}
{{ security('smtp_security') }}
{{ field('smtp_user', 'Gebruikersnaam', placeholder='leeg = zelfde als IMAP') }}
<div class="col-md-6 col-xl-4"><label class="form-label" for="smtp_pass">Wachtwoord</label><input class="form-control" 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 class="col-md-6 col-xl-4"><label class="form-label" for="smtp_sent_folder">Kopie in map</label><input class="form-control" id="smtp_sent_folder" name="smtp_sent_folder" value="{{ s.smtp_sent_folder or '' }}" placeholder="leeg = geen kopie" aria-describedby="sent-hint"><p class="hint" id="sent-hint">Bij Zimbra/MX Plan <code>Sent</code>, zodat verstuurde mail ook in de webmail staat. Gmail doet dit zelf.</p></div>
{{ field('smtp_from', 'Afzender', placeholder='Van Emous <info@vanemouswoningen.nl>', full=true) }}
</div>
<div class="actions"><button class="btn btn-outline-primary" name="action" value="mailtest">Opslaan en verbinding testen</button></div>
</div></div>
<div class="card"><div class="card-body">
@@ -126,4 +145,14 @@ const { ok, id } = await res.json();{% endfilter %}</pre>
<button class="btn btn-outline-secondary btn-sm">Nieuwe API-sleutel maken</button>
</form>
</div></div>
<script>
document.getElementById('mail-provider').addEventListener('change', (e) => {
const opt = e.target.selectedOptions[0];
if (!opt || !opt.dataset.preset) return;
Object.entries(JSON.parse(opt.dataset.preset)).forEach(([key, value]) => {
const el = document.getElementById(key);
if (el) el.value = value;
});
});
</script>
{% endblock %}