12 Commits
Author SHA1 Message Date
MachielandClaude Opus 5.5 5a97f992da Gebruikers: massa-actie actief/inactief maken of verwijderen (jezelf wordt overgeslagen) en een knop Verwijderen op het gebruikersformulier; contacten, taken en activiteiten blijven zonder eigenaar, agenda, meldingen en mailboxkoppelingen gaan mee
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:14:40 +02:00
MachielandClaude Opus 5.5 0368b7c1da Kavelblok: meters naast elkaar, link naar het Omgevingsloket alleen nog op de volledige kaartpagina
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:08:40 +02:00
MachielandClaude Opus 5.5 e9f83278c9 Kavelblok in de CRM eenvoudiger: alleen kavel, bebouwd en vrij (gewone gegevensregels) en de link naar het Omgevingsloket boven de kaart; de rest staat op de volledige kaartpagina
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:00:59 +02:00
MachielandClaude Opus 5.5 805448d515 Kavel bij lead/klant en relatie: kavelgegevens (m², bestemming, plannen, achtererf, natura, cultuur, monument, bouwjaar) in CRM-stijl boven de kaart; de kaart zelf alleen met de knoppen (kaart-app &weergave=kaart, gegevens via postMessage), geen scroll meer in het blok
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:52:12 +02:00
MachielandClaude Opus 5.5 b66d6a6fe6 Migraties bij het starten met een bestandsslot: twee gunicorn-workers voegden tegelijk dezelfde kolom toe (duplicate column name)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:44:04 +02:00
MachielandClaude Opus 5.5 c69c3773dc v0.0.7: documenten met versies bij leads/klanten en relaties (tabel documents, versies in files; oude bestanden worden bij het starten versie 1 van een eigen document); woning = document met versies, de viewer toont de nieuwste (oudere via keuzelijst), nieuwe versie uploaden met opmerking, PDF's per woningversie; kaart (kaart-app, /kaart/) onder de woning en bij een klantrelatie zodra het adres bekend is, met de kavel en getekende woningen uit een kaveltoets-lead; blokken zonder gegevens verborgen; knop Bewerken in Tectum (TECTUM_URL) en API /api/tectum/woningen (lijst, ontwerp, nieuwe versie met conflictcontrole) met sleutel TECTUM_CRM_KEY
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:40:51 +02:00
MachielandClaude Opus 5.5 db3e26d258 v0.0.6: massa-acties en CSV-export in de lijsten van contacten, relaties, producten en offertes
Rijen aanvinken (shift-klik voor een reeks, of alles op de pagina) en in de balk onderin een actie kiezen
(templates/_bulk.html, static/bulk.js). Contacten: status wijzigen, toewijzen, omzetten naar klant, exporteren,
verwijderen; relaties: type wijzigen, exporteren, verwijderen; producten: actief/inactief, exporteren, verwijderen
(in offertes gebruikt → inactief); offertes: status wijzigen (met de gevolgen voor het contact), exporteren,
verwijderen. Exporteer CSV neemt de filters van de lijst mee en levert alle velden; bij contacten ook labels,
toegewezen gebruiker, relatie, model, portaaltoegang en de extra formuliervelden als eigen kolom. Nieuwe
leadstatus Vervallen (telt als afgesloten).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:12:39 +02:00
MachielandClaude Opus 5.5 3cf53bc248 Inloggen (ook CalDAV) niet hoofdlettergevoelig: het ingevoerde e-mailadres gaat in kleine letters, zoals het wordt opgeslagen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:34:55 +02:00
MachielandClaude Opus 5.5 cad838f1f4 v0.0.5: gedeelde mailboxen (info@, facturen@, nieuwsbrief@, persoonlijk) met eigen IMAP/SMTP, afzender, rol en toegang per gebruiker; inbox per mailbox en versturen vanuit een gekozen mailbox; routines (jobs.py: types met @jobs.register, interval, instellingen, logboek, slot tegen dubbel draaien; app.py scheduler/tick) voor e-mail ophalen, nieuwsbrieven versturen en opschonen; meldingen (bel, bureaubladmelding); nieuwsbrieven met segmenten, testmail, inplannen en versturen per batch over één verbinding, afmeldlink met List-Unsubscribe en bounceverwerking; oude mailinstellingen worden de eerste mailbox
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:25:44 +02:00
MachielandClaude Opus 5.5 8f6e1ab443 Afzender met alleen een naam ('Machiel Heinen') wordt 'Naam <inlognaam>' (mail.sender): zonder adres weigerde de ontvangende server het bericht; zelfde afzender in de verstuurde mail in de CRM
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:06:12 +02:00
MachielandClaude Opus 5.5 366a3f9d17 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>
2026-10-16 17:29:47 +02:00
MachielandClaude Opus 5.5 81fc27fa86 v0.0.3: klant-PDF rekent met het volledige model (totalKg faalde met skip3D); Tectum (configurator) op eigen subdomein achter wachtwoord
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:01:22 +02:00
54 changed files with 5059 additions and 579 deletions
+94 -15
View File
@@ -4,26 +4,49 @@ Eenvoudige CRM in Python (Flask) met SQLite, in de Van Emous-huisstijl. Versie 0
## Functies
- **Leads en klanten** (zoals in EspoCRM): status (nieuw → in behandeling → offerte → gewonnen/verloren),
- **Leads en klanten** (zoals in EspoCRM): status (nieuw → in behandeling → offerte → gewonnen/verloren/vervallen),
toewijzen aan een gebruiker, verwachte waarde, bron, omzetten van lead naar klant, tijdlijn met
notities, e-mail, statuswissels en formulierinzendingen, bestanden en afbeeldingen, CSV-export.
- **Massa-acties en CSV-export** in de lijsten van contacten/leads, relaties, producten en offertes: rijen aanvinken
(shift-klik voor een reeks, of alles op de pagina) en in de balk onderin een actie kiezen. Contacten: status
wijzigen, toewijzen, omzetten naar klant, exporteren, verwijderen; relaties: type wijzigen, exporteren, verwijderen;
producten: actief/inactief, exporteren, verwijderen (in offertes gebruikt → inactief); offertes: status wijzigen
(met dezelfde gevolgen voor het contact als één voor één), exporteren, verwijderen. Statuswissels en toewijzingen
komen per contact in de tijdlijn. **Exporteer CSV** bovenaan neemt de filters en zoekterm van de lijst mee en levert
alle velden (`;`, BOM, voor Excel): bij contacten ook soort, statuslabel, toegewezen gebruiker, relatie, model,
portaaltoegang en elk extra formulierveld als eigen kolom (portaaltoken en wachtwoordhash nooit).
- **Relaties** (zoals accounts in EspoCRM), in twee soorten: **klanten** (bundels zoals een huishouden,
familie of bedrijf, met meerdere contactpersonen en hun rol) en **partners** met een type (leverancier,
aannemer, installateur, ...; de typen pas je aan bij Instellingen). Een lead omzetten naar klant zet de
persoon in een nieuwe of bestaande klantrelatie. Leveranciers van producten worden automatisch partners.
- **Gekozen model** per lead/contact: een woningontwerp uit de configurator (`house-design`-JSON, via het
formulier, de API of geüpload bij Bestanden) wordt het gekozen model; het nieuwste telt, een ander ontwerp
kies je op de contactpagina. Daar staat het in een **3D-viewer** op de engine van de configurator
(`/files/<id>/viewer`): 3D, doorsnede en plattegrond, met maten, m², inhoud, gewicht en een indicatieve
prijs (uit de kosten-CSV's van de configurator). In de leadlijst staat het model als kolom.
- **Documenten met versies** bij leads/klanten en bij relaties: elk document heeft een of meer versies (nieuwe
versie uploaden met een opmerking, oude versies blijven te openen en te verwijderen). Soorten: bestand, woning
en de tekeningen (werktekening, klant-PDF) van een woning. Bestanden van vóór v0.0.7 worden bij het starten
vanzelf versie 1 van een eigen document.
- **Woning** per lead/klant: een woningontwerp uit Tectum (`house-design`-JSON, via het formulier, de API, geüpload
bij Documenten of bewaard vanuit Tectum) is een woning met versies. Een nieuwe woning wordt de gekozen woning
(de nieuwste telt), een andere kies je op de contactpagina. Daar staat de nieuwste versie in een **3D-viewer** op
de engine van de configurator (`/files/<id>/viewer`, een oudere versie via de keuzelijst): 3D, doorsnede en
plattegrond, met maten, m², inhoud, gewicht en een indicatieve prijs (uit de kosten-CSV's van de configurator).
Zonder woning is het blok er niet. In de leadlijst staat de woning als kolom (Model).
- **Tectum** (in de stack): **Bewerken in Tectum** bij de woning opent hem op `tectum.<domein>/?woning=<id>`; daar
bewaart **Opslaan in CRM** een nieuwe versie. Tectum toont de woningen van leads en klanten ook bij Nieuw. De API
daarvoor is `/api/tectum/woningen` (lijst, `?q=`), `/api/tectum/woningen/<id>` (ontwerp, `?versie=`) en `POST
/api/tectum/woningen/<id>/versies` (`{ontwerp, opmerking, basis}`; 409 als er intussen een nieuwere versie is),
alleen met de sleutel `TECTUM_CRM_KEY` (Caddy stuurt die namens Tectum mee; leeg = uit). `TECTUM_URL` = adres
van Tectum voor de knop.
- **Kaart** bij een lead/klant (onder de woning) en een klantrelatie zodra het adres bekend is (straat met
huisnummer en postcode of plaats, of anders het adres uit het woningontwerp): de kaveltoets van de kaart-app
(`KAART_URL`, standaard `/kaart/`, in de stack op hetzelfde domein). Kwam de lead uit de kaveltoets, dan opent de
kaart die kavel met de getekende woningen. Zonder adres is het blok er niet.
Met **Lagen** in de viewer kies je een bouwfase (t/m regelwerk, elektra, beplating, …) of losse lagen
(regelwerk, elektra, kozijnen, dak, …), zoals Lagen in de configurator, zodat de ploeg de opbouw kan bekijken.
- **PDF's per ontwerp** (`HousePdf` van de configurator, gemaakt in de viewer en bewaard bij het contact): een
**werktekening** (alle tekenbladen, elektra, zaaglijst en kosten; intern, niet in het klantportaal) en een
**klant-PDF** zoals de light-configurator die maakt (`LightPdf`: voorblad met beeld, kerncijfers, keuzes, indicatieve
prijs en QR-code, dan aanzichten en plattegrond en de werkwijze; wel in het portaal). Ontbreken ze, dan maakt de viewer ze vanzelf bij
het eerste openen; opnieuw maken via PDF in de weergave. Op de contactpagina bij het gekozen model en op de
relatiepagina (Ontwerpen en tekeningen, voor alle contactpersonen).
prijs en QR-code, dan aanzichten en plattegrond en de werkwijze; wel in het portaal). Per woningversie: ontbreken
ze voor de nieuwste versie, dan maakt de viewer ze vanzelf bij het openen; opnieuw maken via PDF in de weergave.
Op de contactpagina bij de woning en op de relatiepagina (Woningen en tekeningen, voor alle contactpersonen).
- Op mobiel staan bij contact en relatie de gegevens bovenaan, de 3D-weergave verderop.
- **Taken/opvolging** per contact met datum en eigenaar; "Mijn taken" op het dashboard.
- **API voor formulieren** (`POST /api/leads`): JSON, gewone formulieren en multipart met bestanden.
@@ -33,8 +56,34 @@ Eenvoudige CRM in Python (Flask) met SQLite, in de Van Emous-huisstijl. Versie 0
Voorbeelden staan onder Instellingen.
- **Import** van CSV en JSON voor leads/contactpersonen, relaties en producten, met een voorbeeld en kolomkoppeling.
Het formaat van de configurator (`;`, komma-decimalen, BOM, `#`-regels) werkt direct.
- **Inbox**: IMAP ophalen en SMTP versturen, mails automatisch gekoppeld aan contacten (op e-mailadres),
contact aanmaken vanuit een mail, beantwoorden.
- **Mailboxen** (Instellingen › Mailboxen): meerdere accounts naast elkaar (info@, facturen@, nieuwsbrief@,
persoonlijk), elk met eigen IMAP/SMTP, afzender en rol (inbox, facturen, nieuwsbrief). Toegang per gebruiker;
zonder gekozen gebruikers is een mailbox gedeeld met iedereen. Voorinstellingen voor OVHcloud (Zimbra / MX Plan)
en Gmail, beveiliging per server, een kopie van verstuurde mail in een IMAP-map en een verbindingstest.
- **Inbox**: e-mail uit de eigen mailboxen (per mailbox of samen), automatisch gekoppeld aan contacten (op
e-mailadres), contact aanmaken vanuit een mail, beantwoorden vanuit dezelfde mailbox. Nieuwe e-mail opstellen
(ook doorsturen) met keuze van de mailbox, CC en bijlagen; mappen Verzonden en Archief (archiveren raakt de
mailbox op de server niet), zoeken, als ongelezen markeren en alles als gelezen markeren.
- **Meldingen**: bel in de bovenbalk (bijv. nieuwe e-mail in een mailbox, nieuwsbrief verzonden), met toestemming
ook als bureaubladmelding. Routines melden via `notify()`.
- **Routines** (beheer): terugkerende taken met een interval en eigen instellingen, met logboek en "Nu uitvoeren":
e-mail ophalen, nieuwsbrieven versturen, opschonen. Nieuwe soorten komen erbij met `@jobs.register` (zie
`jobs.py`). Ze draaien via `python app.py scheduler` (Docker: container `crm-routines`) of cron met
`python app.py tick`; een routine draait nooit twee keer tegelijk.
- **Nieuwsbrieven**: segmenten (filters op soort, status, bron, toegewezen, plaats, datum, ontwerp) en
nieuwsbrieven met velden per ontvanger, testmail, inplannen en versturen per batch over één verbinding (de
limiet van de mailserver blijft buiten bereik), pauzeren en stoppen. Elke mail heeft een afmeldlink en
`List-Unsubscribe` (één klik); afgemelde en onbestelbare adressen (afleverberichten in een mailbox) vallen buiten
elk segment, per adres gaat er één mail uit.
- **E-mailtemplates**: onderwerp en tekst met velden als `{voornaam}`, `{model}` en `{afzender}`, te kiezen bij een
nieuwe e-mail, een antwoord, op de pagina van een contact en bij een nieuwsbrief. Velden worden direct ingevuld als het contact bekend
is, anders bij het versturen (contact op het eerste adres in Aan).
- **Agenda**: een agenda per gebruiker, samen in een maandoverzicht met de taken die een datum hebben; afspraken
aan een contact koppelen (ook op de contactpagina en het dashboard), eenvoudige herhaling. Via **CalDAV**
(`/dav/`, ontdekking via `/.well-known/caldav`) zet elke gebruiker zijn agenda op iPhone, Mac, Android (DAVx⁵)
of in Thunderbird; inloggen met e-mailadres en een app-wachtwoord uit Mijn account.
- **Zoeken en Nieuw** in de bovenbalk: één zoekveld voor leads, contacten, relaties, offertes, afspraken,
producten en e-mail (live resultaten, `/` springt erheen), en een knop om direct iets nieuws aan te maken.
- **Producten** met SKU als vaste sleutel, prijs, btw, eenheid, leverancier (partner) en afbeelding.
Een import of `POST /api/products` werkt producten bij op SKU (alleen de meegegeven velden) en legt
elke mutatie vast met de oude en de nieuwe waarde. Optioneel worden producten die niet meer in de lijst
@@ -59,7 +108,7 @@ Eenvoudige CRM in Python (Flask) met SQLite, in de Van Emous-huisstijl. Versie 0
in `kosten/leveranciers.csv`). Ontbreekt het product, dan wordt het aangemaakt met de prijs per eenheid van de
berekening; bestaande productprijzen worden alleen bijgewerkt als je dat aanvinkt (met producthistorie).
De regels staan in `PRODUCT_RULES` in `static/bestek.js`.
- **Gebruikers** met de rollen beheerder (ook gebruikers en instellingen) en medewerker.
- **Gebruikers** met de rollen beheerder (ook gebruikers en instellingen) en medewerker; massa-actie actief/inactief maken of verwijderen (jezelf wordt overgeslagen). Verwijderen laat contacten, taken en activiteiten zonder eigenaar staan; agenda, meldingen en mailboxkoppelingen gaan mee.
- **Klantportaal** (`/portal`): de klant krijgt een uitnodigingslink (kopiëren of mailen vanaf het
contact), stelt een wachtwoord in en kan dan offertes bekijken, accepteren of afwijzen, bestanden
uploaden en berichten sturen. Een geaccepteerde offerte zet het contact op gewonnen/klant.
@@ -82,6 +131,14 @@ tools/configurator-sync.sh [../configurator] # kopieert js/, assets/, kosten/
Opnieuw draaien als er in de configurator iets aan de weergave verandert; `deploy.sh` doet dit zelf bij elke deploy.
Routines en e-mail vanaf de opdrachtregel:
```sh
.venv/bin/python app.py scheduler # blijft draaien en voert de routines uit die aan de beurt zijn
.venv/bin/python app.py tick # één keer (voor cron elke minuut)
.venv/bin/python app.py sync # nu e-mail ophalen uit alle actieve mailboxen
```
Demoleads (lorem ipsum, elk met een standaardmodel als gekozen model):
```sh
@@ -104,7 +161,7 @@ ssh ubuntu@SERVER sudo bash server/setup.sh crm.voorbeeld.nl jij@voorbeeld.nl
`server/setup.sh` installeert Python, nginx, certbot, rsync en sqlite3, zet de tijdzone op Europe/Amsterdam,
maakt de gebruiker `crm` met `/srv/vanemous-crm` (`releases/`, `current`, `data/`), de systemd-dienst
`vanemous-crm` (gunicorn op 127.0.0.1:5050, `CRM_PROXY=1`), de nginx-site met HTTPS, de firewall (SSH, 80, 443),
automatische beveiligingsupdates en cron (e-mail ophalen elke 10 minuten, elke nacht een databaseback-up, 7 dagen rond).
automatische beveiligingsupdates en cron (elke minuut de routines, elke nacht een databaseback-up, 7 dagen rond).
Een versie uitrollen (of terugrollen) vanaf je eigen machine:
@@ -126,6 +183,25 @@ Lokaal zoals op de server, maar zonder nginx: `./deploy.sh` (zie het script).
Back-ups buiten de server (bijv. de back-upoptie van OVH of een rsync van `/srv/vanemous-crm/data`) regel je apart:
de nachtelijke kopie staat op dezelfde schijf en dekt `uploads/` niet.
### Tectum (configurator) op dezelfde server
De configurator (`../configurator`) staat als statische site op een eigen subdomein, helemaal achter één
wachtwoord (nginx basic auth). Eenmalig, na `server/setup.sh`:
```sh
scp -r server ubuntu@SERVER:
ssh -t ubuntu@SERVER sudo bash server/tectum-setup.sh tectum.voorbeeld.nl jij@voorbeeld.nl [gebruikersnaam]
```
Dat vraagt het wachtwoord (gebruikersnaam standaard `vanemous`; wijzigen: `sudo WACHTWOORD=1 bash ...`), maakt
`/srv/www/tectum` (van `crm`, zodat releases via dezelfde SSH-toegang gaan) en de nginx-site met HTTPS.
Uitrollen of terugrollen vanaf je eigen machine, vanuit een tag van de configurator:
```sh
tools/tectum-release.sh crm@tectum.voorbeeld.nl # nieuwste tag
tools/tectum-release.sh crm@tectum.voorbeeld.nl v0.7.6 # bepaalde versie
```
## Opslag
Alles staat in `data/`: `crm.db` (SQLite), `uploads/` (bestanden), `secret.key` (sessies).
@@ -140,10 +216,13 @@ mailwachtwoorden in.
```
app.py routes en logica
importer.py CSV/JSON inlezen en kolommen herkennen
mail.py IMAP/SMTP
mail.py IMAP/SMTP (voorinstellingen, verbindingstest, kopie in Verzonden, bulk via Outbox, bounces)
jobs.py routines: types registreren en hun instellingen (uitvoeren: run_job in app.py)
ical.py iCalendar lezen/schrijven en herhalingen uitvouwen (agenda en CalDAV)
schema.sql database
templates/ pagina's (Jinja)
static/ style.css (huisstijltokens), fx.js (GSAP-effecten), logo.svg, viewer.js (3D-viewer), bestek.js
static/ style.css (huisstijltokens), fx.js (GSAP-effecten), search.js (zoekveld), notify.js (bel), compose.js
(e-mail opstellen), logo.svg, viewer.js (3D-viewer), bestek.js
static/configurator/ engine van de configurator (tools/configurator-sync.sh, niet in git)
tools/ configurator-sync.sh, export-models.js (standaardmodellen als JSON)
demo/modellen standaardmodellen als house-design-JSON voor de demoleads
+2435 -232
View File
File diff suppressed because it is too large Load Diff
+310
View File
@@ -0,0 +1,310 @@
"""iCalendar (RFC 5545) lezen en schrijven voor de agenda en CalDAV, alleen met de standaardbibliotheek.
Een afspraak is een dict met uid, title, location, description, starts_at, ends_at, all_day, rrule en exdates.
Tijden zijn lokaal (Europe/Amsterdam) als 'YYYY-MM-DD HH:MM:SS'; een hele dag is 'YYYY-MM-DD' met een
exclusief einde (de dag ná de laatste dag), zoals in iCalendar. exdates is een kommalijst in hetzelfde formaat.
"""
import calendar
import re
from datetime import date, datetime, timedelta, timezone
from zoneinfo import ZoneInfo
TZ_NAME = 'Europe/Amsterdam'
TZ = ZoneInfo(TZ_NAME)
# Outlook/Exchange sturen Windows-namen mee
WINDOWS_TZ = {'W. Europe Standard Time': TZ_NAME, 'Romance Standard Time': 'Europe/Brussels',
'Central Europe Standard Time': 'Europe/Budapest', 'GMT Standard Time': 'Europe/London',
'UTC': 'UTC'}
WEEKDAYS = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']
VTIMEZONE = """BEGIN:VTIMEZONE
TZID:Europe/Amsterdam
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE""".split('\n')
# ---------------------------------------------------------------- lezen
def _parse_line(line):
"""'NAAM;PARAM=waarde:inhoud' → (NAAM, {PARAM: waarde}, inhoud); dubbelepunten tussen aanhalingstekens tellen niet."""
quoted = False
for i, ch in enumerate(line):
if ch == '"':
quoted = not quoted
elif ch == ':' and not quoted:
head, value = line[:i], line[i + 1:]
break
else:
return None
parts = re.findall(r'(?:[^;"]|"[^"]*")+', head)
if not parts:
return None
params = {}
for p in parts[1:]:
k, _, v = p.partition('=')
params[k.strip().upper()] = v.strip().strip('"')
return parts[0].strip().upper(), params, value
def parse(text):
"""Componentenboom: [{'name': 'VCALENDAR', 'props': [(naam, params, waarde)], 'subs': [...]}]."""
root = {'name': None, 'props': [], 'subs': []}
stack = [root]
for raw in re.sub(r'\r?\n[ \t]', '', text).splitlines():
parsed = _parse_line(raw) if raw.strip() else None
if not parsed:
continue
name, params, value = parsed
if name == 'BEGIN':
comp = {'name': value.strip().upper(), 'props': [], 'subs': []}
stack[-1]['subs'].append(comp)
stack.append(comp)
elif name == 'END':
if len(stack) > 1:
stack.pop()
else:
stack[-1]['props'].append((name, params, value))
return root['subs']
def _prop(comp, name):
return next((p for p in comp['props'] if p[0] == name), None)
def unescape(value):
return re.sub(r'\\([\\;,nN])', lambda m: '\n' if m.group(1) in 'nN' else m.group(1), value)
def _zone(tzid):
tzid = WINDOWS_TZ.get(tzid, tzid)
for candidate in (tzid, '/'.join(tzid.split('/')[-2:])): # ook '/mozilla.org/20050126_1/Europe/Amsterdam'
try:
return ZoneInfo(candidate)
except (KeyError, ValueError, OSError):
continue
return None
def parse_dt(value, params):
"""iCalendar-datum of -tijd → (date, True) of (lokale naïeve datetime, False)."""
v = value.strip()
if params.get('VALUE', '').upper() == 'DATE' or re.fullmatch(r'\d{8}', v):
return date(int(v[:4]), int(v[4:6]), int(v[6:8])), True
m = re.fullmatch(r'(\d{8})T(\d{4,6})(Z?)', v)
if not m:
raise ValueError(f'onbekende datum {value!r}')
dt = datetime.strptime(m.group(1) + m.group(2).ljust(6, '0'), '%Y%m%d%H%M%S')
zone = timezone.utc if m.group(3) else (_zone(params['TZID']) if params.get('TZID') else None)
if zone:
dt = dt.replace(tzinfo=zone).astimezone(TZ).replace(tzinfo=None)
return dt, False
def parse_duration(value):
m = re.fullmatch(r'([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?', value.strip())
if not m:
return timedelta(0)
sign, w, d, h, mi, s = m.groups()
td = timedelta(weeks=int(w or 0), days=int(d or 0), hours=int(h or 0), minutes=int(mi or 0), seconds=int(s or 0))
return -td if sign == '-' else td
def fmt_local(value):
return value.isoformat() if not isinstance(value, datetime) else value.strftime('%Y-%m-%d %H:%M:%S')
def from_ics(text):
"""Eerste (hoofd)afspraak uit een kalenderobject, of None als er geen VEVENT in zit."""
events = [s for c in parse(text) if c['name'] == 'VCALENDAR' for s in c['subs'] if s['name'] == 'VEVENT']
if not events:
return None
ev = next((e for e in events if not _prop(e, 'RECURRENCE-ID')), events[0])
start_p = _prop(ev, 'DTSTART')
if not start_p:
return None
start, all_day = parse_dt(start_p[2], start_p[1])
end_p, dur_p = _prop(ev, 'DTEND'), _prop(ev, 'DURATION')
if end_p:
end, _ = parse_dt(end_p[2], end_p[1])
if all_day and isinstance(end, datetime):
end = end.date()
elif not all_day and not isinstance(end, datetime):
end = datetime.combine(end, datetime.min.time())
elif dur_p:
end = start + parse_duration(dur_p[2])
else:
end = start + timedelta(days=1) if all_day else start
if all_day and end <= start:
end = start + timedelta(days=1)
exdates = []
for name, params, value in ev['props']:
if name == 'EXDATE':
for v in value.split(','):
try:
exdates.append(fmt_local(parse_dt(v, params)[0]))
except ValueError:
pass
text_of = lambda n: unescape(_prop(ev, n)[2]) if _prop(ev, n) else ''
rrule = _prop(ev, 'RRULE')
return {'uid': text_of('UID').strip(), 'title': text_of('SUMMARY').strip(), 'location': text_of('LOCATION').strip(),
'description': text_of('DESCRIPTION').strip(), 'starts_at': fmt_local(start), 'ends_at': fmt_local(end),
'all_day': 1 if all_day else 0, 'rrule': rrule[2].strip() if rrule else '', 'exdates': ','.join(exdates)}
# ---------------------------------------------------------------- schrijven
def escape(value):
return (str(value or '').replace('\\', '\\\\').replace(';', '\\;').replace(',', '\\,')
.replace('\r\n', '\\n').replace('\n', '\\n'))
def _fold(line):
"""Regels langer dan 75 octetten vouwen (zonder een UTF-8-teken te breken)."""
out, cur, size = [], '', 0
for ch in line:
n = len(ch.encode('utf-8'))
if size + n > 75:
out.append(cur)
cur, size = ' ', 1
cur += ch
size += n
out.append(cur)
return '\r\n'.join(out)
def _ical_dt(value, all_day):
if all_day:
return f";VALUE=DATE:{value.replace('-', '')[:8]}"
return f";TZID={TZ_NAME}:{datetime.strptime(value, '%Y-%m-%d %H:%M:%S').strftime('%Y%m%dT%H%M%S')}"
def to_ics(ev, url=''):
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Van Emous//CRM//NL', 'CALSCALE:GREGORIAN']
if not ev['all_day']:
lines += VTIMEZONE
lines += ['BEGIN:VEVENT', f"UID:{ev['uid']}", f'DTSTAMP:{stamp}', f'LAST-MODIFIED:{stamp}',
'DTSTART' + _ical_dt(ev['starts_at'], ev['all_day']), 'DTEND' + _ical_dt(ev['ends_at'], ev['all_day']),
f"SUMMARY:{escape(ev['title'])}"]
if ev.get('location'):
lines.append(f"LOCATION:{escape(ev['location'])}")
if ev.get('description'):
lines.append(f"DESCRIPTION:{escape(ev['description'])}")
if url:
lines.append(f'URL:{url}')
if ev.get('rrule'):
lines.append(f"RRULE:{ev['rrule']}")
for x in filter(None, (ev.get('exdates') or '').split(',')):
lines.append('EXDATE' + _ical_dt(x, ev['all_day']))
lines += ['END:VEVENT', 'END:VCALENDAR']
return '\r\n'.join(_fold(l) for l in lines) + '\r\n'
# ---------------------------------------------------------------- herhalingen
def to_dt(value):
return datetime.strptime(value, '%Y-%m-%d %H:%M:%S') if len(value) > 10 else datetime.strptime(value, '%Y-%m-%d')
def parse_rrule(rrule):
return {k.upper(): v for k, _, v in (p.partition('=') for p in rrule.split(';') if p)}
def simple_freq(rrule):
"""'WEEKLY' voor een eenvoudige herhaling zoals het CRM-formulier hem maakt, anders '' (dan blijft hij staan)."""
r = parse_rrule(rrule or '')
return r['FREQ'] if set(r) == {'FREQ'} and r['FREQ'] in ('DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY') else ''
def _nth_weekday(year, month, n, wd):
days = [d for d in range(1, calendar.monthrange(year, month)[1] + 1) if date(year, month, d).weekday() == wd]
try:
return days[n - 1] if n > 0 else days[n]
except IndexError:
return None
def _candidates(start, rule):
freq, interval = rule.get('FREQ', ''), max(1, int(rule.get('INTERVAL') or 1))
byday = [d for d in (rule.get('BYDAY') or '').split(',') if d]
bymonthday = [int(d) for d in (rule.get('BYMONTHDAY') or '').split(',') if re.fullmatch(r'-?\d+', d)]
i = 0
while True:
if freq == 'DAILY':
yield start + timedelta(days=i * interval)
elif freq == 'WEEKLY':
week = start - timedelta(days=start.weekday()) + timedelta(weeks=i * interval)
days = sorted({WEEKDAYS.index(d[-2:]) for d in byday if d[-2:] in WEEKDAYS}) or [start.weekday()]
for d in days:
c = week + timedelta(days=d)
if c >= start:
yield c
elif freq == 'MONTHLY':
y, m = divmod(start.month - 1 + i * interval, 12)
y, m = start.year + y, m + 1
last = calendar.monthrange(y, m)[1]
if byday:
found = []
for spec in byday:
mm = re.fullmatch(r'([+-]?\d+)?(MO|TU|WE|TH|FR|SA|SU)', spec)
if mm:
day = _nth_weekday(y, m, int(mm.group(1) or 1), WEEKDAYS.index(mm.group(2)))
if day:
found.append(day)
else:
found = [d if d > 0 else last + d + 1 for d in (bymonthday or [start.day])]
for d in sorted(set(found)):
if 1 <= d <= last:
c = start.replace(year=y, month=m, day=d)
if c >= start:
yield c
elif freq == 'YEARLY':
try:
yield start.replace(year=start.year + i * interval)
except ValueError: # 29 februari
pass
else:
return
i += 1
if i > 5000:
return
def occurrences(ev, range_start, range_end):
"""Begin- en eindtijden (datetimes) van een afspraak, herhalingen uitgevouwen, die [range_start, range_end) raken."""
start, end = to_dt(ev['starts_at']), to_dt(ev['ends_at'] or ev['starts_at'])
dur = max(end - start, timedelta(0))
hits = lambda s: s < range_end and (s + dur > range_start if dur else s >= range_start)
if not ev.get('rrule'):
return [(start, start + dur)] if hits(start) else []
rule = parse_rrule(ev['rrule'])
count = int(rule['COUNT']) if (rule.get('COUNT') or '').isdigit() else None
until = None
if rule.get('UNTIL'):
try:
u, is_date = parse_dt(rule['UNTIL'], {})
until = datetime.combine(u, datetime.max.time()) if is_date else u
except ValueError:
pass
skip = {to_dt(x) for x in (ev.get('exdates') or '').split(',') if x}
out, n = [], 0
for c in _candidates(start, rule):
if (until and c > until) or c >= range_end or (count is not None and n >= count):
break
n += 1
if c not in skip and hits(c):
out.append((c, c + dur))
return out
+88
View File
@@ -0,0 +1,88 @@
"""Routines: terugkerende taken van de CRM (e-mail ophalen, nieuwsbrieven versturen, …).
Een routinetype is een functie met @register; een routine (tabel jobs) is een ingestelde instantie van een type
met een interval en eigen instellingen (config, JSON). Er kunnen meerdere routines van hetzelfde type zijn, bijv.
twee keer e-mail ophalen met andere mailboxen en een ander interval.
Uitvoeren: `python app.py scheduler` (blijft draaien, kijkt elke 30 s; de Docker-container crm-routines) of
`python app.py tick` (één keer, voor cron elke minuut). Een routine draait nooit twee keer tegelijk (running_since
werkt als slot; na STALE_MINUTES geldt een slot als achtergebleven na een crash).
Een nieuw type toevoegen, in app.py:
@jobs.register('rapport', 'Weekrapport', 'Mailt elke week de nieuwe leads.', interval=7 * 24 * 60,
fields=[jobs.Field('to', 'Naar', 'email')])
def job_rapport(job, config):
... # gewone app-context: db(), q(), notify(), mail…
return '12 leads gemeld' # komt in het logboek; een exception = status fout
Een standaardroutine die er altijd moet zijn, krijgt `default=True`: die maakt app.py bij het starten één keer
aan (actief, met het standaardinterval).
"""
from dataclasses import dataclass, field
STALE_MINUTES = 60
KEEP_RUNS = 200 # logregels per routine
UNITS = [(1, 'minuten'), (60, 'uur'), (24 * 60, 'dagen')]
@dataclass
class Field:
"""Instelling van een routinetype. kind: text, number, email, bool, mailboxes (meerkeuze uit de mailboxen)."""
key: str
label: str
kind: str = 'text'
default: object = ''
hint: str = ''
@dataclass
class JobType:
key: str
label: str
description: str
run: object
interval: int = 60 # standaardinterval in minuten
fields: list = field(default_factory=list)
default: bool = False # bij het starten één keer aanmaken
TYPES = {}
def register(key, label, description='', interval=60, fields=(), default=False):
def wrap(fn):
TYPES[key] = JobType(key, label, description, fn, interval, list(fields), default)
return fn
return wrap
def defaults(kind):
return {f.key: f.default for f in TYPES[kind].fields}
def clean_config(kind, form):
"""Instellingen uit een formulier (request.form) volgens de velden van het type."""
out = {}
for f in TYPES[kind].fields:
if f.kind == 'bool':
out[f.key] = bool(form.get(f.key))
elif f.kind == 'number':
try:
out[f.key] = int(form.get(f.key) or f.default or 0)
except ValueError:
out[f.key] = f.default
elif f.kind == 'mailboxes':
out[f.key] = [int(v) for v in form.getlist(f.key) if v.isdigit()]
else:
out[f.key] = (form.get(f.key) or '').strip()
return out
def interval_text(minutes):
for size, unit in reversed(UNITS):
if minutes % size == 0 and minutes >= size:
n = minutes // size
return {'minuten': f'{n} min', 'uur': f'{n} uur', 'dagen': f'{n} dag' + ('en' if n != 1 else '')}[unit]
return f'{minutes} min'
+194 -25
View File
@@ -1,10 +1,18 @@
"""IMAP ophalen en SMTP versturen, alleen met de standaardbibliotheek."""
"""IMAP ophalen en SMTP versturen, alleen met de standaardbibliotheek.
Beveiliging per server: 'ssl' (direct TLS, IMAP 993 / SMTP 465), 'starttls' (IMAP 143 / SMTP 587) of 'none';
leeg = afleiden uit de poort. Met smtp_sent_folder komt een kopie van verstuurde mail in die IMAP-map, voor
servers die dat zelf niet doen (Zimbra, MX Plan; Gmail doet het wel zelf).
"""
import email
import html
import imaplib
import re
import smtplib
import ssl
import time
from datetime import datetime, timedelta
from email.headerregistry import Address
from email.message import EmailMessage
from email.policy import default as policy
from email.utils import formatdate, make_msgid, parseaddr, parsedate_to_datetime
@@ -28,19 +36,120 @@ def _text(msg):
return re.sub(r'\n{3,}', '\n\n', content).strip()
def bounced(msg):
"""Adressen waarvoor een afleverbericht (DSN, multipart/report) meldt dat bezorgen definitief mislukt is."""
if msg.get_content_type() != 'multipart/report':
return []
out = []
for part in msg.walk():
if part.get_content_type() != 'message/delivery-status':
continue
for block in part.get_payload() or []:
if not hasattr(block, 'get'):
continue
action = str(block.get('Action', '')).strip().lower()
rcpt = str(block.get('Final-Recipient') or block.get('Original-Recipient') or '')
addr = rcpt.partition(';')[2].strip().strip('<>').lower()
if action == 'failed' and '@' in addr:
out.append(addr)
return out
# Voorinstellingen voor Instellingen › E-mail (de pagina vult de velden in; daarna blijft alles aanpasbaar).
PROVIDERS = {
'ovh': {'label': 'OVHcloud Zimbra / MX Plan (Europa)', 'imap_host': 'imap.mail.ovh.net', 'imap_port': '993',
'imap_security': 'ssl', 'smtp_host': 'smtp.mail.ovh.net', 'smtp_port': '465', 'smtp_security': 'ssl',
'smtp_sent_folder': 'Sent'},
'ovh-ca': {'label': 'OVHcloud Zimbra / MX Plan (Amerika, Azië)', 'imap_host': 'imap.mail.ovh.ca',
'imap_port': '993', 'imap_security': 'ssl', 'smtp_host': 'smtp.mail.ovh.ca', 'smtp_port': '465',
'smtp_security': 'ssl', 'smtp_sent_folder': 'Sent'},
'gmail': {'label': 'Gmail / Google Workspace (app-wachtwoord)', 'imap_host': 'imap.gmail.com', 'imap_port': '993',
'imap_security': 'ssl', 'smtp_host': 'smtp.gmail.com', 'smtp_port': '587', 'smtp_security': 'starttls',
'smtp_sent_folder': ''},
}
SECURITY = [('', 'Automatisch (uit de poort)'), ('ssl', 'SSL/TLS'), ('starttls', 'STARTTLS'), ('none', 'Geen')]
def _security(cfg, kind, ssl_port):
sec = (cfg.get(f'{kind}_security') or '').lower()
return sec if sec in ('ssl', 'starttls', 'none') else ('ssl' if int(cfg.get(f'{kind}_port') or ssl_port) == ssl_port else 'starttls')
def _connect_imap(cfg):
host, port = cfg['imap_host'], int(cfg.get('imap_port') or 993)
if port == 993:
conn = imaplib.IMAP4_SSL(host, port, timeout=30)
sec = _security(cfg, 'imap', 993)
if sec == 'ssl':
conn = imaplib.IMAP4_SSL(host, port, ssl_context=ssl.create_default_context(), timeout=30)
else:
conn = imaplib.IMAP4(host, port, timeout=30)
conn.starttls()
if sec == 'starttls':
conn.starttls(ssl.create_default_context())
conn.login(cfg['imap_user'], cfg.get('imap_pass', ''))
return conn
def fetch(cfg, known, limit=200):
"""Haal berichten van de laatste `imap_days` dagen op die nog niet in `known` staan."""
def _connect_smtp(cfg):
host, port = cfg.get('smtp_host'), int(cfg.get('smtp_port') or 587)
if not host:
raise RuntimeError('Geen SMTP-server ingesteld')
sec = _security(cfg, 'smtp', 465)
if sec == 'ssl':
server = smtplib.SMTP_SSL(host, port, timeout=30, context=ssl.create_default_context())
else:
server = smtplib.SMTP(host, port, timeout=30)
if sec == 'starttls':
server.starttls(context=ssl.create_default_context())
user = cfg.get('smtp_user') or cfg.get('imap_user', '')
if user:
server.login(user, cfg.get('smtp_pass') or cfg.get('imap_pass', ''))
return server
def _quote(folder):
return '"' + folder.replace('\\', '\\\\').replace('"', '\\"') + '"'
def folders(conn):
names = []
for line in conn.list()[1] or []:
m = re.match(rb'\((?P<flags>[^)]*)\) (?:"[^"]*"|NIL) (?P<name>.+)$', line or b'')
if m and b'\\noselect' not in m.group('flags').lower():
names.append(m.group('name').decode('utf-8', 'replace').strip().strip('"'))
return names
def test(cfg):
"""Verbindingen proberen zonder iets te versturen: [(gelukt, melding)]."""
out = []
if cfg.get('imap_host'):
try:
conn = _connect_imap(cfg)
try:
names = folders(conn)
folder = cfg.get('imap_folder') or 'INBOX'
out.append((True, f"IMAP: ingelogd op {cfg['imap_host']}; mappen: {', '.join(names[:12]) or '—'}"))
if conn.select(_quote(folder), readonly=True)[0] != 'OK':
out.append((False, f'IMAP: map "{folder}" niet gevonden'))
sent = cfg.get('smtp_sent_folder')
if sent and sent not in names:
out.append((False, f'IMAP: map voor verzonden mail "{sent}" niet gevonden'))
finally:
conn.logout()
except Exception as e:
out.append((False, f"IMAP: {e or type(e).__name__}"))
else:
out.append((False, 'IMAP: geen server ingesteld'))
try:
with _connect_smtp(cfg):
out.append((True, f"SMTP: ingelogd op {cfg['smtp_host']}"))
except Exception as e:
out.append((False, f"SMTP: {e or type(e).__name__}"))
return out
def fetch(cfg, known, limit=200, prefix=''):
"""Haal berichten van de laatste `imap_days` dagen op die nog niet in `known` staan. Sleutel (uid) =
prefix + map:uid, zodat meerdere mailboxen naast elkaar passen."""
folder = cfg.get('imap_folder') or 'INBOX'
days = int(cfg.get('imap_days') or 30)
since = datetime.now() - timedelta(days=days)
@@ -48,14 +157,14 @@ def fetch(cfg, known, limit=200):
conn = _connect_imap(cfg)
try:
typ, _ = conn.select(f'"{folder}"', readonly=True)
typ, _ = conn.select(_quote(folder), readonly=True)
if typ != 'OK':
raise RuntimeError(f'Map "{folder}" niet gevonden')
_, data = conn.uid('search', None, 'SINCE', since_str)
uids = data[0].split()[-limit:]
out = []
for uid in uids:
key = f'{folder}:{uid.decode()}'
key = f'{prefix}{folder}:{uid.decode()}'
if key in known:
continue
_, msgdata = conn.uid('fetch', uid, '(BODY.PEEK[])')
@@ -76,6 +185,7 @@ def fetch(cfg, known, limit=200):
'subject': str(msg.get('Subject', '')) or '(geen onderwerp)',
'body': _text(msg),
'date': date,
'bounced': bounced(msg),
})
return out
finally:
@@ -85,32 +195,91 @@ def fetch(cfg, known, limit=200):
pass
def send(cfg, to, subject, body, in_reply_to=None):
host = cfg.get('smtp_host')
if not host:
raise RuntimeError('Geen SMTP-server ingesteld')
def sender(cfg):
"""Afzender voor From: smtp_from, of de inlognaam. Alleen een naam in smtp_from ('Machiel Heinen') wordt
'Machiel Heinen <inlognaam>': zonder adres weigert de ontvangende server het bericht."""
user = cfg.get('smtp_user') or cfg.get('imap_user', '')
password = cfg.get('smtp_pass') or cfg.get('imap_pass', '')
port = int(cfg.get('smtp_port') or 587)
raw = (cfg.get('smtp_from') or '').strip()
name, addr = parseaddr(raw)
if '@' not in addr:
name, addr = raw, user
return str(Address(display_name=name, addr_spec=addr)) if name and addr else addr
def build(cfg, to, subject, body, in_reply_to=None, cc='', attachments=(), headers=None):
"""Een bericht opbouwen. attachments = [(bestandsnaam, bytes, mimetype)]; headers = extra kopregels."""
msg = EmailMessage()
msg['From'] = cfg.get('smtp_from') or user
msg['From'] = sender(cfg)
msg['To'] = to
if cc:
msg['Cc'] = cc
msg['Subject'] = subject
msg['Date'] = formatdate(localtime=True)
msg['Message-ID'] = make_msgid()
domain = parseaddr(msg['From'])[1].rpartition('@')[2] or None
msg['Message-ID'] = make_msgid(domain=domain)
if in_reply_to:
msg['In-Reply-To'] = in_reply_to
msg['References'] = in_reply_to
for key, value in (headers or {}).items():
msg[key] = value
msg.set_content(body)
for name, data, ctype in attachments:
maintype, _, subtype = (ctype or 'application/octet-stream').partition('/')
msg.add_attachment(data, maintype=maintype, subtype=subtype or 'octet-stream', filename=name)
return msg
if port == 465:
server = smtplib.SMTP_SSL(host, port, timeout=30)
else:
server = smtplib.SMTP(host, port, timeout=30)
server.starttls()
with server:
if user:
server.login(user, password)
def save_sent(cfg, msg):
"""Kopie in de map voor verzonden mail (smtp_sent_folder); geeft een waarschuwing of None."""
folder = cfg.get('smtp_sent_folder')
if not (folder and cfg.get('imap_host')):
return None
try:
conn = _connect_imap(cfg)
try:
typ, data = conn.append(_quote(folder), '\\Seen', imaplib.Time2Internaldate(time.time()), msg.as_bytes())
if typ != 'OK':
raise RuntimeError((data[0] or b'').decode('utf-8', 'replace') if data else typ)
finally:
conn.logout()
except Exception as e:
return f'Verstuurd, maar niet bewaard in "{folder}": {e}'
return None
def send(cfg, to, subject, body, in_reply_to=None, cc='', attachments=()):
"""Eén bericht versturen; geeft (Message-ID, waarschuwing of None). Een mislukte kopie in Verzonden is alleen een
waarschuwing."""
msg = build(cfg, to, subject, body, in_reply_to, cc, attachments)
with _connect_smtp(cfg) as server:
server.send_message(msg)
return msg['Message-ID']
return msg['Message-ID'], save_sent(cfg, msg)
class Outbox:
"""Veel berichten over één SMTP-verbinding (nieuwsbrieven), zonder kopie in Verzonden:
with mail.Outbox(cfg) as out:
out.send(mail.build(cfg, …))
Bij een verbroken verbinding wordt één keer opnieuw verbonden."""
def __init__(self, cfg):
self.cfg, self.server = cfg, None
def __enter__(self):
self.server = _connect_smtp(self.cfg)
return self
def send(self, msg):
try:
self.server.send_message(msg)
except smtplib.SMTPServerDisconnected:
self.server = _connect_smtp(self.cfg)
self.server.send_message(msg)
def __exit__(self, *exc):
try:
self.server.quit()
except Exception:
pass
+164 -1
View File
@@ -68,6 +68,23 @@ CREATE TABLE IF NOT EXISTS activities (
created_at TEXT NOT NULL
);
-- Documenten bij een contact (lead/klant) of relatie, elk met een of meer versies (tabel files). Soort: bestand,
-- woning (house-design-JSON uit Tectum; de nieuwste versie telt), werk/klant (PDF's van een woning: design_id).
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE,
relation_id INTEGER REFERENCES relations(id) ON DELETE CASCADE,
kind TEXT NOT NULL DEFAULT 'bestand',
title TEXT NOT NULL DEFAULT '',
design_id INTEGER REFERENCES documents(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_documents_contact ON documents(contact_id, kind);
CREATE INDEX IF NOT EXISTS idx_documents_relation ON documents(relation_id, kind);
-- Bestanden: elke rij is één versie van een document (document_id, version). source_file_id bij een PDF = de
-- woningversie waarvan hij gemaakt is. title/kind/design_id zijn van vóór de documenten (alleen nog voor de migratie).
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE,
@@ -145,10 +162,24 @@ CREATE TABLE IF NOT EXISTS emails (
subject TEXT DEFAULT '',
body TEXT DEFAULT '',
date TEXT NOT NULL,
is_read INTEGER NOT NULL DEFAULT 0
is_read INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
cc TEXT DEFAULT '',
attachments TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_emails_from ON emails(from_addr);
-- E-mailtemplates: onderwerp en tekst met velden als {voornaam}, ingevuld bij het opstellen of versturen
-- (MAIL_FIELDS in app.py).
CREATE TABLE IF NOT EXISTS email_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
subject TEXT DEFAULT '',
body TEXT DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE,
@@ -158,3 +189,135 @@ CREATE TABLE IF NOT EXISTS tasks (
done INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
-- Agenda: één agenda per gebruiker, ook via CalDAV (/dav/). `ics` is het kalenderobject zoals een
-- agenda-app het stuurde (of zoals de CRM het maakte), de losse kolommen zijn daaruit gehaald voor weergave.
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
uid TEXT NOT NULL,
href TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
location TEXT DEFAULT '',
description TEXT DEFAULT '',
starts_at TEXT NOT NULL,
ends_at TEXT NOT NULL,
all_day INTEGER NOT NULL DEFAULT 0,
rrule TEXT DEFAULT '',
exdates TEXT DEFAULT '',
ics TEXT NOT NULL,
etag TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_events_href ON events(user_id, href);
CREATE UNIQUE INDEX IF NOT EXISTS idx_events_uid ON events(user_id, uid);
CREATE INDEX IF NOT EXISTS idx_events_start ON events(starts_at);
CREATE INDEX IF NOT EXISTS idx_events_contact ON events(contact_id);
-- Mailboxen: elk een eigen IMAP/SMTP-account (info@, facturen@, nieuwsbrief@, persoonlijk). purpose = rol
-- (MAILBOX_PURPOSES in app.py); toegang via mailbox_users, zonder regels is de mailbox gedeeld met iedereen.
CREATE TABLE IF NOT EXISTS mailboxes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
purpose TEXT NOT NULL DEFAULT 'inbox',
active INTEGER NOT NULL DEFAULT 1,
notify INTEGER NOT NULL DEFAULT 1,
imap_host TEXT DEFAULT '', imap_port TEXT DEFAULT '', imap_security TEXT DEFAULT '', imap_user TEXT DEFAULT '',
imap_pass TEXT DEFAULT '', imap_folder TEXT DEFAULT '', imap_days TEXT DEFAULT '',
smtp_host TEXT DEFAULT '', smtp_port TEXT DEFAULT '', smtp_security TEXT DEFAULT '', smtp_user TEXT DEFAULT '',
smtp_pass TEXT DEFAULT '', smtp_sent_folder TEXT DEFAULT '', smtp_from TEXT DEFAULT '',
signature TEXT DEFAULT '',
last_sync TEXT,
last_error TEXT DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS mailbox_users (
mailbox_id INTEGER NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (mailbox_id, user_id)
);
-- Meldingen per gebruiker (bel in de bovenbalk); url = waar de melding heen leidt.
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT DEFAULT '',
title TEXT NOT NULL,
body TEXT DEFAULT '',
url TEXT DEFAULT '',
created_at TEXT NOT NULL,
read_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, read_at);
-- Routines (jobs.py): een type met eigen instellingen (config, JSON) en een interval in minuten.
-- running_since = slot tegen dubbel draaien.
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
name TEXT NOT NULL,
config TEXT NOT NULL DEFAULT '{}',
interval_minutes INTEGER NOT NULL DEFAULT 60,
active INTEGER NOT NULL DEFAULT 1,
next_run TEXT,
last_run TEXT,
last_status TEXT DEFAULT '',
last_message TEXT DEFAULT '',
running_since TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS job_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
started_at TEXT NOT NULL,
finished_at TEXT,
status TEXT NOT NULL,
message TEXT DEFAULT '',
manual INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_job_runs_job ON job_runs(job_id, started_at);
-- Segmenten: een opgeslagen selectie van contacten (filters als JSON, zie segment_where in app.py).
CREATE TABLE IF NOT EXISTS segments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
filters TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Nieuwsbrieven (bulkmail): bij het inplannen worden de ontvangers uit het segment vastgelegd; de routine
-- "Nieuwsbrieven versturen" verstuurt ze per batch. base_url = adres van de CRM voor de afmeldlink.
CREATE TABLE IF NOT EXISTS campaigns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
mailbox_id INTEGER REFERENCES mailboxes(id) ON DELETE SET NULL,
segment_id INTEGER REFERENCES segments(id) ON DELETE SET NULL,
subject TEXT DEFAULT '',
body TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'concept',
scheduled_at TEXT,
started_at TEXT,
finished_at TEXT,
base_url TEXT DEFAULT '',
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS campaign_recipients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
email TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'wacht',
error TEXT DEFAULT '',
sent_at TEXT,
UNIQUE (campaign_id, email)
);
CREATE INDEX IF NOT EXISTS idx_campaign_recipients ON campaign_recipients(campaign_id, status);
+5 -4
View File
@@ -10,7 +10,8 @@
# Wat het doet: pakketten (Python, nginx, certbot, rsync, sqlite3), tijdzone Europe/Amsterdam, gebruiker `crm`
# met /srv/vanemous-crm, SSH-toegang voor releases (zelfde sleutels als de sudo-gebruiker), systemd-dienst,
# nginx-site met HTTPS (Let's Encrypt), firewall (alleen SSH, 80, 443), automatische beveiligingsupdates,
# cron voor e-mail ophalen (elke 10 min) en een dagelijkse databaseback-up (7 dagen rond).
# cron voor de routines (elke minuut `app.py tick`: e-mail ophalen, nieuwsbrieven, …) en een dagelijkse
# databaseback-up (7 dagen rond).
set -euo pipefail
DOMAIN="${1:-}"
@@ -100,11 +101,11 @@ else
say "let op: certificaat mislukt. Klopt de DNS? Daarna opnieuw: sudo certbot --nginx -d $DOMAIN -m $EMAIL --agree-tos --redirect"
fi
# --- cron: e-mail en back-up -------------------------------------------------------------------
say "cron: e-mail elke 10 minuten, database-back-up elke nacht"
# --- cron: routines en back-up -----------------------------------------------------------------
say "cron: routines elke minuut, database-back-up elke nacht"
cat > /etc/cron.d/$APP <<EOF
# Van Emous CRM (server/setup.sh)
*/10 * * * * crm [ -d $ROOT/current ] && cd $ROOT/current && CRM_DATA=$ROOT/data .venv/bin/python app.py sync 2>&1 | logger -t $APP-sync
* * * * * crm [ -d $ROOT/current ] && cd $ROOT/current && CRM_DATA=$ROOT/data .venv/bin/python app.py tick 2>&1 | logger -t $APP-routines
15 3 * * * crm [ -f $ROOT/data/crm.db ] && sqlite3 $ROOT/data/crm.db ".backup '$ROOT/data/backups/nacht-\$(date +\%a).db'"
EOF
chmod 644 /etc/cron.d/$APP
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Eenmalig op de server (na server/setup.sh): Tectum, de configurator, als statische site achter een wachtwoord.
#
# scp -r server ubuntu@SERVER:
# ssh -t ubuntu@SERVER sudo bash server/tectum-setup.sh tectum.voorbeeld.nl jij@voorbeeld.nl [gebruikersnaam]
#
# Vraagt het wachtwoord als die gebruiker nog niet bestaat; met WACHTWOORD=1 ervoor wijzig je het:
# ssh -t ubuntu@SERVER sudo WACHTWOORD=1 bash server/tectum-setup.sh tectum.voorbeeld.nl jij@voorbeeld.nl
# Daarna rolt tools/tectum-release.sh (op je eigen machine) een versie uit naar crm@SERVER.
# Opnieuw draaien kan: elke stap controleert wat er al is.
set -euo pipefail
DOMAIN="${1:-}"
EMAIL="${2:-}"
LOGIN="${3:-vanemous}"
ROOT=/srv/www/tectum
SITE=tectum
HTPASSWD=/etc/nginx/tectum.htpasswd
HERE="$(cd "$(dirname "$0")" && pwd)"
say() { printf '\033[38;2;185;89;19m▸\033[0m %s\n' "$*"; }
fail() { printf 'Fout: %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" = 0 ] || fail "draai dit met sudo"
[ -n "$DOMAIN" ] && [ -n "$EMAIL" ] || fail "gebruik: sudo bash tectum-setup.sh <domein> <e-mail voor Let's Encrypt> [gebruikersnaam]"
id crm >/dev/null 2>&1 || fail "gebruiker crm bestaat niet; draai eerst server/setup.sh"
command -v nginx >/dev/null || fail "nginx ontbreekt; draai eerst server/setup.sh"
# --- map: releases komen binnen als crm (zelfde SSH-toegang als de CRM-releases) ----------------
say "map $ROOT"
install -d -o root -g root -m 755 /srv/www
install -d -o crm -g crm -m 755 "$ROOT" "$ROOT/releases"
# --- wachtwoord ----------------------------------------------------------------------------------
touch "$HTPASSWD"
chown root:www-data "$HTPASSWD"; chmod 640 "$HTPASSWD"
if [ -n "${WACHTWOORD:-}" ] || ! grep -q "^$LOGIN:" "$HTPASSWD"; then
say "wachtwoord voor gebruiker $LOGIN"
read -rsp "Wachtwoord: " pw1 </dev/tty; echo
read -rsp "Nog een keer: " pw2 </dev/tty; echo
[ -n "$pw1" ] || fail "leeg wachtwoord"
[ "$pw1" = "$pw2" ] || fail "wachtwoorden verschillen"
hash="$(printf '%s' "$pw1" | openssl passwd -6 -stdin)"
{ grep -v "^$LOGIN:" "$HTPASSWD" || true; printf '%s:%s\n' "$LOGIN" "$hash"; } > "$HTPASSWD.new"
chown root:www-data "$HTPASSWD.new"; chmod 640 "$HTPASSWD.new"
mv "$HTPASSWD.new" "$HTPASSWD"
fi
# --- nginx -------------------------------------------------------------------------------
say "nginx voor $DOMAIN"
if [ ! -f /etc/nginx/sites-available/$SITE ] || ! grep -q "listen 443" /etc/nginx/sites-available/$SITE; then
sed "s/__DOMAIN__/$DOMAIN/g" "$HERE/tectum-site.conf" > /etc/nginx/sites-available/$SITE
fi
ln -sfn /etc/nginx/sites-available/$SITE /etc/nginx/sites-enabled/$SITE
nginx -t
systemctl reload nginx
# --- HTTPS -------------------------------------------------------------------------------
say "certificaat aanvragen (DNS van $DOMAIN moet al naar deze server wijzen)"
if certbot --nginx -d "$DOMAIN" -m "$EMAIL" --agree-tos --non-interactive --redirect; then
say "HTTPS actief; verlengen gaat automatisch (certbot.timer)"
else
say "let op: certificaat mislukt. Klopt de DNS? Daarna opnieuw: sudo certbot --nginx -d $DOMAIN -m $EMAIL --agree-tos --redirect"
fi
say "klaar. Rol nu vanaf je eigen machine een versie uit: tools/tectum-release.sh crm@$DOMAIN"
+28
View File
@@ -0,0 +1,28 @@
# Tectum (configurator) als statische site achter één wachtwoord (server/tectum-setup.sh vult __DOMAIN__ in;
# certbot voegt HTTPS en de doorverwijzing toe; de ACME-controle valt niet onder auth_basic).
server {
listen 80;
listen [::]:80;
server_name __DOMAIN__;
root /srv/www/tectum/current;
index index.html;
auth_basic "Tectum";
auth_basic_user_file /etc/nginx/tectum.htpasswd;
add_header X-Robots-Tag "noindex, nofollow" always;
location / {
try_files $uri $uri/ =404;
# steeds even navragen zodat een nieuwe versie meteen zichtbaar is
add_header Cache-Control "no-cache";
add_header X-Robots-Tag "noindex, nofollow" always;
}
location /assets/ {
access_log off;
add_header Cache-Control "no-cache";
add_header X-Robots-Tag "noindex, nofollow" always;
}
}
+57
View File
@@ -0,0 +1,57 @@
/* Massa-acties in lijsten (templates/_bulk.html): rijen aanvinken, shift-klik voor een reeks,
dan in de balk onderin een actie kiezen. Een klik op het vinkje opent de rij niet. */
(() => {
const form = document.getElementById('bulk');
if (!form) return;
const boxes = Array.from(document.querySelectorAll('input[name="ids"][form="bulk"]'));
const all = document.querySelector('[data-bulk-all]');
const count = form.querySelector('[data-bulk-count]');
const action = form.elements.action;
let last = null;
const update = () => {
const n = boxes.filter(b => b.checked).length;
count.textContent = n;
form.hidden = !n;
document.body.classList.toggle('bulk-open', n > 0);
if (all) {
all.checked = n > 0 && n === boxes.length;
all.indeterminate = n > 0 && n < boxes.length;
}
boxes.forEach(b => b.closest('tr')?.classList.toggle('selected', b.checked));
};
const showFields = () => form.querySelectorAll('[data-show]').forEach(el => {
const on = el.dataset.show === action.value;
el.hidden = !on;
el.disabled = !on;
});
boxes.forEach((box, i) => box.closest('td').addEventListener('click', e => {
e.stopPropagation();
if (e.target !== box) box.checked = !box.checked;
if (e.shiftKey && last !== null) {
boxes.slice(Math.min(last, i), Math.max(last, i) + 1).forEach(b => { b.checked = box.checked; });
}
last = i;
update();
}));
all?.closest('th').addEventListener('click', e => {
if (e.target !== all) all.checked = !all.checked;
boxes.forEach(b => { b.checked = all.checked; });
update();
});
form.querySelector('[data-bulk-clear]').addEventListener('click', () => {
boxes.forEach(b => { b.checked = false; });
update();
});
action.addEventListener('change', showFields);
form.addEventListener('submit', e => {
const ask = action.selectedOptions[0]?.dataset.confirm;
if (ask && !confirm(ask.replace('{n}', count.textContent))) return e.preventDefault();
// een export is een download: de pagina blijft staan, dus de keuze terugzetten
if (action.value === 'export') setTimeout(() => { action.value = ''; showFields(); }, 0);
});
addEventListener('pageshow', update);
showFields();
update();
})();
+27
View File
@@ -0,0 +1,27 @@
// E-mail opstellen (templates/_compose.html): een template zet zijn tekst boven het bericht (of vervangt de tekst die
// de vorige template daar zette) en vult een leeg onderwerp. Velden als {voornaam} worden ingevuld met data-vars; wat
// overblijft, vult de server bij het versturen in.
document.querySelectorAll('form.compose').forEach((form) => {
const pick = form.querySelector('select[name="template"]');
if (!pick) return;
const vars = JSON.parse(form.dataset.vars || '{}');
const fill = (text) => text.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? vars[k] : m));
const subject = form.elements.subject, body = form.elements.body;
let added = '', addedSubject = '';
function apply() {
const o = pick.selectedOptions[0];
const on = o && o.value;
const rest = (added && body.value.startsWith(added) ? body.value.slice(added.length) : body.value).replace(/^\n+/, '');
const text = on ? fill(o.dataset.body || '') : '';
added = text && rest ? `${text}\n\n` : text;
body.value = added + rest;
if (!subject.value.trim() || subject.value === addedSubject) {
addedSubject = on ? fill(o.dataset.subject || '') : '';
subject.value = addedSubject;
}
body.focus();
body.setSelectionRange(text.length, text.length);
}
pick.addEventListener('change', apply);
if (pick.value) apply();
});
+32
View File
@@ -0,0 +1,32 @@
// Bel in de bovenbalk: elke minuut het aantal ongelezen meldingen ophalen (/notifications/poll). Met toestemming
// van de browser verschijnen nieuwe meldingen ook als bureaubladmelding (zolang de CRM ergens open staat).
(() => {
const bell = document.querySelector('.bell[data-poll]');
if (!bell) return;
const count = bell.querySelector('.bell-count');
const desktop = bell.querySelector('.bell-desktop');
let last = Number(bell.dataset.last) || 0;
const canNotify = 'Notification' in window;
if (canNotify && Notification.permission === 'default') {
desktop.hidden = false;
desktop.addEventListener('click', () => Notification.requestPermission().then(() => { desktop.hidden = true; }));
}
async function poll() {
try {
const r = await fetch(`${bell.dataset.poll}?after=${last}`, { headers: { Accept: 'application/json' } });
if (!r.ok) return;
const d = await r.json();
count.textContent = d.count;
count.hidden = !d.count;
d.items.slice().reverse().forEach((n) => {
last = Math.max(last, n.id);
if (canNotify && Notification.permission === 'granted') {
const note = new Notification(n.title, { body: n.body || '', tag: `crm-${n.id}` });
note.onclick = () => { window.focus(); location = n.url; };
}
});
} catch (e) { /* offline of uitgelogd: volgende keer opnieuw */ }
}
setInterval(() => { if (!document.hidden) poll(); }, 60000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) poll(); });
})();
+58
View File
@@ -0,0 +1,58 @@
/* Zoeken in de bovenbalk: live resultaten (search_live) onder het veld; Enter opent de volledige zoekpagina.
* Pijltjes lopen door de resultaten, Escape sluit, "/" springt naar het zoekveld. */
(() => {
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
document.querySelectorAll('form.tsearch').forEach((form) => {
const input = form.querySelector('input[name=q]');
const box = form.querySelector('.tsearch-results');
let timer = null, ctrl = null, last = '';
const items = () => [...box.querySelectorAll('a')];
const close = () => { box.classList.remove('open'); input.setAttribute('aria-expanded', 'false'); };
const open = () => { if (box.innerHTML) { box.classList.add('open'); input.setAttribute('aria-expanded', 'true'); } };
const render = (data) => {
box.innerHTML = data.groups.length
? data.groups.map((g) => `<div class="ts-group">${esc(g.label)}</div>` + g.items.map((i) =>
`<a class="ts-item" role="option" href="${esc(i.url)}">${esc(i.title)}${i.sub ? `<small>${esc(i.sub)}</small>` : ''}</a>`).join('')).join('')
+ `<a class="ts-all" href="${esc(data.url)}">Alle resultaten voor “${esc(data.q)}”</a>`
: `<div class="ts-empty">Niets gevonden voor “${esc(data.q)}”.</div>`;
open();
};
input.addEventListener('input', () => {
clearTimeout(timer);
const v = input.value.trim();
if (v.length < 2) { box.innerHTML = ''; close(); return; }
timer = setTimeout(() => {
if (v === last && box.innerHTML) { open(); return; }
if (ctrl) ctrl.abort();
ctrl = new AbortController();
fetch(`${form.dataset.live}?q=${encodeURIComponent(v)}`, { signal: ctrl.signal, headers: { Accept: 'application/json' } })
.then((r) => (r.ok ? r.json() : null))
.then((d) => { if (d && input.value.trim() === v) { last = v; render(d); } })
.catch(() => {});
}, 180);
});
form.addEventListener('keydown', (e) => {
const list = items(), at = list.indexOf(document.activeElement);
if (e.key === 'ArrowDown' && list.length) { e.preventDefault(); open(); list[Math.min(at + 1, list.length - 1)].focus(); }
else if (e.key === 'ArrowUp' && list.length) { e.preventDefault(); (at <= 0 ? input : list[at - 1]).focus(); }
else if (e.key === 'Escape') { close(); input.focus(); }
});
input.addEventListener('focus', () => { if (input.value.trim().length >= 2) open(); });
document.addEventListener('click', (e) => { if (!form.contains(e.target)) close(); });
// mobiel: het veld krijgt de focus zodra het uitklapmenu opengaat
const toggle = form.closest('.dropdown')?.querySelector('[data-bs-toggle="dropdown"]');
if (toggle) toggle.addEventListener('shown.bs.dropdown', () => input.focus());
});
document.addEventListener('keydown', (e) => {
if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey || e.target.closest('input, textarea, select, [contenteditable]')) return;
const input = [...document.querySelectorAll('form.tsearch input[name=q]')].find((i) => i.offsetParent);
if (input) { e.preventDefault(); input.focus(); input.select(); }
});
})();
+136 -2
View File
@@ -72,15 +72,41 @@ a:focus-visible, button:focus-visible, .btn:focus-visible { outline: 3px solid v
.active .count { background: var(--white); }
.topbar { position: sticky; top: 0; z-index: 1020; background: var(--white); border-bottom: 1px solid var(--line); padding: 0.6rem 1rem; display: flex; align-items: center; gap: 0.75rem; }
.topbar img { width: 120px; display: block; }
.topbar-logo img { width: 120px; display: block; }
.topbar .menu-btn { border: 1px solid var(--line); background: var(--white); border-radius: var(--radius-pill); padding: 0.45rem 0.9rem; display: inline-flex; align-items: center; gap: 0.5rem; font-weight: 600; color: var(--ink); }
.topbar .menu-btn svg { width: 20px; height: 20px; }
.topbar .count { margin-left: auto; }
.topbar-tools { margin-left: auto; display: flex; align-items: center; gap: 0.5rem; }
.icon-btn { width: 40px; height: 40px; border: 1px solid var(--line); background: var(--white); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: var(--ink); padding: 0; }
.icon-btn svg { width: 20px; height: 20px; }
.icon-btn:hover, .icon-btn[aria-expanded="true"] { background: var(--orange-tint-soft); border-color: var(--orange-line); }
.new-btn { --bs-btn-padding-x: 0.9rem; height: 40px; }
.new-btn svg { width: 18px; height: 18px; }
@media (max-width: 575.98px) { .new-btn { width: 40px; padding: 0; } }
.new-menu { --bs-dropdown-link-active-bg: var(--orange); --bs-dropdown-border-color: var(--line); --bs-dropdown-border-radius: var(--radius-md); min-width: 12rem; padding: 0.4rem; }
.new-menu .dropdown-item { border-radius: var(--radius-sm); padding: 0.45rem 0.8rem; font-weight: 600; }
.new-menu .dropdown-item:hover { background: var(--orange-tint-soft); }
/* zoeken: live resultaten onder het veld (desktop) of in het uitklapmenu (mobiel) */
.tsearch { position: relative; }
.tsearch-icon { position: absolute; left: 0.8rem; top: 50%; width: 18px; height: 18px; transform: translateY(-50%); color: var(--ink-muted); pointer-events: none; }
.tsearch input { padding-left: 2.4rem; border-radius: var(--radius-pill); width: 340px; max-width: 100%; padding-top: 0.45rem; padding-bottom: 0.45rem; }
.tsearch-results { display: none; position: absolute; right: 0; top: calc(100% + 0.4rem); width: min(440px, 90vw); max-height: 70vh; overflow-y: auto; background: var(--white); border: 1px solid var(--line); border-radius: var(--radius-md); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12); padding: 0.4rem; z-index: 1030; }
.tsearch-results.open { display: block; }
.ts-group { font-size: 0.72rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); padding: 0.5rem 0.6rem 0.2rem; }
.ts-item { display: block; padding: 0.4rem 0.6rem; border-radius: var(--radius-sm); color: var(--ink); line-height: 1.3; }
.ts-item small { display: block; color: var(--ink-muted); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ts-item:hover, .ts-item:focus { background: var(--orange-tint-soft); text-decoration: none; outline: none; }
.ts-all { display: block; text-align: center; padding: 0.5rem; border-top: 1px solid var(--line); margin-top: 0.3rem; }
.ts-empty { color: var(--ink-muted); padding: 0.6rem; }
.tsearch-pop { width: calc(100vw - 2rem); padding: 0.75rem; border-radius: var(--radius-md); }
.tsearch-pop .tsearch input { width: 100%; }
.tsearch-pop .tsearch-results { position: static; width: auto; box-shadow: none; border: 0; padding: 0.4rem 0 0; max-height: 60vh; }
.app-main { flex: 1; min-width: 0; }
.content { padding: 1.25rem 1rem 2rem; max-width: 1400px; }
@media (min-width: 992px) {
.topbar { padding: 0.75rem 2.5rem; }
.app { display: flex; min-height: 100vh; }
.side { width: 240px; flex: none; position: sticky; top: 0; height: 100vh; background: var(--white); border-right: 1px solid var(--line); padding: 1.5rem 1rem; display: flex; flex-direction: column; }
.side-panel { display: flex; flex-direction: column; }
@@ -222,6 +248,30 @@ dl.props dd { margin: 0; overflow-wrap: anywhere; white-space: pre-line; }
.file .muted { padding: 0 0.5rem 0.35rem; font-size: 0.8rem; }
.viewer-frame { aspect-ratio: 16 / 10; min-height: 320px; border: 1px solid var(--line); border-radius: var(--radius-md); overflow: hidden; background: var(--blue); }
.viewer-frame iframe { display: block; width: 100%; height: 100%; border: 0; }
.kaart-frame { height: 520px; max-height: 75vh; border: 1px solid var(--line); border-radius: var(--radius-md); overflow: hidden; background: var(--blue); }
.kaart-frame iframe { display: block; width: 100%; height: 100%; border: 0; }
.kavel-meters { display: flex; flex-wrap: wrap; gap: 0.25rem 2rem; margin: 0 0 1rem; }
.kavel-meters[hidden] { display: none; }
.kavel-meters > div { display: flex; gap: 0.5rem; }
.kavel-meters dt { color: var(--ink-muted); font-weight: 400; }
.kavel-meters dd { margin: 0; }
/* documenten met versies (contact, relatie, portaal) */
.docs { margin-bottom: 1rem; }
.doc { border-top: 1px solid var(--line); }
.doc:first-child { border-top: 0; }
.doc > summary { list-style: none; display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0; font: inherit; }
.doc > summary::-webkit-details-marker { display: none; }
.doc[open] > summary { margin-bottom: 0.25rem; }
.doc .doc-icon { flex: 0 0 40px; height: 40px; border-radius: 10px; background: var(--blue); display: flex; align-items: center; justify-content: center; overflow: hidden; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; color: var(--ink); }
.doc .doc-icon img { width: 100%; height: 100%; object-fit: cover; }
.doc .doc-main { min-width: 0; flex: 1; }
.doc .doc-title { display: block; color: var(--ink); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.doc .doc-main .muted { display: block; font-size: 0.8rem; }
.doc .versions { list-style: none; padding: 0 0 0 52px; margin: 0 0 0.5rem; font-size: 0.85rem; }
.doc .versions li { display: flex; flex-wrap: wrap; gap: 0 0.5rem; align-items: baseline; padding: 0.2rem 0; }
.doc .versions form { margin-left: auto; }
.doc .doc-forms { padding: 0 0 0.75rem 52px; }
.doc .doc-forms .actions { margin-top: 0.5rem; }
details summary { cursor: pointer; font-family: var(--font-heading); font-size: 1.3rem; font-weight: 500; }
details[open] summary { margin-bottom: 1rem; }
.mailbody { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 15px; line-height: 1.7; }
@@ -301,3 +351,87 @@ tr.link td { transition: background-color 0.2s; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition: none !important; animation: none !important; }
}
/* ---------- agenda ---------- */
.u0 { --u: #b95913; } .u1 { --u: #3f6e9e; } .u2 { --u: #5b8a3a; } .u3 { --u: #8a4f9e; } .u4 { --u: #a33f4a; } .u5 { --u: #2f7f7b; }
.cal { width: 100%; table-layout: fixed; border-collapse: collapse; }
.cal th { font-size: 0.75rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); padding: 0 0.4rem 0.4rem; }
.cal td { border: 1px solid var(--line); vertical-align: top; height: 7.5rem; padding: 0.3rem; }
.cal td.out { background: var(--ultra-light); }
.cal td.out .cal-num { color: var(--control); }
.cal-num { display: inline-flex; align-items: center; justify-content: center; min-width: 1.8rem; height: 1.8rem; border-radius: 50%; color: var(--ink); font-weight: 600; font-size: 0.875rem; }
.cal-num:hover { background: var(--orange-tint-soft); text-decoration: none; }
.cal td.today .cal-num { background: var(--orange); color: var(--white); }
.cal-ev { display: block; font-size: 0.8rem; line-height: 1.25; padding: 0.15rem 0.4rem; margin-top: 0.15rem; border-radius: 0.35rem; color: var(--ink); background: color-mix(in srgb, var(--u, #b95913) 12%, white); border-left: 3px solid var(--u, #b95913); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cal-ev:hover { text-decoration: none; background: color-mix(in srgb, var(--u, #b95913) 22%, white); }
.cal-ev .t { font-weight: 400; color: var(--ink-muted); margin-right: 0.25rem; }
.cal-ev.task { background: var(--white); border: 1px dashed var(--control); border-left-width: 1px; font-weight: 400; }
.cal-ev.allday { background: var(--u, #b95913); color: var(--white); }
.cal-ld { display: flex; gap: 0.9rem; padding: 0.7rem 0; border-bottom: 1px solid var(--line); }
.cal-ld:last-child { border-bottom: 0; }
.cal-ldate { width: 3.2rem; flex: none; text-align: center; line-height: 1.1; }
.cal-ldate b { display: block; font-family: var(--font-heading); font-size: 1.4rem; font-weight: 600; }
.cal-ldate span { font-size: 0.75rem; color: var(--ink-muted); text-transform: uppercase; letter-spacing: 0.06em; }
.cal-ld.today .cal-ldate b { color: var(--orange); }
.cal-ld .cal-items { flex: 1; min-width: 0; }
.cal-ld .cal-ev { white-space: normal; font-size: 0.9rem; padding: 0.35rem 0.6rem; margin: 0 0 0.3rem; }
.cal-legend { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; font-size: 0.85rem; color: var(--ink-muted); margin-top: 0.75rem; }
.cal-legend span::before { content: ""; display: inline-block; width: 0.7rem; height: 0.7rem; border-radius: 3px; background: var(--u); margin-right: 0.35rem; vertical-align: -1px; }
.agenda-row { display: flex; gap: 0.75rem; padding: 0.55rem 0; border-bottom: 1px solid var(--line); align-items: baseline; }
.agenda-row:last-child { border-bottom: 0; }
.agenda-row .when { width: 7.5rem; flex: none; color: var(--ink-muted); font-size: 0.875rem; }
.dav-box code { word-break: break-all; }
.dav-pass { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1.15rem; letter-spacing: 0.04em; background: var(--orange-tint-soft); border: 1px solid var(--orange-line); border-radius: var(--radius-sm); padding: 0.5rem 0.8rem; display: inline-block; }
/* ---------- afdrukken: alleen de inhoud (offerte, bestek) ---------- */
@media print {
body { background: #fff; }
.side, .topbar, .flash, .no-print { display: none !important; }
.app { display: block; }
.content { padding: 0; max-width: none; }
}
/* E-mailtemplates: velden om in te voegen */
.mail-fields { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.4rem; }
.mail-fields li { display: flex; align-items: center; gap: 0.6rem; font-size: 0.9rem; }
.mail-fields .btn { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; flex: none; }
/* Meldingen (bel in de bovenbalk) */
.bell .icon-btn { position: relative; }
.bell-count { position: absolute; top: -4px; right: -4px; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: var(--orange); color: var(--white); font-size: 0.7rem; font-weight: 700; line-height: 18px; text-align: center; }
.bell-menu { width: min(360px, calc(100vw - 2rem)); padding: 0; overflow: hidden; }
.bell-head, .bell-foot { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 0.6rem 1rem; }
.bell-head { border-bottom: 1px solid var(--line); }
.bell-foot { border-top: 1px solid var(--line); font-size: 0.875rem; }
.bell-menu .dropdown-item { display: flex; flex-direction: column; white-space: normal; padding: 0.6rem 1rem; border-bottom: 1px solid var(--line); }
.bell-menu .dropdown-item span { color: var(--ink-muted); font-size: 0.875rem; overflow-wrap: anywhere; }
.bell-menu .dropdown-item small { color: var(--ink-muted); font-size: 0.75rem; }
.bell-menu .dropdown-item.unread { background: var(--orange-tint-soft); }
/* Nieuwsbrieven */
.chip.campaign-gepland, .chip.campaign-bezig { background: var(--orange-tint); color: var(--orange-dark); }
.chip.campaign-verzonden { background: var(--orange); color: var(--white); }
.chip.campaign-gestopt, .chip.campaign-gepauzeerd { background: var(--light); color: var(--ink-muted); }
.schedule { margin-top: 1.5rem; padding-top: 1.25rem; border-top: 1px solid var(--line); }
.progress-bar { background: var(--orange); }
/* ---------- massa-acties (templates/_bulk.html, static/bulk.js) ---------- */
.chip.vervallen { background: var(--light); color: var(--ink-muted); }
.table > :not(caption) > * > .bulk-col { width: 2.25rem; padding-right: 0; cursor: pointer; }
.bulk-col .form-check-input { margin: 0.2rem 0 0; cursor: pointer; }
.table > tbody > tr.selected > * { --bs-table-bg-state: var(--orange-tint); }
.bulk-bar { position: fixed; z-index: 1030; left: 50%; bottom: 1rem; transform: translateX(-50%); display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; max-width: calc(100% - 2rem); padding: 0.6rem 0.75rem 0.6rem 1.25rem; background: var(--white); border: 1px solid var(--orange-line); border-radius: var(--radius-pill); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.14); }
.bulk-bar[hidden] { display: none; }
.bulk-bar .form-select { width: auto; max-width: 16rem; }
.bulk-count { white-space: nowrap; }
body.bulk-open main.content { padding-bottom: 7rem; }
@media (max-width: 767.98px) {
.bulk-bar { left: 0.75rem; right: 0.75rem; bottom: 0.75rem; transform: none; max-width: none; border-radius: var(--radius-md); padding: 0.75rem; }
.bulk-bar .form-select { flex: 1 1 100%; max-width: none; }
.bulk-bar .btn { flex: 1 1 auto; }
.bulk-count { flex-basis: 100%; }
.table-stack > tbody > tr { position: relative; }
.table-stack > tbody > tr > td.bulk-col { position: absolute; top: 0.8rem; right: 0; padding: 0; width: auto; }
.table-stack > tbody > tr > td.bulk-col::before { content: none; }
.table-stack > tbody > tr > td.bulk-col ~ td.stack-title { padding-right: 2.25rem; }
}
+3 -1
View File
@@ -455,7 +455,9 @@
const raw = JSON.parse(document.getElementById('design').textContent);
const key = raw.model || '', model = (HouseModel.MODELS || {})[key];
const name = (st.project && st.project.naam) || (model && model.label) || 'Uw woning';
const full = HouseModel.computeParts(st, { skip3D: true });
// Afgewerkte woning met 3D, zoals light.js: met skip3D hebben de wandstukken geen specs (gewicht) en faalt computeTotals.
const full = HouseModel.computeParts(st, Object.assign({ layers: ViewLayers.all(), finishedOnly: true }, mats));
HouseModel.rootGroups(full).forEach(disposeGeo);
const totals = HouseModel.computeTotals(full), d = HouseModel.computeDimensions(full, S.groundLevel);
const info = {
price: CostModel.compute(full, totals).summary.inclBtw, kg: totals.totalKg, units: st.units.length,
+22
View File
@@ -0,0 +1,22 @@
{# Massa-acties in lijsten: een vinkje per rij (form="bulk") en een balk onderin met de actie (static/bulk.js).
Importeren met: {% from '_bulk.html' import bulk_head, bulk_cell, bulk_bar with context %} #}
{% macro bulk_head() %}<th class="bulk-col"><input class="form-check-input" type="checkbox" data-bulk-all aria-label="Alles op deze pagina selecteren"></th>{% endmacro %}
{% macro bulk_cell(id, label) %}<td class="bulk-col"><input class="form-check-input" type="checkbox" name="ids" value="{{ id }}" form="bulk" aria-label="Selecteer {{ label }}"></td>{% endmacro %}
{# actions: lijst van (waarde, label) of (waarde, label, bevestigingsvraag met {n}); velden met data-show="<waarde>"
in de caller verschijnen alleen bij die actie #}
{% macro bulk_bar(action_url, actions) %}
<form id="bulk" class="bulk-bar" method="post" action="{{ action_url }}" hidden>
<input type="hidden" name="next" value="{{ request.full_path }}">
<span class="bulk-count"><strong data-bulk-count>0</strong> geselecteerd</span>
<select class="form-select form-select-sm" name="action" aria-label="Actie" required>
<option value="">Kies een actie…</option>
{% for a in actions %}<option value="{{ a[0] }}"{% if a|length > 2 %} data-confirm="{{ a[2] }}"{% endif %}>{{ a[1] }}</option>{% endfor %}
</select>
{{ caller() if caller }}
<button class="btn btn-primary btn-sm">Toepassen</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-bulk-clear>Wissen</button>
</form>
<script src="{{ url_for('static', filename='bulk.js') }}" defer></script>
{% endmacro %}
+37
View File
@@ -0,0 +1,37 @@
{# E-mail opstellen (nieuw, beantwoorden, vanaf een contact). m = {to, cc, subject, body, contact_id, reply_to, next,
template, mailbox_id}; mailboxes = waarmee de gebruiker mag versturen (Van). 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, mailboxes, 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 not mailboxes %}
<div class="col-12"><div class="flash error mb-0">Er is geen mailbox waarmee je mag versturen. Een beheerder stelt die in bij Instellingen › Mailboxen.</div></div>
{% elif mailboxes|length == 1 %}
<input type="hidden" name="mailbox_id" value="{{ mailboxes[0].id }}">
{% else %}
<div class="col-12"><label class="form-label" for="{{ id }}-from">Van</label>
<select class="form-select" id="{{ id }}-from" name="mailbox_id">{% for b in mailboxes %}<option value="{{ b.id }}" {{ 'selected' if m.mailbox_id == b.id }}>{{ b.name }}{% if b.smtp_from or b.smtp_user or b.imap_user %} · {{ b.smtp_from or b.smtp_user or b.imap_user }}{% endif %}</option>{% endfor %}</select></div>
{% endif %}
{% 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 %}
+47
View File
@@ -0,0 +1,47 @@
{# Documenten met versies (contact en relatie). docs = documents_of(…); upload_url = waar nieuwe documenten heen gaan;
chosen_id = gekozen woning (alleen bij een contact, dan ook "Kies als woning"). #}
{% macro documents_list(docs, upload_url, chosen_id=None, choose_url=None) %}
<div class="docs">
{% for d in docs %}{% set v0 = d.latest %}
<details class="doc">
<summary>
<span class="doc-icon">{% if v0.mime.startswith('image/') %}<img src="{{ url_for('upload_file', name=v0.stored) }}" alt="" loading="lazy">{% elif d.kind == 'woning' %}3D{% else %}{{ v0.original.rsplit('.', 1)[-1][:4] if '.' in v0.original else 'doc' }}{% endif %}</span>
<span class="doc-main"><span class="doc-title" title="{{ d.title }}">{{ d.title }}</span>
<span class="muted">{{ DOC_KINDS.get(d.kind, 'Document') }} · versie {{ v0.version }} · {{ v0.created_at|dt('%d-%m-%Y') }}{% if d.kind == 'werk' %} · intern, niet in portaal{% endif %}{% if chosen_id and d.id == chosen_id %} · gekozen woning{% endif %}{% if d.contact_name and not chosen_id %} · {{ d.contact_name }}{% endif %}</span></span>
</summary>
<ul class="versions">
{% for v in d.versions %}
<li><a href="{{ url_for('design_viewer', fid=v.id) if d.kind == 'woning' else url_for('upload_file', name=v.stored) }}" target="_blank">Versie {{ v.version }}</a>
<a class="muted" href="{{ url_for('upload_file', name=v.stored) }}" download="{{ v.original }}" title="Downloaden">↓</a>
<span class="muted">{{ v.created_at|dt }}{% if v.user_name %} · {{ v.user_name }}{% endif %}{% if v.note %} · {{ v.note }}{% endif %}</span>
<form method="post" action="{{ url_for('file_delete', fid=v.id) }}" onsubmit="return confirm('Versie {{ v.version }} van {{ d.title|e }} verwijderen?{{ ' Dit is de enige versie: het document verdwijnt.' if d.versions|length == 1 }}')"><button class="linkbtn">Verwijderen</button></form>
</li>
{% endfor %}
</ul>
<div class="doc-forms">
{% if d.kind in ('bestand', 'woning') %}
<form method="post" action="{{ url_for('document_version', did=d.id) }}" enctype="multipart/form-data" class="row g-2">
<div class="col-12"><input class="form-control form-control-sm" type="file" name="file" required aria-label="Nieuwe versie"{% if d.kind == 'woning' %} accept=".json,application/json"{% endif %}></div>
<div class="col"><input class="form-control form-control-sm" name="note" placeholder="Wat is er veranderd?" aria-label="Opmerking"></div>
<div class="col-auto"><button class="btn btn-outline-primary btn-sm">Nieuwe versie</button></div>
</form>
{% endif %}
<div class="actions">
{% if choose_url and d.kind == 'woning' and d.id != chosen_id %}
<form method="post" action="{{ choose_url }}" class="inline"><input type="hidden" name="document_id" value="{{ d.id }}"><button class="linkbtn">Kies als woning</button></form>
{% endif %}
<form method="post" action="{{ url_for('document_delete', did=d.id) }}" class="inline" onsubmit="return confirm('{{ d.title|e }} met alle versies{{ ' en tekeningen' if d.kind == 'woning' }} verwijderen?')"><button class="linkbtn">Document verwijderen</button></form>
</div>
</div>
</details>
{% else %}
<p class="empty">Nog geen documenten.</p>
{% endfor %}
</div>
<form method="post" action="{{ upload_url }}" enctype="multipart/form-data">
<input class="form-control" type="file" name="files" multiple required aria-label="Documenten kiezen">
<input class="form-control mt-2" name="note" placeholder="Opmerking (optioneel)" aria-label="Opmerking">
<div class="actions"><button class="btn btn-outline-primary btn-sm">Uploaden</button></div>
<p class="muted" style="margin:.5rem 0 0;font-size:.8rem">Elk bestand wordt een nieuw document; een woningontwerp uit Tectum (JSON) wordt een woning. Een nieuwe versie van een bestaand document voeg je toe bij dat document.</p>
</form>
{% endmacro %}
+33
View File
@@ -0,0 +1,33 @@
{# Kavel bij een contact of relatie: de meters, daaronder alleen de kaart
(kaart-app, &weergave=kaart). Alle details staan op de volledige kaartpagina (knop). De kaart stuurt de gegevens
met postMessage ({type: 'kaveltoets', resultaat | fout}). #}
{% macro kaart_block(kaart) %}
<div class="card kavel"><div class="card-body">
<div class="card-top"><h2>Kavel</h2>
<div class="actions"><a class="btn btn-outline-primary btn-sm" href="{{ kaart }}" target="_blank" data-kavel-open>Volledig openen</a></div></div>
<p class="muted" data-kavel-status style="margin:0 0 1rem">Kavel wordt opgezocht…</p>
<dl class="kavel-meters" data-kavel-gegevens hidden>
<div><dt>Kavel</dt><dd data-k="kavel_m2"></dd></div>
<div><dt>Bebouwd</dt><dd data-k="bebouwd_m2"></dd></div>
<div><dt>Vrij</dt><dd data-k="vrije_m2"></dd></div>
</dl>
<div class="kaart-frame"><iframe src="{{ kaart }}&amp;weergave=kaart" title="Kaart van de kavel" loading="lazy" data-kavel-frame></iframe></div>
</div></div>
<script>
(() => {
const card = document.currentScript.previousElementSibling;
const frame = card.querySelector('[data-kavel-frame]'), box = card.querySelector('[data-kavel-gegevens]');
const status = card.querySelector('[data-kavel-status]'), open = card.querySelector('[data-kavel-open]');
const k = (n) => card.querySelector(`[data-k="${n}"]`);
const m2 = (n) => n == null ? '—' : `${Number(n).toLocaleString('nl-NL')} m²`;
addEventListener('message', (e) => {
if (e.origin !== location.origin || e.source !== frame.contentWindow || !e.data || e.data.type !== 'kaveltoets') return;
if (e.data.fout) { status.textContent = e.data.fout; status.hidden = false; box.hidden = true; return; }
const r = e.data.resultaat;
status.hidden = true; box.hidden = false;
['kavel_m2', 'bebouwd_m2', 'vrije_m2'].forEach(n => { k(n).textContent = m2(r[n]); });
if (r.link) open.href = r.link;
});
})();
</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 %}
+65 -6
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,9 @@
<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>
<script src="{{ url_for('static', filename='notify.js') }}" defer></script>{% endif %}
{% block styles %}{% endblock %}
</head>
<body>
{% block body %}
@@ -40,13 +44,17 @@
<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('campaigns') }}" class="{{ 'active' if ep.startswith('campaign') or ep.startswith('segment') }}">Nieuwsbrieven</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>
<a href="{{ url_for('routines') }}" class="{{ 'active' if ep.startswith('routine') }}">Routines</a>
<a href="{{ url_for('settings') }}" class="{{ 'active' if ep == 'settings' or ep.startswith('mailbox') }}">Instellingen</a>
{% endif %}
</nav>
<a href="{{ url_for('account') }}" class="logout">{{ g.user.name }}</a>
@@ -55,12 +63,62 @@
</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 bell" data-poll="{{ url_for('notifications_poll') }}" data-last="{{ bell[0].id if bell else 0 }}">
<button class="icon-btn" type="button" data-bs-toggle="dropdown" aria-expanded="false" aria-label="Meldingen{% if bell_unread %}: {{ bell_unread }} ongelezen{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
<span class="bell-count" {{ 'hidden' if not bell_unread }}>{{ bell_unread }}</span>
</button>
<div class="dropdown-menu dropdown-menu-end bell-menu">
<div class="bell-head"><b>Meldingen</b>
{% if bell_unread %}<form method="post" action="{{ url_for('notifications_read_all') }}" class="inline"><button class="btn btn-link btn-sm p-0">Alles gelezen</button></form>{% endif %}</div>
{% for n in bell %}
<a class="dropdown-item {{ 'unread' if not n.read_at }}" href="{{ url_for('notification_open', nid=n.id) }}"><b>{{ n.title }}</b>{% if n.body %}<span>{{ n.body }}</span>{% endif %}<small>{{ n.created_at|dt }}</small></a>
{% else %}
<p class="empty px-3">Geen meldingen.</p>
{% endfor %}
<div class="bell-foot"><a href="{{ url_for('notifications') }}">Alle meldingen</a><button type="button" class="btn btn-link btn-sm p-0 bell-desktop" hidden>Bureaubladmeldingen aanzetten</button></div>
</div>
</div>
<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>
<li><form method="post" action="{{ url_for('campaign_new') }}"><button class="dropdown-item">Nieuwsbrief</button></form></li>
</ul>
</div>
</div>
</header>
<main class="content">
{% with msgs = get_flashed_messages(with_categories=true) %}
@@ -71,5 +129,6 @@
</div>
</div>
{% endblock %}
{% block scripts %}{% endblock %}
</body>
</html>
+14 -19
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>
<script type="application/json" id="bestek-meta">{{ {'design': {'file_id': design_file.id, 'title': design_file.design_title or 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 %}
+95
View File
@@ -0,0 +1,95 @@
{% extends 'base.html' %}
{% block title %}{{ c.name }}{% endblock %}
{% block content %}
{% set editable = c.status == 'concept' %}
<div class="head">
<div><a class="label" href="{{ url_for('campaigns') }}">← Nieuwsbrieven</a><h1>{{ c.name }}</h1>
<div class="muted"><span class="chip campaign-{{ c.status }}">{{ CSTATUS.get(c.status, c.status) }}</span>
{% if c.scheduled_at and not editable %} · vanaf {{ c.scheduled_at|dt }}{% endif %}{% if c.finished_at %} · klaar {{ c.finished_at|dt }}{% endif %}</div></div>
<div class="actions">
<form method="post" class="inline">
{% if c.status in ('gepland', 'bezig') %}<button class="btn btn-outline-primary" name="action" value="pause">Pauzeren</button>{% endif %}
{% if c.status == 'gepauzeerd' %}<button class="btn btn-primary" name="action" value="resume">Hervatten</button>{% endif %}
{% if c.status in ('gepland', 'bezig', 'gepauzeerd') %}<button class="btn btn-outline-secondary" name="action" value="stop" onclick="return confirm('Stoppen? Wie hem nog niet kreeg, krijgt hem niet meer.')">Stoppen</button>{% endif %}
<button class="btn btn-outline-secondary" name="action" value="copy">Kopie maken</button>
</form>
</div>
</div>
{% if not editable %}
<div class="card"><div class="card-body">
<h2>Voortgang</h2>
{% set pct = (100 * (stats.totaal - stats.wacht) / stats.totaal)|round|int if stats.totaal else 0 %}
<div class="progress mb-3" role="progressbar" aria-label="Voortgang" aria-valuenow="{{ pct }}" aria-valuemin="0" aria-valuemax="100"><div class="progress-bar" style="width: {{ pct }}%"></div></div>
<div class="d-flex flex-wrap gap-4">
{% for k, l in RSTATUS.items() %}<div><div class="muted">{{ l }}</div><b>{{ stats[k] }}</b></div>{% endfor %}
</div>
</div></div>
{% endif %}
<div class="row g-4">
<div class="col-lg-8">
<form method="post" class="card compose" data-vars='{}'><div class="card-body">
<fieldset {{ 'disabled' if not editable }}>
<div class="row g-3">
<div class="col-12"><label class="form-label" for="name">Naam (intern)</label><input class="form-control" id="name" name="name" value="{{ c.name }}" required></div>
<div class="col-md-6"><label class="form-label" for="mailbox_id">Van</label>
<select class="form-select" id="mailbox_id" name="mailbox_id">
{% if not mailboxes %}<option value="">Geen mailbox om mee te versturen</option>{% endif %}
{% for m in mailboxes %}<option value="{{ m.id }}" {{ 'selected' if c.mailbox_id == m.id }}>{{ m.name }}{% if m.smtp_from or m.smtp_user or m.imap_user %} · {{ m.smtp_from or m.smtp_user or m.imap_user }}{% endif %}</option>{% endfor %}
{% if mailbox and mailbox.id not in mailboxes|map(attribute='id')|list %}<option value="{{ mailbox.id }}" selected>{{ mailbox.name }}</option>{% endif %}
</select></div>
<div class="col-md-6"><label class="form-label" for="segment_id">Aan (segment)</label>
<select class="form-select" id="segment_id" name="segment_id" aria-describedby="seg-hint">
<option value="">Kies een segment…</option>
{% for s in segments %}<option value="{{ s.id }}" {{ 'selected' if c.segment_id == s.id }}>{{ s.name }}</option>{% endfor %}
</select>
<p class="hint" id="seg-hint">{% if c.segment_id and editable %}Nu {{ seg_count }} ontvanger(s). {% endif %}<a href="{{ url_for('segments') }}">Segmenten beheren</a></p></div>
{% if templates and editable %}
<div class="col-12"><label class="form-label" for="template">Template</label>
<select class="form-select" 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 }}">{{ t.name }}</option>{% endfor %}
</select></div>
{% endif %}
<div class="col-12"><label class="form-label" for="subject">Onderwerp</label><input class="form-control" id="subject" name="subject" value="{{ c.subject or '' }}"></div>
<div class="col-12"><label class="form-label" for="body">Tekst</label><textarea class="form-control" id="body" name="body" rows="16">
{{ c.body or '' }}</textarea>
<p class="hint">Velden worden per ontvanger ingevuld: {% for k, l in FIELDS %}<code title="{{ l }}">{{ '{' ~ k ~ '}' }}</code> {% endfor %}. Zonder {afmeldlink} komt de afmeldlink onderaan.</p></div>
</div>
</fieldset>
<div class="actions">
{% if editable %}
<button class="btn btn-primary" name="action" value="save">Opslaan</button>
<button class="btn btn-outline-primary" name="action" value="test">Testmail naar mij</button>
{% endif %}
</div>
{% if editable %}
<div class="schedule">
<h2>Versturen</h2>
<p class="hint">Bij inplannen worden de ontvangers uit het segment vastgelegd. Daarna verstuurt de routine "Nieuwsbrieven versturen" ze per batch.</p>
<div class="row g-3 align-items-end">
<div class="col-md-6"><label class="form-label" for="scheduled_at">Vanaf (leeg = zo snel mogelijk)</label><input class="form-control" id="scheduled_at" type="datetime-local" name="scheduled_at"></div>
<div class="col-md-6"><button class="btn btn-primary" name="action" value="schedule" onclick="return confirm('Nieuwsbrief inplannen? Daarna kun je de tekst niet meer wijzigen.')">Opslaan en inplannen</button></div>
</div>
</div>
{% endif %}
</div></form>
{% if editable %}
<form method="post" onsubmit="return confirm('Nieuwsbrief verwijderen?')"><button class="btn btn-outline-secondary btn-sm" name="action" value="delete">Nieuwsbrief verwijderen</button></form>
{% endif %}
</div>
{% if recipients %}
<div class="col-lg-4">
<div class="card"><div class="card-body">
<h2>Ontvangers</h2>
<ul class="timeline">{% for r in recipients %}<li>{% if r.contact_id %}<a href="{{ url_for('contact_view', cid=r.contact_id) }}">{{ r.name or r.email }}</a>{% else %}{{ r.email }}{% endif %}
<span class="chip {{ 'verloren' if r.status in ('mislukt', 'overgeslagen') else '' }}">{{ RSTATUS.get(r.status, r.status) }}</span><br>
<span class="muted">{{ r.email }}{% if r.sent_at %} · {{ r.sent_at|dt }}{% endif %}{% if r.error %} · {{ r.error }}{% endif %}</span></li>{% endfor %}</ul>
{% if stats.totaal > recipients|length %}<p class="hint">De eerste {{ recipients|length }} van {{ stats.totaal }}.</p>{% endif %}
</div></div>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}{% if editable %}<script src="{{ url_for('static', filename='compose.js') }}" defer></script>{% endif %}{% endblock %}
+33
View File
@@ -0,0 +1,33 @@
{% extends 'base.html' %}
{% block title %}Nieuwsbrieven{% endblock %}
{% block content %}
<div class="head">
<div><div class="kicker">E-mail</div><h1>Nieuwsbrieven</h1></div>
<div class="actions">
<a class="btn btn-outline-primary" href="{{ url_for('segments') }}">Segmenten</a>
<a class="btn btn-outline-primary" href="{{ url_for('mail_templates') }}">Templates</a>
<form method="post" action="{{ url_for('campaign_new') }}" class="inline"><button class="btn btn-primary">Nieuwe nieuwsbrief</button></form>
</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>Segment</th><th>Van</th><th>Status</th><th class="num">Verzonden</th><th>Gepland</th></tr></thead>
<tbody>
{% for c in rows %}
<tr class="link" onclick="location='{{ url_for('campaign_edit', cid=c.id) }}'">
<td class="stack-title"><a href="{{ url_for('campaign_edit', cid=c.id) }}">{{ c.name }}</a></td>
<td data-label="Segment">{{ c.segment_name or '—' }}</td>
<td data-label="Van">{{ c.mailbox_name or '—' }}</td>
<td data-label="Status"><span class="chip campaign-{{ c.status }}">{{ CSTATUS.get(c.status, c.status) }}</span></td>
<td data-label="Verzonden" class="num">{% if c.stats.totaal %}{{ c.stats.verzonden }} / {{ c.stats.totaal }}{% else %}—{% endif %}</td>
<td data-label="Gepland" class="muted">{{ c.scheduled_at|dt if c.scheduled_at else '' }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Nog geen nieuwsbrieven. Maak eerst een segment (wie krijgt hem) en eventueel een template.</p>{% endif %}
</div></div>
<p class="muted">Nieuwsbrieven gaan per batch de deur uit via de routine "Nieuwsbrieven versturen". Elke mail krijgt een afmeldlink;
wie zich afmeldt of onbestelbaar blijkt, valt voortaan buiten elk segment.</p>
{% endblock %}
+64 -58
View File
@@ -1,4 +1,7 @@
{% extends 'base.html' %}
{% from '_compose.html' import compose %}
{% from '_documents.html' import documents_list with context %}
{% from '_kaart.html' import kaart_block %}
{% block title %}{{ c.name }}{% endblock %}
{% block content %}
<div class="head">
@@ -27,15 +30,24 @@
<div class="row gx-4 stack">
<div class="col-lg-7 col-xl-8">
{% if woningen %}
<div class="card"><div class="card-body">
<div class="card-top">
<h2>Gekozen model</h2>
{% if design %}
<h2>Woning</h2>
<div class="actions">
{% if designs|length > 1 %}
{% if woningen|length > 1 or not woning %}
<form method="post" action="{{ url_for('contact_design', cid=c.id) }}" class="inline">
<select class="form-select form-select-sm w-auto" name="file_id" onchange="this.form.submit()" aria-label="Ander ontwerp kiezen">
{% for d in designs %}<option value="{{ d.id }}" {{ 'selected' if d.id == design.id }}>{{ d.title }} · {{ d.created_at|dt('%d-%m-%Y') }}</option>{% endfor %}
<select class="form-select form-select-sm w-auto" name="document_id" onchange="this.form.submit()" aria-label="Andere woning kiezen">
{% if not woning %}<option value="">Kies een woning…</option>{% endif %}
{% for d in woningen %}<option value="{{ d.id }}" {{ 'selected' if woning and d.id == woning.id }}>{{ d.title }} · {{ d.updated_at|dt('%d-%m-%Y') }}</option>{% endfor %}
</select>
</form>
{% endif %}
{% if woning %}
{% if woning.versions|length > 1 %}
<form method="get" action="{{ url_for('contact_view', cid=c.id) }}" class="inline">
<select class="form-select form-select-sm w-auto" name="versie" onchange="this.form.submit()" aria-label="Versie">
{% for v in woning.versions %}<option value="{{ v.id }}" {{ 'selected' if v.id == shown.id }}>Versie {{ v.version }}{{ ' (nieuwste)' if loop.first }} · {{ v.created_at|dt('%d-%m-%Y') }}</option>{% endfor %}
</select>
</form>
{% endif %}
@@ -43,19 +55,22 @@
<input type="hidden" name="contact_id" value="{{ c.id }}"><input type="hidden" name="bestek" value="1">
<button class="btn btn-primary btn-sm">Offerte met bestek</button>
</form>
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('design_viewer', fid=design.id) }}" target="_blank">Openen</a>
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('upload_file', name=design.stored) }}" download="{{ design.original }}">JSON</a>
{% if tectum_url %}<a class="btn btn-outline-primary btn-sm" href="{{ tectum_url }}" target="_blank" title="Openen in Tectum; Opslaan in CRM maakt een nieuwe versie">Bewerken in Tectum</a>{% endif %}
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('design_viewer', fid=shown.id) }}" target="_blank">Openen</a>
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('upload_file', name=shown.stored) }}" download="{{ shown.original }}">JSON</a>
{% endif %}
</div>
{% endif %}
</div>
{% if design %}
<div class="viewer-frame"><iframe src="{{ url_for('design_viewer', fid=design.id) }}" title="3D-weergave {{ design.title }}" loading="lazy" allowfullscreen></iframe></div>
<p class="muted" style="margin:.75rem 0 0"><strong style="color:var(--ink)">{{ design.title }}</strong>
{% if woning %}
<div class="viewer-frame"><iframe src="{{ url_for('design_viewer', fid=shown.id) }}" title="3D-weergave {{ woning.title }}" loading="lazy" allowfullscreen></iframe></div>
<p class="muted" style="margin:.75rem 0 0"><strong style="color:var(--ink)">{{ woning.title }}</strong>
· versie {{ shown.version }}{% if shown.id != woning.latest.id %} (nieuwste: {{ woning.latest.version }}){% endif %}{% if shown.note %} · {{ shown.note }}{% endif %}
· {{ design_meta.units }} unit{{ 's' if design_meta.units != 1 }}{% if design_meta.address %} · {{ design_meta.address }}{% endif %}
· ontvangen {{ design.created_at|dt }}{% if design_meta.version %} · configurator {{ design_meta.version }}{% endif %}</p>
· {{ shown.created_at|dt }}{% if design_meta.version %} · configurator {{ design_meta.version }}{% endif %}</p>
{% if design_meta.note %}<p style="white-space:pre-line;margin:.5rem 0 0">{{ design_meta.note }}</p>{% endif %}
<p class="pdf-links" style="margin:.5rem 0 0">Tekeningen:
{% for k, label in PDF_KINDS.items() %}{% set p = pdfs.get(k) %}
{% if shown.id == woning.latest.id %}
<p class="pdf-links" style="margin:.5rem 0 0">Tekeningen van deze versie:
{% for k, label in PDF_KINDS.items() %}{% set p = pdfs.get(k) if pdfs.get(k) and pdfs.get(k).current else None %}
<a data-pdf="{{ k }}" href="{{ p.url if p else '#' }}" target="_blank" {{ 'hidden' if not p }}>{{ (label[:1]|upper) ~ label[1:] }}</a><span class="muted" data-pdf-wait="{{ k }}" {{ 'hidden' if p }}>{{ label }} wordt gemaakt…</span>{% if k == 'werk' %} <span class="muted">(intern)</span> ·{% endif %}
{% endfor %}
<span class="muted">· opnieuw maken via PDF in de weergave</span></p>
@@ -67,18 +82,22 @@
if (w) w.hidden = true;
});
</script>
{% elif designs %}
<p class="muted">Er is geen model gekozen.</p>
<form method="post" action="{{ url_for('contact_design', cid=c.id) }}" class="d-flex flex-wrap gap-2">
<select class="form-select w-auto" name="file_id" aria-label="Ontwerp">
{% for d in designs %}<option value="{{ d.id }}">{{ d.title }} · {{ d.created_at|dt('%d-%m-%Y') }}</option>{% endfor %}
</select>
<button class="btn btn-outline-primary">Kiezen</button>
</form>
{% endif %}
<details style="margin-top:1rem">
<summary style="font-size:1rem">Nieuwe versie uploaden</summary>
<form method="post" action="{{ url_for('document_version', did=woning.id) }}" enctype="multipart/form-data" class="row g-2">
<div class="col-md-6"><input class="form-control" type="file" name="file" accept=".json,application/json" required aria-label="Woningontwerp (JSON)"></div>
<div class="col-md"><input class="form-control" name="note" placeholder="Wat is er veranderd?" aria-label="Opmerking"></div>
<div class="col-md-auto"><button class="btn btn-outline-primary">Opslaan als versie {{ woning.latest.version + 1 }}</button></div>
</form>
</details>
{% else %}
<p class="empty">Nog geen model. Een ontwerp uit de configurator (Bestand › Opslaan, JSON) komt hier vanzelf als je het bij Bestanden uploadt of als het met het formulier meekomt.</p>
<p class="muted" style="margin:0">Er is geen woning gekozen.</p>
{% endif %}
</div></div>
{% endif %}
{% if kaart %}{{ kaart_block(kaart) }}{% endif %}
<div class="card o-2"><div class="card-body">
<h2>Taken</h2>
@@ -101,6 +120,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) }}">
@@ -109,19 +140,17 @@
</form>
</div></div>
{% if c.unsubscribed_at or c.bounced_at %}
<div class="flash error d-flex justify-content-between align-items-center gap-3 flex-wrap">
<span>{% if c.bounced_at %}Het e-mailadres is onbestelbaar (sinds {{ c.bounced_at|dt }}){% else %}Afgemeld voor nieuwsbrieven op {{ c.unsubscribed_at|dt }}{% endif %}; dit contact krijgt geen nieuwsbrieven.</span>
<form method="post" action="{{ url_for('contact_mailstatus', cid=c.id) }}" class="inline" onsubmit="return confirm('Dit contact weer nieuwsbrieven sturen?')"><button class="btn btn-outline-secondary btn-sm">Weer aanmelden</button></form>
</div>
{% endif %}
{% if c.email %}
<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, mailboxes) }}
</details>
</div></div>
{% endif %}
@@ -170,7 +199,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>
@@ -185,31 +214,8 @@
</div></div>
<div class="card"><div class="card-body">
<h2>Bestanden</h2>
{% if files %}
<div class="files">
{% for f in files %}
<div class="file">
<a href="{{ url_for('design_viewer', fid=f.id) if f.title else 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">
{% elif f.title %}3D{% else %}{{ f.original.rsplit('.', 1)[-1][:4] if '.' in f.original else 'bestand' }}{% endif %}
</a>
<div class="name" title="{{ f.original }}">{{ f.title or f.original }}</div>
{% if f.kind == 'werk' %}<div class="muted">Intern, niet in portaal</div>{% endif %}
{% if f.title %}{% if c.design_file_id == f.id %}<div class="muted">Gekozen model</div>{% else %}
<form method="post" action="{{ url_for('contact_design', cid=c.id) }}"><input type="hidden" name="file_id" value="{{ f.id }}"><button class="linkbtn">Kies als model</button></form>
{% endif %}{% endif %}
<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 class="form-control" type="file" name="files" multiple required aria-label="Bestanden kiezen">
<div class="actions"><button class="btn btn-outline-primary btn-sm">Uploaden</button></div>
</form>
<h2>Documenten</h2>
{{ documents_list(documents, url_for('contact_upload', cid=c.id), c.design_id or -1, url_for('contact_design', cid=c.id)) }}
</div></div>
<div class="card"><div class="card-body">
+10 -2
View File
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% from '_bulk.html' import bulk_head, bulk_cell, bulk_bar with context %}
{% block title %}Contacten{% endblock %}
{% block content %}
<div class="head">
@@ -8,7 +9,7 @@
{% for k in ('type', 'status', 'owner') %}{% if a[k] %}<input type="hidden" name="{{ k }}" value="{{ a[k] }}">{% endif %}{% endfor %}
<input class="form-control" type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, e-mail, plaats…" aria-label="Zoeken">
</form>
<a class="btn btn-outline-primary" href="{{ url_for('contacts_export') }}">Exporteer CSV</a>
<a class="btn btn-outline-primary" href="{{ url_for('contacts_export', **request.args) }}" title="Alle velden van de contacten in deze lijst">Exporteer CSV</a>
<a class="btn btn-primary" href="{{ url_for('contact_new', type=a.type or 'lead') }}">{{ 'Nieuwe klant' if a.type == 'klant' else 'Nieuwe lead' }}</a>
</div>
</div>
@@ -33,10 +34,11 @@
<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>Relatie</th><th>Telefoon</th><th>Plaats</th><th>Model</th><th>Bron</th><th>Toegewezen</th><th>Status</th><th>Aangemaakt</th></tr></thead>
<thead><tr>{{ bulk_head() }}<th>Naam</th><th>Relatie</th><th>Telefoon</th><th>Plaats</th><th>Model</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) }}'">
{{ bulk_cell(c.id, c.name) }}
<td class="stack-title"><a href="{{ url_for('contact_view', cid=c.id) }}">{{ c.name }}</a><div class="muted">{{ c.email }}</div></td>
<td data-label="Relatie">{% if c.relation_id %}<span><a href="{{ url_for('relation_view', rid=c.relation_id) }}" onclick="event.stopPropagation()">{{ c.relation_name }}</a>{% if c.relation_role %}<span class="muted d-block">{{ c.relation_role }}</span>{% endif %}</span>{% else %}{{ c.company }}{% endif %}</td>
<td data-label="Telefoon">{{ c.phone }}</td>
@@ -52,4 +54,10 @@
</table></div>
{% else %}<p class="empty">Geen contacten gevonden.</p>{% endif %}
</div></div>
{% call bulk_bar(url_for('contacts_bulk'), [('status', 'Status wijzigen'), ('assign', 'Toewijzen'),
('convert', 'Omzetten naar klant'), ('export', 'Exporteren als CSV'),
('delete', 'Verwijderen', '{n} contact(en) verwijderen, met hun tijdlijn, taken en bestanden? Dit kan niet ongedaan worden gemaakt.')]) %}
<select class="form-select form-select-sm" name="status" data-show="status" aria-label="Nieuwe status">{% for k, l in STATUSES %}<option value="{{ k }}">{{ l }}</option>{% endfor %}</select>
<select class="form-select form-select-sm" name="user_id" data-show="assign" aria-label="Toewijzen aan"><option value="">Niemand</option>{% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if u.id == g.user.id }}>{{ u.name }}</option>{% endfor %}</select>
{% endcall %}
{% endblock %}
+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 %}
+16 -15
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', mb=e.mailbox_id, 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 %}
· {{ e.date|dt }}
{% 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 }}{% if e.mailbox_name %} · {{ e.mailbox_name }}{% endif %}
</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, 'mailbox_id': e.mailbox_id}, templates, vars, mailboxes) }}
</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 %}
+36 -11
View File
@@ -1,38 +1,63 @@
{% 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{% if current %} · {{ current.name }}{% endif %}</div><h1>{{ {'sent': 'Verzonden', 'archived': 'Archief'}.get(f, 'Inbox') }}</h1>
{% if current and current.last_sync %}<div class="muted">Laatst opgehaald: {{ current.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 }}">
{% if mb %}<input type="hidden" name="mb" value="{{ mb }}">{% endif %}
<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">{% if mb %}<input type="hidden" name="mb" value="{{ mb }}">{% endif %}<button class="btn btn-outline-primary">Nieuwe e-mail ophalen</button></form>
<a class="btn btn-primary" href="{{ url_for('mail_new', mb=mb) }}">Nieuwe e-mail</a>
</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>
{% if not mailboxes %}
<div class="flash">Er is nog geen mailbox voor jou. {% if g.user.role == 'admin' %}Voeg er een toe bij <a href="{{ url_for('settings') }}#mail">Instellingen</a>.{% else %}Vraag een beheerder om er een in te stellen.{% endif %}</div>
{% elif current and current.last_error %}
<div class="flash error">{{ current.name }}: laatste ophaalpoging mislukt: {{ current.last_error }}</div>
{% endif %}
{% if mailboxes|length > 1 %}
<div class="filters" aria-label="Mailboxen">
<a href="{{ url_for('inbox', f=f if f != 'all' else None, q=term or None) }}" class="{{ 'on' if not mb }}">Alle mailboxen{% if unread %} <span class="muted">{{ unread }}</span>{% endif %}</a>
{% for m in mailboxes %}
<a href="{{ url_for('inbox', mb=m.id, f=f if f != 'all' else None, q=term or None) }}" class="{{ 'on' if mb == m.id }}">{{ m.name }}{% if unread_by.get(m.id) %} <span class="muted">{{ unread_by[m.id] }}</span>{% endif %}</a>
{% endfor %}
</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>
{% 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, mb=mb, q=term or None) }}" class="{{ 'on' if f == k }}">{{ l }}</a>
{% endfor %}
{% if (unread_by.get(mb) if mb else unread) and not sent %}
<form method="post" action="{{ url_for('inbox_read_all') }}" class="inline">{% if mb %}<input type="hidden" name="mb" value="{{ mb }}">{% endif %}<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>{% if not mb and mailboxes|length > 1 %}<th>Mailbox</th>{% endif %}<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>
{% if not mb and mailboxes|length > 1 %}<td data-label="Mailbox" class="muted">{{ e.mailbox_name or '' }}</td>{% endif %}
<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 de mailbox op de server 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, mailboxes, 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 %}
+91
View File
@@ -0,0 +1,91 @@
{% extends 'base.html' %}
{% block title %}{{ mb.name or 'Nieuwe mailbox' }}{% endblock %}
{% 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="{{ mb[key] or '' if mb[key] is defined else '' }}" 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 (mb[key] or '') == v }}>{{ label }}</option>{% endfor %}</select></div>
{% endmacro %}
{% macro secret(key, label, empty='') %}
<div class="col-md-6 col-xl-4"><label class="form-label" for="{{ key }}">{{ label }}</label><input class="form-control" id="{{ key }}" type="password" name="{{ key }}" placeholder="{{ '•••••••• (ingesteld)' if mb[key] else empty }}" autocomplete="new-password"></div>
{% endmacro %}
{% block content %}
<div class="head">
<div><a class="label" href="{{ url_for('settings', _anchor='mail') }}">← Instellingen</a><h1>{{ mb.name if mb.id else 'Nieuwe mailbox' }}</h1>
{% if mb.last_error %}<div class="flash error mt-2">Laatste fout bij ophalen: {{ mb.last_error }}</div>{% endif %}</div>
</div>
<form method="post">
<div class="card"><div class="card-body">
<h2>Algemeen</h2>
<div class="row g-3">
<div class="col-md-6 col-xl-4"><label class="form-label" for="name">Naam *</label><input class="form-control" id="name" name="name" value="{{ mb.name or '' }}" placeholder="Info" required></div>
<div class="col-md-6 col-xl-4"><label class="form-label" for="purpose">Rol</label>
<select class="form-select" id="purpose" name="purpose" aria-describedby="purpose-hint">{% for k, l in PURPOSES %}<option value="{{ k }}" {{ 'selected' if mb.purpose == k }}>{{ l }}</option>{% endfor %}</select>
<p class="hint" id="purpose-hint">Nieuwsbrieven kiezen standaard een nieuwsbriefmailbox.</p></div>
{{ field('smtp_from', 'Afzender', placeholder='Van Emous <info@vanemouswoningen.nl>') }}
<div class="col-12">
<div class="form-check"><input class="form-check-input" type="checkbox" id="active" name="active" value="1" {{ 'checked' if mb.active }}><label class="form-check-label" for="active">Actief (ophalen en versturen)</label></div>
<div class="form-check"><input class="form-check-input" type="checkbox" id="notify" name="notify" value="1" {{ 'checked' if mb.notify }}><label class="form-check-label" for="notify">Melding bij nieuwe e-mail voor wie toegang heeft</label></div>
</div>
</div>
<h3>Toegang</h3>
<p class="hint">Niemand aangevinkt = gedeeld met iedereen. Vink alleen jezelf aan voor een persoonlijke mailbox.</p>
<div class="d-flex flex-wrap gap-3">
{% for u in USERS %}<div class="form-check"><input class="form-check-input" type="checkbox" id="user-{{ u.id }}" name="users" value="{{ u.id }}" {{ 'checked' if u.id in access }}><label class="form-check-label" for="user-{{ u.id }}">{{ u.name }}</label></div>{% endfor %}
</div>
</div></div>
<div class="card"><div class="card-body">
<h2>Verbinding</h2>
<p class="hint" style="margin:-.4rem 0 1rem">Bij OVHcloud (Zimbra of MX Plan) is de gebruikersnaam het volledige e-mailadres en heeft elke mailbox een eigen wachtwoord.
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.mail.ovh.net') }}
{{ field('imap_port', 'Poort', 'number', '993') }}
{{ security('imap_security') }}
{{ field('imap_user', 'Gebruikersnaam', placeholder='info@vanemouswoningen.nl') }}
{{ secret('imap_pass', 'Wachtwoord') }}
{{ 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.mail.ovh.net') }}
{{ field('smtp_port', 'Poort', 'number', '465') }}
{{ security('smtp_security') }}
{{ field('smtp_user', 'Gebruikersnaam', placeholder='leeg = zelfde als IMAP') }}
{{ secret('smtp_pass', 'Wachtwoord', 'leeg = zelfde als IMAP') }}
<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="{{ mb.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. Nieuwsbrieven komen er niet in.</p></div>
</div>
</div></div>
<div class="actions">
<button class="btn btn-primary" name="action" value="save">Opslaan</button>
<button class="btn btn-outline-primary" name="action" value="test">Opslaan en verbinding testen</button>
<a class="btn btn-outline-secondary" href="{{ url_for('settings', _anchor='mail') }}">Annuleren</a>
</div>
</form>
{% endblock %}
{% block scripts %}
<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 %}
+24
View File
@@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% block title %}Meldingen{% endblock %}
{% block content %}
<div class="head">
<div><div class="kicker">Voor jou</div><h1>Meldingen</h1></div>
{% if bell_unread %}<div class="actions"><form method="post" action="{{ url_for('notifications_read_all') }}" class="inline"><button class="btn btn-outline-primary">Alles als gelezen markeren</button></form></div>{% 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>Melding</th><th>Details</th><th class="num">Wanneer</th></tr></thead>
<tbody>
{% for n in rows %}
<tr class="link {{ 'unread' if not n.read_at }}" onclick="location='{{ url_for('notification_open', nid=n.id) }}'">
<td class="stack-title">{% if not n.read_at %}<span class="dot" aria-label="Ongelezen"></span>{% endif %}<a href="{{ url_for('notification_open', nid=n.id) }}">{{ n.title }}</a></td>
<td data-label="Details" class="muted">{{ n.body }}</td>
<td data-label="Wanneer" class="muted num">{{ n.created_at|dt }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Geen meldingen.</p>{% endif %}
</div></div>
{% endblock %}
+4 -3
View File
@@ -37,15 +37,16 @@
<div class="col-md-6">
<div class="card"><div class="card-body">
<h2>Bestanden</h2>
{% if files %}
{% if documents %}
<div class="files">
{% for f in files %}
{% for d in documents %}{% set f = d.latest %}
<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 class="name" title="{{ d.title }}">{{ d.title }}</div>
{% if d.versions|length > 1 %}<div class="muted">versie {{ f.version }}</div>{% endif %}
</div>
{% endfor %}
</div>
+7 -2
View File
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% from '_bulk.html' import bulk_head, bulk_cell, bulk_bar with context %}
{% block title %}Producten{% endblock %}
{% block content %}
<div class="head">
@@ -8,7 +9,7 @@
<input type="hidden" name="show" value="{{ show }}">
<input class="form-control" type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, SKU, leverancier…" aria-label="Zoeken">
</form>
<a class="btn btn-outline-primary" href="{{ url_for('products_export') }}">Exporteer CSV</a>
<a class="btn btn-outline-primary" href="{{ url_for('products_export', **request.args) }}" title="De producten in deze lijst, in het formaat van de import">Exporteer CSV</a>
<a class="btn btn-outline-primary" href="{{ url_for('import_start', kind='products') }}">Importeren</a>
<a class="btn btn-primary" href="{{ url_for('product_edit') }}">Nieuw product</a>
</div>
@@ -22,10 +23,11 @@
<div class="card"><div class="card-body">
{% if rows %}
<div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th class="d-none d-md-table-cell"></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>
<thead><tr>{{ bulk_head() }}<th class="d-none d-md-table-cell"></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) }}'">
{{ bulk_cell(p.id, p.name) }}
<td class="d-none d-md-table-cell" 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 class="stack-title"><a href="{{ url_for('product_edit', pid=p.id) }}">{{ p.name }}</a><div class="muted">{{ p.description|truncate(90) }}</div></td>
<td data-label="SKU"><code>{{ p.sku }}</code></td>
@@ -41,4 +43,7 @@
</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></div>
{% call bulk_bar(url_for('products_bulk'), [('activate', 'Actief maken'), ('deactivate', 'Inactief maken'),
('export', 'Exporteren als CSV'),
('delete', 'Verwijderen', '{n} product(en) verwijderen? Producten die in offertes staan, worden op inactief gezet.')]) %}{% endcall %}
{% 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 %}
+8 -1
View File
@@ -1,9 +1,11 @@
{% extends 'base.html' %}
{% from '_bulk.html' import bulk_head, bulk_cell, bulk_bar with context %}
{% block title %}Offertes{% endblock %}
{% block content %}
<div class="head">
<div><div class="kicker">Verkoop</div><h1>Offertes</h1></div>
<div class="actions">
<a class="btn btn-outline-primary" href="{{ url_for('quotes_export', **request.args) }}" title="Alle velden van de offertes in deze lijst">Exporteer CSV</a>
<form method="post" action="{{ url_for('quote_new') }}" class="inline"><button class="btn btn-primary">Nieuwe offerte</button></form>
</div>
</div>
@@ -14,10 +16,11 @@
<div class="card"><div class="card-body">
{% if rows %}
<div class="table-responsive"><table class="table table-hover table-stack">
<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>
<thead><tr>{{ bulk_head() }}<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) }}'">
{{ bulk_cell(qt.id, qt.number) }}
<td class="stack-title"><a href="{{ url_for('quote_edit', qid=qt.id) }}">{{ qt.number }}</a></td>
<td data-label="Klant">{{ qt.contact_name or '—' }}</td>
<td data-label="Onderwerp">{{ qt.title }}</td>
@@ -30,4 +33,8 @@
</table></div>
{% else %}<p class="empty">Nog geen offertes.</p>{% endif %}
</div></div>
{% call bulk_bar(url_for('quotes_bulk'), [('status', 'Status wijzigen'), ('export', 'Exporteren als CSV'),
('delete', 'Verwijderen', '{n} offerte(s) verwijderen, met hun regels? Dit kan niet ongedaan worden gemaakt.')]) %}
<select class="form-select form-select-sm" name="status" data-show="status" aria-label="Nieuwe status">{% for k, l in QUOTE_STATUSES %}<option value="{{ k }}">{{ l }}</option>{% endfor %}</select>
{% endcall %}
{% endblock %}
+17 -7
View File
@@ -1,4 +1,6 @@
{% extends 'base.html' %}
{% from '_documents.html' import documents_list with context %}
{% from '_kaart.html' import kaart_block %}
{% block title %}{{ r.name }}{% endblock %}
{% block content %}
<div class="head">
@@ -58,17 +60,18 @@
{% if designs %}
<div class="card"><div class="card-body">
<h2>Ontwerpen en tekeningen</h2>
<h2>Woningen en tekeningen</h2>
<div class="table-responsive"><table class="table table-stack">
<thead><tr><th>Ontwerp</th><th>Contact</th><th>Tekeningen</th><th>Ontvangen</th></tr></thead>
<thead><tr><th>Woning</th><th>Contact</th><th>Versie</th><th>Tekeningen</th><th>Gewijzigd</th></tr></thead>
<tbody>
{% for d in designs %}{% set p = pdfs.get(d.id, {}) %}
<tr>
<td class="stack-title"><a href="{{ url_for('design_viewer', fid=d.id) }}" target="_blank">{{ d.title }}</a>{% if d.chosen %} <span class="chip">Gekozen model</span>{% endif %}</td>
<td data-label="Contact"><a href="{{ url_for('contact_view', cid=d.contact_id) }}">{{ d.contact_name }}</a></td>
<td data-label="Tekeningen"><span>{% for k, label in PDF_KINDS.items() %}{% if p.get(k) %}<a href="{{ p[k].url }}" target="_blank">{{ (label[:1]|upper) ~ label[1:] }}</a>{% if not loop.last %} · {% endif %}{% endif %}{% endfor %}
{% if not p %}<span class="muted">Wordt gemaakt bij openen van het contact</span>{% endif %}</span></td>
<td data-label="Ontvangen" class="muted">{{ d.created_at|dt('%d-%m-%Y') }}</td>
<td class="stack-title"><a href="{{ url_for('design_viewer', fid=d.latest.id) }}" target="_blank">{{ d.title }}</a>{% if d.chosen %} <span class="chip">Gekozen woning</span>{% endif %}</td>
<td data-label="Contact">{% if d.contact_id %}<a href="{{ url_for('contact_view', cid=d.contact_id) }}">{{ d.contact_name }}</a>{% else %}<span class="muted">relatie</span>{% endif %}</td>
<td data-label="Versie">{{ d.latest.version }}</td>
<td data-label="Tekeningen"><span>{% for k, label in PDF_KINDS.items() %}{% if p.get(k) %}<a href="{{ p[k].url }}" target="_blank">{{ (label[:1]|upper) ~ label[1:] }}</a>{% if not p[k].current %} <span class="muted">(v{{ p[k].version }}, ouder)</span>{% endif %}{% if not loop.last %} · {% endif %}{% endif %}{% endfor %}
{% if not p %}<span class="muted">Wordt gemaakt bij openen van de woning</span>{% endif %}</span></td>
<td data-label="Gewijzigd" class="muted">{{ d.latest.created_at|dt('%d-%m-%Y') }}</td>
</tr>
{% endfor %}
</tbody>
@@ -76,6 +79,8 @@
</div></div>
{% endif %}
{% if kaart %}{{ kaart_block(kaart) }}{% endif %}
{% if r.kind == 'partner' %}
<div class="card"><div class="card-body">
<h2>Producten van deze leverancier</h2>
@@ -127,6 +132,11 @@
{% if r.notes %}<h3>Notities</h3><p style="white-space:pre-line;margin:0">{{ r.notes }}</p>{% endif %}
</div></div>
<div class="card"><div class="card-body">
<h2>Documenten van de relatie</h2>
{{ documents_list(documents, url_for('relation_upload', rid=r.id)) }}
</div></div>
{% if tasks %}
<div class="card"><div class="card-body">
<h2>Open taken</h2>
+9 -1
View File
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% from '_bulk.html' import bulk_head, bulk_cell, bulk_bar with context %}
{% block title %}{{ 'Klanten' if kind == 'klant' else 'Partners' }}{% endblock %}
{% block content %}
<div class="head">
@@ -13,6 +14,7 @@
{% if type %}<input type="hidden" name="type" value="{{ type }}">{% endif %}
<input class="form-control" type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, plaats, persoon…" aria-label="Zoeken">
</form>
<a class="btn btn-outline-primary" href="{{ url_for('relations_export', **request.args) }}" title="Alle velden van de relaties in deze lijst">Exporteer CSV</a>
<a class="btn btn-outline-primary" href="{{ url_for('import_start', kind='relations') }}">Importeren</a>
<a class="btn btn-primary" href="{{ url_for('relation_edit', kind=kind, type=type or None) }}">{{ 'Nieuwe klant' if kind == 'klant' else 'Nieuwe partner' }}</a>
</div>
@@ -28,10 +30,11 @@
<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>Type</th><th>Contactpersonen</th><th>Plaats</th><th>Contact</th>{% if kind == 'partner' %}<th class="num">Producten</th>{% endif %}</tr></thead>
<thead><tr>{{ bulk_head() }}<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) }}'">
{{ bulk_cell(r.id, r.name) }}
<td class="stack-title"><a href="{{ url_for('relation_view', rid=r.id) }}">{{ r.name }}</a></td>
<td data-label="Type">{% if r.type %}<span class="chip">{{ r.type }}</span>{% endif %}</td>
<td data-label="Personen"><span>{{ r.member_names or '—' }}{% if r.members > 4 %} <span class="muted">+{{ r.members - 4 }}</span>{% endif %}</span></td>
@@ -44,4 +47,9 @@
</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></div>
{% call bulk_bar(url_for('relations_bulk'), [('type', 'Type wijzigen'), ('export', 'Exporteren als CSV'),
('delete', 'Verwijderen', '{n} relatie(s) verwijderen? De contactpersonen en producten blijven bewaard.')]) %}
<input type="hidden" name="kind" value="{{ kind }}">
<select class="form-select form-select-sm" name="type" data-show="type" aria-label="Nieuw type"><option value="">(geen type)</option>{% for t in types %}<option value="{{ t }}">{{ t }}</option>{% endfor %}</select>
{% endcall %}
{% endblock %}
+55
View File
@@ -0,0 +1,55 @@
{% extends 'base.html' %}
{% block title %}{{ job.name if job else jt.label }}{% endblock %}
{% block content %}
<div class="head">
<div><a class="label" href="{{ url_for('routines') }}">← Routines</a><h1>{{ job.name if job else 'Nieuwe routine' }}</h1><div class="muted">{{ jt.label }}</div></div>
{% if job %}<div class="actions"><form method="post" action="{{ url_for('routine_run', jid=job.id) }}" class="inline"><button class="btn btn-outline-primary">Nu uitvoeren</button></form></div>{% endif %}
</div>
<div class="row g-4">
<div class="col-lg-7">
<form method="post" class="card"><div class="card-body">
{% if jt.description %}<p class="hint" style="margin:0 0 1rem">{{ jt.description }}</p>{% endif %}
<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="{{ job.name if job else jt.label }}"></div>
<div class="col-6 col-md-4"><label class="form-label" for="every">Elke</label><input class="form-control" id="every" type="number" min="1" name="every" value="{{ every }}" required></div>
<div class="col-6 col-md-4"><label class="form-label" for="unit">Eenheid</label>
<select class="form-select" id="unit" name="unit">{% for size, l in UNITS %}<option value="{{ size }}" {{ 'selected' if unit == size }}>{{ l }}</option>{% endfor %}</select></div>
{% for f in jt.fields %}
{% if f.kind == 'bool' %}
<div class="col-12"><div class="form-check"><input class="form-check-input" type="checkbox" id="f-{{ f.key }}" name="{{ f.key }}" value="1" {{ 'checked' if config[f.key] }}><label class="form-check-label" for="f-{{ f.key }}">{{ f.label }}</label></div>{% if f.hint %}<p class="hint">{{ f.hint }}</p>{% endif %}</div>
{% elif f.kind == 'mailboxes' %}
<div class="col-12"><span class="form-label d-block">{{ f.label }}</span>
<div class="d-flex flex-wrap gap-3">{% for m in mailboxes %}<div class="form-check"><input class="form-check-input" type="checkbox" id="f-{{ f.key }}-{{ m.id }}" name="{{ f.key }}" value="{{ m.id }}" {{ 'checked' if m.id in config[f.key] }}><label class="form-check-label" for="f-{{ f.key }}-{{ m.id }}">{{ m.name }}</label></div>{% else %}<span class="muted">Nog geen mailboxen.</span>{% endfor %}</div>
{% if f.hint %}<p class="hint">{{ f.hint }}</p>{% endif %}</div>
{% else %}
<div class="col-md-8"><label class="form-label" for="f-{{ f.key }}">{{ f.label }}</label><input class="form-control" id="f-{{ f.key }}" type="{{ 'number' if f.kind == 'number' else f.kind }}" name="{{ f.key }}" value="{{ config[f.key] }}">{% if f.hint %}<p class="hint">{{ f.hint }}</p>{% endif %}</div>
{% endif %}
{% endfor %}
<div class="col-12"><div class="form-check"><input class="form-check-input" type="checkbox" id="active" name="active" value="1" {{ 'checked' if not job or job.active }}><label class="form-check-label" for="active">Actief</label></div></div>
</div>
<div class="actions">
<button class="btn btn-primary">Opslaan</button>
<a class="btn btn-outline-secondary" href="{{ url_for('routines') }}">Annuleren</a>
</div>
</div></form>
{% if job %}
<form method="post" action="{{ url_for('routine_delete', jid=job.id) }}" onsubmit="return confirm('Routine verwijderen?')">
<button class="btn btn-outline-secondary btn-sm">Routine verwijderen</button>
</form>
{% endif %}
</div>
{% if job %}
<div class="col-lg-5">
<div class="card"><div class="card-body">
<h2>Logboek</h2>
<p class="hint">Volgende keer: {{ (job.next_run|dt if job.next_run else 'zo snel mogelijk') if job.active else 'staat uit' }}</p>
{% if runs %}
<ul class="timeline">
{% for r in runs %}<li><b>{{ r.started_at|dt }}</b> {% if r.status == 'fout' %}<span class="chip nieuw">Fout</span>{% endif %}{% if r.manual %} <span class="muted">(handmatig)</span>{% endif %}<br><span class="muted">{{ r.message }}</span></li>{% endfor %}
</ul>
{% else %}<p class="empty">Nog niet uitgevoerd.</p>{% endif %}
</div></div>
</div>
{% endif %}
</div>
{% endblock %}
+35
View File
@@ -0,0 +1,35 @@
{% extends 'base.html' %}
{% block title %}Routines{% endblock %}
{% block content %}
<div class="head">
<div><div class="kicker">Beheer</div><h1>Routines</h1></div>
<div class="actions">
<div class="dropdown">
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Nieuwe routine</button>
<ul class="dropdown-menu dropdown-menu-end">{% for k, t in TYPES.items() %}<li><a class="dropdown-item" href="{{ url_for('routine_edit', kind=k) }}">{{ t.label }}</a></li>{% endfor %}</ul>
</div>
</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>Soort</th><th>Elke</th><th>Laatst</th><th>Volgende</th><th>Resultaat</th><th></th></tr></thead>
<tbody>
{% for j in rows %}
<tr class="link" onclick="location='{{ url_for('routine_edit', jid=j.id) }}'">
<td class="stack-title"><a href="{{ url_for('routine_edit', jid=j.id) }}">{{ j.name }}</a>{% if not j.active %} <span class="chip verloren">Uit</span>{% endif %}{% if j.running_since %} <span class="chip nieuw">Bezig</span>{% endif %}</td>
<td data-label="Soort">{{ TYPES[j.kind].label if j.kind in TYPES else j.kind }}</td>
<td data-label="Elke">{{ interval_text(j.interval_minutes) }}</td>
<td data-label="Laatst" class="muted">{{ j.last_run|dt if j.last_run else '—' }}</td>
<td data-label="Volgende" class="muted">{{ (j.next_run|dt if j.next_run else 'zo snel mogelijk') if j.active else '—' }}</td>
<td data-label="Resultaat">{% if j.last_status == 'fout' %}<span class="chip nieuw">Fout</span> {% endif %}<span class="muted">{{ j.last_message|truncate(80) }}</span></td>
<td class="num"><form method="post" action="{{ url_for('routine_run', jid=j.id) }}" class="inline" onclick="event.stopPropagation()"><button class="btn btn-outline-primary btn-sm">Nu uitvoeren</button></form></td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Nog geen routines.</p>{% endif %}
</div></div>
<p class="muted">Routines draaien op de achtergrond (Docker: container <code>crm-routines</code>; zonder Docker: cron elke minuut
<code>python app.py tick</code>). Elke halve minuut wordt gekeken welke aan de beurt zijn; een routine draait nooit twee keer tegelijk.</p>
{% 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 %}
+46
View File
@@ -0,0 +1,46 @@
{% extends 'base.html' %}
{% block title %}{{ name or 'Nieuw segment' }}{% endblock %}
{% macro checks(key, options, selected) %}
<div class="d-flex flex-wrap gap-3">{% for v, l in options %}<div class="form-check"><input class="form-check-input" type="checkbox" id="{{ key }}-{{ loop.index }}" name="{{ key }}" value="{{ v }}" {{ 'checked' if v in selected }}><label class="form-check-label" for="{{ key }}-{{ loop.index }}">{{ l }}</label></div>{% endfor %}</div>
{% endmacro %}
{% block content %}
<div class="head">
<div><a class="label" href="{{ url_for('segments') }}">← Segmenten</a><h1>{{ name or 'Nieuw segment' }}</h1></div>
</div>
<div class="row g-4">
<div class="col-lg-7">
<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="{{ name }}" placeholder="Bijvoorbeeld: Leads met een ontwerp"></div>
<div class="col-12"><span class="form-label d-block">Soort</span>{{ checks('types', TYPES, filters.types or []) }}</div>
<div class="col-12"><span class="form-label d-block">Status</span>{{ checks('statuses', STATUSES, filters.statuses or []) }}</div>
{% if sources %}<div class="col-12"><span class="form-label d-block">Bron</span>{{ checks('sources', sources, filters.sources or []) }}</div>{% endif %}
<div class="col-12"><span class="form-label d-block">Toegewezen aan</span>{{ checks('users', users, filters.users or []) }}</div>
<div class="col-md-4"><label class="form-label" for="city">Plaats bevat</label><input class="form-control" id="city" name="city" value="{{ filters.city or '' }}"></div>
<div class="col-6 col-md-4"><label class="form-label" for="created_from">Aangemaakt vanaf</label><input class="form-control" id="created_from" type="date" name="created_from" value="{{ filters.created_from or '' }}"></div>
<div class="col-6 col-md-4"><label class="form-label" for="created_to">tot en met</label><input class="form-control" id="created_to" type="date" name="created_to" value="{{ filters.created_to or '' }}"></div>
<div class="col-12"><div class="form-check"><input class="form-check-input" type="checkbox" id="has_design" name="has_design" value="1" {{ 'checked' if filters.has_design }}><label class="form-check-label" for="has_design">Alleen met een gekozen woningontwerp</label></div></div>
</div>
<p class="hint">Niets aangevinkt bij een onderdeel = geen beperking daarop.</p>
<div class="actions">
<button class="btn btn-primary" name="action" value="save">Opslaan</button>
<button class="btn btn-outline-primary" name="action" value="preview">Ontvangers tellen</button>
</div>
</div></form>
{% if seg %}
<form method="post" action="{{ url_for('segment_delete', sid=seg.id) }}" onsubmit="return confirm('Segment verwijderen? Nieuwsbrieven die het gebruiken houden hun vastgelegde ontvangers.')">
<button class="btn btn-outline-secondary btn-sm">Segment verwijderen</button>
</form>
{% endif %}
</div>
<div class="col-lg-5">
<div class="card"><div class="card-body">
<h2>{{ count }} ontvanger{{ '' if count == 1 else 's' }}</h2>
{% if sample %}
<ul class="timeline">{% for c in sample %}<li><a href="{{ url_for('contact_view', cid=c.id) }}">{{ c.name or c.email }}</a><br><span class="muted">{{ c.email }}{% if c.city %} · {{ c.city }}{% endif %}</span></li>{% endfor %}</ul>
{% if count > sample|length %}<p class="hint">En nog {{ count - sample|length }}.</p>{% endif %}
{% else %}<p class="empty">Geen contacten met deze filters.</p>{% endif %}
</div></div>
</div>
</div>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% block title %}Segmenten{% endblock %}
{% block content %}
<div class="head">
<div><a class="label" href="{{ url_for('campaigns') }}">← Nieuwsbrieven</a><h1>Segmenten</h1></div>
<div class="actions"><a class="btn btn-primary" href="{{ url_for('segment_edit') }}">Nieuw segment</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 class="num">Ontvangers nu</th><th>Bijgewerkt</th></tr></thead>
<tbody>
{% for s in rows %}
<tr class="link" onclick="location='{{ url_for('segment_edit', sid=s.id) }}'">
<td class="stack-title"><a href="{{ url_for('segment_edit', sid=s.id) }}">{{ s.name }}</a></td>
<td data-label="Ontvangers nu" class="num">{{ s.n }}</td>
<td data-label="Bijgewerkt" class="muted">{{ s.updated_at|dt }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Nog geen segmenten.</p>{% endif %}
</div></div>
<p class="muted">Een segment is een selectie van contacten met een e-mailadres. Afgemelde en onbestelbare adressen tellen nooit mee;
per e-mailadres gaat er één nieuwsbrief uit.</p>
{% endblock %}
+26 -22
View File
@@ -6,6 +6,32 @@
{% block content %}
<div class="head"><div><div class="kicker">Beheer</div><h1>Instellingen</h1></div></div>
<div class="card" id="mail"><div class="card-body">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
<div><h2>Mailboxen</h2>
<p class="hint" style="margin:-.4rem 0 1rem">Elke mailbox (info@, facturen@, nieuwsbrief@ of een persoonlijke) heeft een eigen account. Een mailbox
zonder gekozen gebruikers is gedeeld met iedereen. Ophalen gaat via de routine "E-mail ophalen" (<a href="{{ url_for('routines') }}">Routines</a>).</p></div>
<a class="btn btn-primary" href="{{ url_for('mailbox_edit') }}">Mailbox toevoegen</a>
</div>
{% if mailboxes %}
<div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th>Naam</th><th>Adres</th><th>Rol</th><th>Toegang</th><th>Laatst opgehaald</th><th>Status</th></tr></thead>
<tbody>
{% for m in mailboxes %}
<tr class="link" onclick="location='{{ url_for('mailbox_edit', mbid=m.id) }}'">
<td class="stack-title"><a href="{{ url_for('mailbox_edit', mbid=m.id) }}">{{ m.name }}</a></td>
<td data-label="Adres">{{ m.imap_user or m.smtp_user }}</td>
<td data-label="Rol">{{ PURPOSE.get(m.purpose, m.purpose) }}</td>
<td data-label="Toegang">{{ m.access or 'Iedereen' }}</td>
<td data-label="Laatst opgehaald" class="muted">{{ m.last_sync|dt if m.last_sync else '—' }}</td>
<td data-label="Status">{% if not m.active %}<span class="chip verloren">Inactief</span>{% elif m.last_error %}<span class="chip nieuw" title="{{ m.last_error }}">Fout</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Nog geen mailbox.</p>{% endif %}
</div></div>
<form method="post">
<div class="card"><div class="card-body">
<h2>Bedrijfsgegevens</h2>
@@ -33,28 +59,6 @@
</div>
</div></div>
<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>
<h3>Ontvangen (IMAP)</h3>
<div class="row g-3">
{{ field('imap_host', 'Server', placeholder='imap.gmail.com') }}
{{ field('imap_port', 'Poort', 'number', '993') }}
{{ field('imap_user', 'Gebruikersnaam', placeholder='info@vanemous.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_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>
</div></div>
<div class="card"><div class="card-body">
<h2>Formulieren en API</h2>
<div class="row g-3">
+20
View File
@@ -0,0 +1,20 @@
{% extends 'base.html' %}
{% block title %}Afmelden{% endblock %}
{% block body %}
<div class="auth-wrap">
<div class="auth">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="{{ company }}">
<div class="card"><div class="card-body">
<h1>Afmelden</h1>
{% if not valid %}
<p>Deze afmeldlink is niet geldig. Neem contact met ons op als u geen nieuwsbrieven meer wilt ontvangen.</p>
{% elif done %}
<p>U bent afgemeld. U ontvangt geen nieuwsbrieven meer van {{ company }}.</p>
{% else %}
<p>Wilt u geen nieuwsbrieven meer ontvangen van {{ company }}?</p>
<form method="post"><button class="btn btn-primary">Afmelden</button></form>
{% endif %}
</div></div>
</div>
</div>
{% endblock %}
+2
View File
@@ -19,6 +19,8 @@
<div class="actions">
<button class="btn btn-primary">Opslaan</button>
<a class="btn btn-outline-secondary" href="{{ url_for('users') }}">Annuleren</a>
{% if u.id and u.id != g.user.id %}<button class="btn btn-outline-secondary ms-auto" form="delete-user" onclick='return confirm({{ (u.name ~ " verwijderen? Contacten, taken en activiteiten blijven zonder eigenaar staan; agenda en meldingen verdwijnen. Alleen inactief maken kan ook: haal het vinkje Actief weg.")|tojson }})'>Verwijderen</button>{% endif %}
</div>
</div></form>
{% if u.id and u.id != g.user.id %}<form id="delete-user" method="post" action="{{ url_for('user_delete', uid=u.id) }}"></form>{% endif %}
{% endblock %}
+5 -1
View File
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% from '_bulk.html' import bulk_head, bulk_cell, bulk_bar with context %}
{% block title %}Gebruikers{% endblock %}
{% block content %}
<div class="head">
@@ -7,10 +8,11 @@
</div>
<div class="card"><div class="card-body">
<div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th>Naam</th><th>E-mail</th><th>Rol</th><th class="num">Contacten</th><th>Status</th></tr></thead>
<thead><tr>{{ bulk_head() }}<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) }}'">
{{ bulk_cell(u.id, u.name) }}
<td class="stack-title"><a href="{{ url_for('user_edit', uid=u.id) }}">{{ u.name }}</a></td>
<td data-label="E-mail">{{ u.email }}</td>
<td data-label="Rol">{{ ROLE.get(u.role, u.role) }}</td>
@@ -22,4 +24,6 @@
</table></div>
</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>
{{ bulk_bar(url_for('users_bulk'), [('activate', 'Actief maken'), ('deactivate', 'Inactief maken (kan niet meer inloggen)'),
('delete', 'Verwijderen', '{n} gebruiker(s) verwijderen? Hun contacten, taken en activiteiten blijven zonder eigenaar staan; hun agenda en meldingen verdwijnen. Dit kan niet ongedaan worden gemaakt.')]) }}
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Een getagde versie van Tectum (de configurator) naar de server brengen (eerst eenmalig server/tectum-setup.sh).
#
# tools/tectum-release.sh crm@tectum.voorbeeld.nl nieuwste tag
# tools/tectum-release.sh crm@tectum.voorbeeld.nl v0.7.6 een bepaalde versie (ook om terug te rollen)
#
# De bestanden komen uit `git archive <tag>` van $CONFIGURATOR (standaard ../configurator), dus niet-gecommit
# werk gaat niet mee. rsync met hardlinks naar de vorige versie, zodat alleen wijzigingen over de lijn gaan;
# daarna wijst `current` naar de nieuwe versie. De laatste 5 versies blijven staan.
set -euo pipefail
REPO="$(cd "$(dirname "$0")/.." && pwd)"
HOST="${1:-}"
CONFIGURATOR="${CONFIGURATOR:-$REPO/../configurator}"
ROOT=/srv/www/tectum
say() { printf '\033[38;2;185;89;19m▸\033[0m %s\n' "$*"; }
fail() { printf 'Fout: %s\n' "$*" >&2; exit 1; }
[ -n "$HOST" ] || fail "gebruik: tools/tectum-release.sh crm@server [vX.Y.Z]"
[ -f "$CONFIGURATOR/index.html" ] || fail "geen configurator in $CONFIGURATOR (zet CONFIGURATOR=pad)"
TAG="${2:-$(git -C "$CONFIGURATOR" tag --list 'v*' --sort=-v:refname | head -n1)}"
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "onbekende versie '$TAG' (verwacht vX.Y.Z)"
git -C "$CONFIGURATOR" rev-parse -q --verify "refs/tags/$TAG" >/dev/null || fail "tag $TAG bestaat niet"
[ -z "$(git -C "$CONFIGURATOR" status --porcelain)" ] || say "let op: niet-gecommitte wijzigingen in de configurator zitten niet in $TAG"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
chmod 755 "$tmp" # mktemp maakt hem 700, en rsync -a neemt dat mee (nginx moet erin kunnen)
git -C "$CONFIGURATOR" archive "$TAG" | tar -x -C "$tmp"
rm -f "$tmp/README.md" "$tmp/start.sh" "$tmp/.gitignore"
say "Tectum $TAG uploaden naar $HOST"
ssh "$HOST" "mkdir -p $ROOT/releases/$TAG"
rsync -az --delete --link-dest="$ROOT/current/" "$tmp/" "$HOST:$ROOT/releases/$TAG/"
say "$TAG actief maken"
ssh "$HOST" "set -e; cd $ROOT; ln -sfn releases/$TAG current.new; mv -T current.new current
ls -1 releases | sort -V | head -n -5 | grep -vxF '$TAG' | sed 's|^|releases/|' | xargs -r rm -rf"
say "klaar: Tectum $TAG staat live"