Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a97f992da | ||
|
|
0368b7c1da | ||
|
|
e9f83278c9 | ||
|
|
805448d515 | ||
|
|
b66d6a6fe6 | ||
|
|
c69c3773dc | ||
|
|
db3e26d258 | ||
|
|
3cf53bc248 | ||
|
|
cad838f1f4 | ||
|
|
8f6e1ab443 | ||
|
|
366a3f9d17 | ||
|
|
81fc27fa86 |
@@ -4,26 +4,49 @@ Eenvoudige CRM in Python (Flask) met SQLite, in de Van Emous-huisstijl. Versie 0
|
|||||||
|
|
||||||
## Functies
|
## 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
|
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.
|
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,
|
- **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,
|
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
|
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.
|
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
|
- **Documenten met versies** bij leads/klanten en bij relaties: elk document heeft een of meer versies (nieuwe
|
||||||
formulier, de API of geüpload bij Bestanden) wordt het gekozen model; het nieuwste telt, een ander ontwerp
|
versie uploaden met een opmerking, oude versies blijven te openen en te verwijderen). Soorten: bestand, woning
|
||||||
kies je op de contactpagina. Daar staat het in een **3D-viewer** op de engine van de configurator
|
en de tekeningen (werktekening, klant-PDF) van een woning. Bestanden van vóór v0.0.7 worden bij het starten
|
||||||
(`/files/<id>/viewer`): 3D, doorsnede en plattegrond, met maten, m², inhoud, gewicht en een indicatieve
|
vanzelf versie 1 van een eigen document.
|
||||||
prijs (uit de kosten-CSV's van de configurator). In de leadlijst staat het model als kolom.
|
- **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
|
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.
|
(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
|
- **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
|
**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
|
**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
|
prijs en QR-code, dan aanzichten en plattegrond en de werkwijze; wel in het portaal). Per woningversie: ontbreken
|
||||||
het eerste openen; opnieuw maken via PDF in de weergave. Op de contactpagina bij het gekozen model en op de
|
ze voor de nieuwste versie, dan maakt de viewer ze vanzelf bij het openen; opnieuw maken via PDF in de weergave.
|
||||||
relatiepagina (Ontwerpen en tekeningen, voor alle contactpersonen).
|
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.
|
- 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.
|
- **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.
|
- **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.
|
Voorbeelden staan onder Instellingen.
|
||||||
- **Import** van CSV en JSON voor leads/contactpersonen, relaties en producten, met een voorbeeld en kolomkoppeling.
|
- **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.
|
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),
|
- **Mailboxen** (Instellingen › Mailboxen): meerdere accounts naast elkaar (info@, facturen@, nieuwsbrief@,
|
||||||
contact aanmaken vanuit een mail, beantwoorden.
|
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.
|
- **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
|
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
|
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
|
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).
|
berekening; bestaande productprijzen worden alleen bijgewerkt als je dat aanvinkt (met producthistorie).
|
||||||
De regels staan in `PRODUCT_RULES` in `static/bestek.js`.
|
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
|
- **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
|
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.
|
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.
|
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):
|
Demoleads (lorem ipsum, elk met een standaardmodel als gekozen model):
|
||||||
|
|
||||||
```sh
|
```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,
|
`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
|
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),
|
`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:
|
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:
|
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.
|
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
|
## Opslag
|
||||||
|
|
||||||
Alles staat in `data/`: `crm.db` (SQLite), `uploads/` (bestanden), `secret.key` (sessies).
|
Alles staat in `data/`: `crm.db` (SQLite), `uploads/` (bestanden), `secret.key` (sessies).
|
||||||
@@ -140,10 +216,13 @@ mailwachtwoorden in.
|
|||||||
```
|
```
|
||||||
app.py routes en logica
|
app.py routes en logica
|
||||||
importer.py CSV/JSON inlezen en kolommen herkennen
|
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
|
schema.sql database
|
||||||
templates/ pagina's (Jinja)
|
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)
|
static/configurator/ engine van de configurator (tools/configurator-sync.sh, niet in git)
|
||||||
tools/ configurator-sync.sh, export-models.js (standaardmodellen als JSON)
|
tools/ configurator-sync.sh, export-models.js (standaardmodellen als JSON)
|
||||||
demo/modellen standaardmodellen als house-design-JSON voor de demoleads
|
demo/modellen standaardmodellen als house-design-JSON voor de demoleads
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -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 email
|
||||||
import html
|
import html
|
||||||
import imaplib
|
import imaplib
|
||||||
import re
|
import re
|
||||||
import smtplib
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from email.headerregistry import Address
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from email.policy import default as policy
|
from email.policy import default as policy
|
||||||
from email.utils import formatdate, make_msgid, parseaddr, parsedate_to_datetime
|
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()
|
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):
|
def _connect_imap(cfg):
|
||||||
host, port = cfg['imap_host'], int(cfg.get('imap_port') or 993)
|
host, port = cfg['imap_host'], int(cfg.get('imap_port') or 993)
|
||||||
if port == 993:
|
sec = _security(cfg, 'imap', 993)
|
||||||
conn = imaplib.IMAP4_SSL(host, port, timeout=30)
|
if sec == 'ssl':
|
||||||
|
conn = imaplib.IMAP4_SSL(host, port, ssl_context=ssl.create_default_context(), timeout=30)
|
||||||
else:
|
else:
|
||||||
conn = imaplib.IMAP4(host, port, timeout=30)
|
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', ''))
|
conn.login(cfg['imap_user'], cfg.get('imap_pass', ''))
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
def fetch(cfg, known, limit=200):
|
def _connect_smtp(cfg):
|
||||||
"""Haal berichten van de laatste `imap_days` dagen op die nog niet in `known` staan."""
|
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'
|
folder = cfg.get('imap_folder') or 'INBOX'
|
||||||
days = int(cfg.get('imap_days') or 30)
|
days = int(cfg.get('imap_days') or 30)
|
||||||
since = datetime.now() - timedelta(days=days)
|
since = datetime.now() - timedelta(days=days)
|
||||||
@@ -48,14 +157,14 @@ def fetch(cfg, known, limit=200):
|
|||||||
|
|
||||||
conn = _connect_imap(cfg)
|
conn = _connect_imap(cfg)
|
||||||
try:
|
try:
|
||||||
typ, _ = conn.select(f'"{folder}"', readonly=True)
|
typ, _ = conn.select(_quote(folder), readonly=True)
|
||||||
if typ != 'OK':
|
if typ != 'OK':
|
||||||
raise RuntimeError(f'Map "{folder}" niet gevonden')
|
raise RuntimeError(f'Map "{folder}" niet gevonden')
|
||||||
_, data = conn.uid('search', None, 'SINCE', since_str)
|
_, data = conn.uid('search', None, 'SINCE', since_str)
|
||||||
uids = data[0].split()[-limit:]
|
uids = data[0].split()[-limit:]
|
||||||
out = []
|
out = []
|
||||||
for uid in uids:
|
for uid in uids:
|
||||||
key = f'{folder}:{uid.decode()}'
|
key = f'{prefix}{folder}:{uid.decode()}'
|
||||||
if key in known:
|
if key in known:
|
||||||
continue
|
continue
|
||||||
_, msgdata = conn.uid('fetch', uid, '(BODY.PEEK[])')
|
_, 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)',
|
'subject': str(msg.get('Subject', '')) or '(geen onderwerp)',
|
||||||
'body': _text(msg),
|
'body': _text(msg),
|
||||||
'date': date,
|
'date': date,
|
||||||
|
'bounced': bounced(msg),
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
finally:
|
finally:
|
||||||
@@ -85,32 +195,91 @@ def fetch(cfg, known, limit=200):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def send(cfg, to, subject, body, in_reply_to=None):
|
def sender(cfg):
|
||||||
host = cfg.get('smtp_host')
|
"""Afzender voor From: smtp_from, of de inlognaam. Alleen een naam in smtp_from ('Machiel Heinen') wordt
|
||||||
if not host:
|
'Machiel Heinen <inlognaam>': zonder adres weigert de ontvangende server het bericht."""
|
||||||
raise RuntimeError('Geen SMTP-server ingesteld')
|
|
||||||
user = cfg.get('smtp_user') or cfg.get('imap_user', '')
|
user = cfg.get('smtp_user') or cfg.get('imap_user', '')
|
||||||
password = cfg.get('smtp_pass') or cfg.get('imap_pass', '')
|
raw = (cfg.get('smtp_from') or '').strip()
|
||||||
port = int(cfg.get('smtp_port') or 587)
|
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 = EmailMessage()
|
||||||
msg['From'] = cfg.get('smtp_from') or user
|
msg['From'] = sender(cfg)
|
||||||
msg['To'] = to
|
msg['To'] = to
|
||||||
|
if cc:
|
||||||
|
msg['Cc'] = cc
|
||||||
msg['Subject'] = subject
|
msg['Subject'] = subject
|
||||||
msg['Date'] = formatdate(localtime=True)
|
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:
|
if in_reply_to:
|
||||||
msg['In-Reply-To'] = in_reply_to
|
msg['In-Reply-To'] = in_reply_to
|
||||||
msg['References'] = in_reply_to
|
msg['References'] = in_reply_to
|
||||||
|
for key, value in (headers or {}).items():
|
||||||
|
msg[key] = value
|
||||||
msg.set_content(body)
|
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)
|
def save_sent(cfg, msg):
|
||||||
else:
|
"""Kopie in de map voor verzonden mail (smtp_sent_folder); geeft een waarschuwing of None."""
|
||||||
server = smtplib.SMTP(host, port, timeout=30)
|
folder = cfg.get('smtp_sent_folder')
|
||||||
server.starttls()
|
if not (folder and cfg.get('imap_host')):
|
||||||
with server:
|
return None
|
||||||
if user:
|
try:
|
||||||
server.login(user, password)
|
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)
|
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
@@ -68,6 +68,23 @@ CREATE TABLE IF NOT EXISTS activities (
|
|||||||
created_at TEXT NOT NULL
|
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 (
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE,
|
contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE,
|
||||||
@@ -145,10 +162,24 @@ CREATE TABLE IF NOT EXISTS emails (
|
|||||||
subject TEXT DEFAULT '',
|
subject TEXT DEFAULT '',
|
||||||
body TEXT DEFAULT '',
|
body TEXT DEFAULT '',
|
||||||
date TEXT NOT NULL,
|
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);
|
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 (
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE,
|
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,
|
done INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL
|
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
@@ -10,7 +10,8 @@
|
|||||||
# Wat het doet: pakketten (Python, nginx, certbot, rsync, sqlite3), tijdzone Europe/Amsterdam, gebruiker `crm`
|
# 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,
|
# 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,
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
DOMAIN="${1:-}"
|
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"
|
say "let op: certificaat mislukt. Klopt de DNS? Daarna opnieuw: sudo certbot --nginx -d $DOMAIN -m $EMAIL --agree-tos --redirect"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- cron: e-mail en back-up -------------------------------------------------------------------
|
# --- cron: routines en back-up -----------------------------------------------------------------
|
||||||
say "cron: e-mail elke 10 minuten, database-back-up elke nacht"
|
say "cron: routines elke minuut, database-back-up elke nacht"
|
||||||
cat > /etc/cron.d/$APP <<EOF
|
cat > /etc/cron.d/$APP <<EOF
|
||||||
# Van Emous CRM (server/setup.sh)
|
# 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'"
|
15 3 * * * crm [ -f $ROOT/data/crm.db ] && sqlite3 $ROOT/data/crm.db ".backup '$ROOT/data/backups/nacht-\$(date +\%a).db'"
|
||||||
EOF
|
EOF
|
||||||
chmod 644 /etc/cron.d/$APP
|
chmod 644 /etc/cron.d/$APP
|
||||||
|
|||||||
Executable
+66
@@ -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"
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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(); });
|
||||||
|
})();
|
||||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
@@ -72,15 +72,41 @@ a:focus-visible, button:focus-visible, .btn:focus-visible { outline: 3px solid v
|
|||||||
.active .count { background: var(--white); }
|
.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 { 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 { 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 .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; }
|
.app-main { flex: 1; min-width: 0; }
|
||||||
.content { padding: 1.25rem 1rem 2rem; max-width: 1400px; }
|
.content { padding: 1.25rem 1rem 2rem; max-width: 1400px; }
|
||||||
|
|
||||||
@media (min-width: 992px) {
|
@media (min-width: 992px) {
|
||||||
|
.topbar { padding: 0.75rem 2.5rem; }
|
||||||
.app { display: flex; min-height: 100vh; }
|
.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 { 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; }
|
.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; }
|
.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 { 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; }
|
.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 summary { cursor: pointer; font-family: var(--font-heading); font-size: 1.3rem; font-weight: 500; }
|
||||||
details[open] summary { margin-bottom: 1rem; }
|
details[open] summary { margin-bottom: 1rem; }
|
||||||
.mailbody { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 15px; line-height: 1.7; }
|
.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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after { transition: none !important; animation: none !important; }
|
*, *::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
@@ -455,7 +455,9 @@
|
|||||||
const raw = JSON.parse(document.getElementById('design').textContent);
|
const raw = JSON.parse(document.getElementById('design').textContent);
|
||||||
const key = raw.model || '', model = (HouseModel.MODELS || {})[key];
|
const key = raw.model || '', model = (HouseModel.MODELS || {})[key];
|
||||||
const name = (st.project && st.project.naam) || (model && model.label) || 'Uw woning';
|
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 totals = HouseModel.computeTotals(full), d = HouseModel.computeDimensions(full, S.groundLevel);
|
||||||
const info = {
|
const info = {
|
||||||
price: CostModel.compute(full, totals).summary.inclBtw, kg: totals.totalKg, units: st.units.length,
|
price: CostModel.compute(full, totals).summary.inclBtw, kg: totals.totalKg, units: st.units.length,
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 }}&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 %}
|
||||||
@@ -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
@@ -2,7 +2,9 @@
|
|||||||
{% block title %}Mijn account{% endblock %}
|
{% block title %}Mijn account{% endblock %}
|
||||||
{% block content %}
|
{% 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>
|
<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>
|
<h2>Wachtwoord wijzigen</h2>
|
||||||
<div class="row g-3">
|
<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>
|
<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>
|
||||||
<div class="actions"><button class="btn btn-primary">Opslaan</button></div>
|
<div class="actions"><button class="btn btn-primary">Opslaan</button></div>
|
||||||
</div></form>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
+65
-6
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
{% block head %}{% endblock %}
|
||||||
<title>{% block title %}CRM{% endblock %} · Van Emous CRM</title>
|
<title>{% block title %}CRM{% endblock %} · Van Emous CRM</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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://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="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>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% block 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='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('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('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('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('quotes') }}" class="{{ 'active' if ep.startswith('quote') }}">Offertes</a>
|
||||||
<a href="{{ url_for('import_start') }}" class="{{ 'active' if ep.startswith('import') }}">Importeren</a>
|
<a href="{{ url_for('import_start') }}" class="{{ 'active' if ep.startswith('import') }}">Importeren</a>
|
||||||
{% if g.user.role == 'admin' %}
|
{% if g.user.role == 'admin' %}
|
||||||
<a href="{{ url_for('users') }}" class="{{ 'active' if ep.startswith('user') }}">Gebruikers</a>
|
<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 %}
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
<a href="{{ url_for('account') }}" class="logout">{{ g.user.name }}</a>
|
<a href="{{ url_for('account') }}" class="logout">{{ g.user.name }}</a>
|
||||||
@@ -55,12 +63,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="app-main">
|
<div class="app-main">
|
||||||
<header class="topbar d-lg-none">
|
<header class="topbar">
|
||||||
<button class="menu-btn" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidenav" aria-controls="sidenav">
|
<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
|
<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>
|
</button>
|
||||||
<a href="{{ url_for('dashboard') }}"><img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous"></a>
|
<a href="{{ url_for('dashboard') }}" class="topbar-logo d-lg-none"><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 %}
|
<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>
|
</header>
|
||||||
<main class="content">
|
<main class="content">
|
||||||
{% with msgs = get_flashed_messages(with_categories=true) %}
|
{% with msgs = get_flashed_messages(with_categories=true) %}
|
||||||
@@ -71,5 +129,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+14
-19
@@ -1,18 +1,12 @@
|
|||||||
<!doctype html>
|
{% extends 'base.html' %}
|
||||||
<html lang="nl">
|
{% block head %}
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
{# De engine haalt kosten/*.csv en assets/ relatief op: base = de gekopieerde configurator. Alle CRM-links zijn absoluut. #}
|
{# 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/') }}">
|
<base href="{{ url_for('static', filename='configurator/') }}">
|
||||||
<title>Bestek · Offerte {{ qt.number }} · Van Emous CRM</title>
|
{% endblock %}
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
{% block title %}Bestek · Offerte {{ qt.number }}{% endblock %}
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
{% block styles %}
|
||||||
<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') }}">
|
|
||||||
<style>
|
<style>
|
||||||
.content { margin: 0 auto; max-width: 980px; }
|
.bestek-page { max-width: 980px; }
|
||||||
.bs-ch { margin-bottom: 1.5rem; }
|
.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 { 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; }
|
.bs-ch-head h2 { margin: 0; font-size: 1.1rem; }
|
||||||
@@ -28,9 +22,9 @@
|
|||||||
.bs-opts .form-check { margin-bottom: 0.4rem; }
|
.bs-opts .form-check { margin-bottom: 0.4rem; }
|
||||||
@media (max-width: 640px) { .bs-ch td.num { width: auto; } }
|
@media (max-width: 640px) { .bs-ch td.num { width: auto; } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
{% endblock %}
|
||||||
<body>
|
{% block content %}
|
||||||
<main class="content">
|
<div class="bestek-page">
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<div>
|
<div>
|
||||||
<a class="label" href="{{ url_for('quote_edit', qid=qt.id) }}">← Offerte {{ qt.number }}</a>
|
<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>
|
<a class="btn btn-outline-secondary" href="{{ url_for('quote_edit', qid=qt.id) }}">Annuleren</a></div>
|
||||||
</div></form>
|
</div></form>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
{% if engine %}
|
{% if engine %}
|
||||||
<script type="application/json" id="design">{{ design|tojson }}</script>
|
<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>
|
{% for src in scripts %}<script src="{{ src }}"></script>
|
||||||
{% endfor %}<script src="{{ url_for('static', filename='bestek.js') }}"></script>
|
{% endfor %}<script src="{{ url_for('static', filename='bestek.js') }}"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</body>
|
{% endblock %}
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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
@@ -1,4 +1,7 @@
|
|||||||
{% extends 'base.html' %}
|
{% 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 title %}{{ c.name }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="head">
|
<div class="head">
|
||||||
@@ -27,15 +30,24 @@
|
|||||||
|
|
||||||
<div class="row gx-4 stack">
|
<div class="row gx-4 stack">
|
||||||
<div class="col-lg-7 col-xl-8">
|
<div class="col-lg-7 col-xl-8">
|
||||||
|
{% if woningen %}
|
||||||
<div class="card"><div class="card-body">
|
<div class="card"><div class="card-body">
|
||||||
<div class="card-top">
|
<div class="card-top">
|
||||||
<h2>Gekozen model</h2>
|
<h2>Woning</h2>
|
||||||
{% if design %}
|
|
||||||
<div class="actions">
|
<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">
|
<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">
|
<select class="form-select form-select-sm w-auto" name="document_id" onchange="this.form.submit()" aria-label="Andere woning 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 %}
|
{% 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>
|
</select>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -43,19 +55,22 @@
|
|||||||
<input type="hidden" name="contact_id" value="{{ c.id }}"><input type="hidden" name="bestek" value="1">
|
<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>
|
<button class="btn btn-primary btn-sm">Offerte met bestek</button>
|
||||||
</form>
|
</form>
|
||||||
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('design_viewer', fid=design.id) }}" target="_blank">Openen</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-secondary btn-sm" href="{{ url_for('upload_file', name=design.stored) }}" download="{{ design.original }}">JSON</a>
|
<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>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% if design %}
|
{% if woning %}
|
||||||
<div class="viewer-frame"><iframe src="{{ url_for('design_viewer', fid=design.id) }}" title="3D-weergave {{ design.title }}" loading="lazy" allowfullscreen></iframe></div>
|
<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)">{{ design.title }}</strong>
|
<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 %}
|
· {{ 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 %}
|
{% 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:
|
{% if shown.id == woning.latest.id %}
|
||||||
{% for k, label in PDF_KINDS.items() %}{% set p = pdfs.get(k) %}
|
<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 %}
|
<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 %}
|
{% endfor %}
|
||||||
<span class="muted">· opnieuw maken via PDF in de weergave</span></p>
|
<span class="muted">· opnieuw maken via PDF in de weergave</span></p>
|
||||||
@@ -67,18 +82,22 @@
|
|||||||
if (w) w.hidden = true;
|
if (w) w.hidden = true;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% elif designs %}
|
{% endif %}
|
||||||
<p class="muted">Er is geen model gekozen.</p>
|
<details style="margin-top:1rem">
|
||||||
<form method="post" action="{{ url_for('contact_design', cid=c.id) }}" class="d-flex flex-wrap gap-2">
|
<summary style="font-size:1rem">Nieuwe versie uploaden</summary>
|
||||||
<select class="form-select w-auto" name="file_id" aria-label="Ontwerp">
|
<form method="post" action="{{ url_for('document_version', did=woning.id) }}" enctype="multipart/form-data" class="row g-2">
|
||||||
{% for d in designs %}<option value="{{ d.id }}">{{ d.title }} · {{ d.created_at|dt('%d-%m-%Y') }}</option>{% endfor %}
|
<div class="col-md-6"><input class="form-control" type="file" name="file" accept=".json,application/json" required aria-label="Woningontwerp (JSON)"></div>
|
||||||
</select>
|
<div class="col-md"><input class="form-control" name="note" placeholder="Wat is er veranderd?" aria-label="Opmerking"></div>
|
||||||
<button class="btn btn-outline-primary">Kiezen</button>
|
<div class="col-md-auto"><button class="btn btn-outline-primary">Opslaan als versie {{ woning.latest.version + 1 }}</button></div>
|
||||||
</form>
|
</form>
|
||||||
|
</details>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div></div>
|
</div></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if kaart %}{{ kaart_block(kaart) }}{% endif %}
|
||||||
|
|
||||||
<div class="card o-2"><div class="card-body">
|
<div class="card o-2"><div class="card-body">
|
||||||
<h2>Taken</h2>
|
<h2>Taken</h2>
|
||||||
@@ -101,6 +120,18 @@
|
|||||||
</form>
|
</form>
|
||||||
</div></div>
|
</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">
|
<div class="card o-3"><div class="card-body">
|
||||||
<h2>Notitie toevoegen</h2>
|
<h2>Notitie toevoegen</h2>
|
||||||
<form method="post" action="{{ url_for('contact_note', cid=c.id) }}">
|
<form method="post" action="{{ url_for('contact_note', cid=c.id) }}">
|
||||||
@@ -109,19 +140,17 @@
|
|||||||
</form>
|
</form>
|
||||||
</div></div>
|
</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 %}
|
{% if c.email %}
|
||||||
<div class="card"><div class="card-body">
|
<div class="card"><div class="card-body">
|
||||||
<details>
|
<details>
|
||||||
<summary>E-mail sturen</summary>
|
<summary>E-mail sturen</summary>
|
||||||
<form method="post" action="{{ url_for('mail_send') }}">
|
{{ compose({'to': c.email, 'contact_id': c.id}, mail_templates, mail_vars, mailboxes) }}
|
||||||
<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>
|
|
||||||
</details>
|
</details>
|
||||||
</div></div>
|
</div></div>
|
||||||
{% endif %}
|
{% 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.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.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.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 %}
|
{% endif %}{{ c.postcode }} {{ c.city }}</dd>{% endif %}
|
||||||
{% if c.source %}<dt>Bron</dt><dd>{{ c.source }}</dd>{% endif %}
|
{% if c.source %}<dt>Bron</dt><dd>{{ c.source }}</dd>{% endif %}
|
||||||
<dt>Aangemaakt</dt><dd>{{ c.created_at|dt }}</dd>
|
<dt>Aangemaakt</dt><dd>{{ c.created_at|dt }}</dd>
|
||||||
@@ -185,31 +214,8 @@
|
|||||||
</div></div>
|
</div></div>
|
||||||
|
|
||||||
<div class="card"><div class="card-body">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Bestanden</h2>
|
<h2>Documenten</h2>
|
||||||
{% if files %}
|
{{ documents_list(documents, url_for('contact_upload', cid=c.id), c.design_id or -1, url_for('contact_design', cid=c.id)) }}
|
||||||
<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>
|
|
||||||
</div></div>
|
</div></div>
|
||||||
|
|
||||||
<div class="card"><div class="card-body">
|
<div class="card"><div class="card-body">
|
||||||
|
|||||||
+10
-2
@@ -1,4 +1,5 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
{% from '_bulk.html' import bulk_head, bulk_cell, bulk_bar with context %}
|
||||||
{% block title %}Contacten{% endblock %}
|
{% block title %}Contacten{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="head">
|
<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 %}
|
{% 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">
|
<input class="form-control" type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, e-mail, plaats…" aria-label="Zoeken">
|
||||||
</form>
|
</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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,10 +34,11 @@
|
|||||||
<div class="card"><div class="card-body">
|
<div class="card"><div class="card-body">
|
||||||
{% if rows %}
|
{% if rows %}
|
||||||
<div class="table-responsive"><table class="table table-hover table-stack">
|
<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>
|
<tbody>
|
||||||
{% for c in rows %}
|
{% for c in rows %}
|
||||||
<tr class="link" onclick="location='{{ url_for('contact_view', cid=c.id) }}'">
|
<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 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="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>
|
<td data-label="Telefoon">{{ c.phone }}</td>
|
||||||
@@ -52,4 +54,10 @@
|
|||||||
</table></div>
|
</table></div>
|
||||||
{% else %}<p class="empty">Geen contacten gevonden.</p>{% endif %}
|
{% else %}<p class="empty">Geen contacten gevonden.</p>{% endif %}
|
||||||
</div></div>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -97,6 +97,15 @@
|
|||||||
<p class="empty">Geen openstaande taken.</p>
|
<p class="empty">Geen openstaande taken.</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div></div>
|
</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">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Ongelezen e-mail</h2>
|
<h2>Ongelezen e-mail</h2>
|
||||||
{% for e in mails %}
|
{% for e in mails %}
|
||||||
|
|||||||
+16
-15
@@ -1,39 +1,40 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
{% from '_compose.html' import compose %}
|
||||||
{% block title %}{{ e.subject }}{% endblock %}
|
{% block title %}{{ e.subject }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% set incoming = e.direction == 'in' %}
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<div>
|
<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>
|
<h1>{{ e.subject }}</h1>
|
||||||
<div class="muted" style="overflow-wrap:anywhere">
|
<div class="muted" style="overflow-wrap:anywhere">
|
||||||
{% if e.direction == 'in' %}Van {{ e.from_name }} <{{ e.from_addr }}>{% else %}Aan {{ e.to_addr }}{% endif %}
|
{% if incoming %}Van {{ e.from_name }} <{{ e.from_addr }}>{% else %}Aan {{ e.to_addr }}{% endif %}
|
||||||
· {{ e.date|dt }}
|
{% if e.cc %}· CC {{ e.cc }}{% endif %}
|
||||||
|
· {{ e.date|dt }}{% if e.mailbox_name %} · {{ e.mailbox_name }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if e.attachments %}<div class="muted" style="overflow-wrap:anywhere">Bijlagen: {{ e.attachments }}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{% if contact %}
|
{% if contact %}
|
||||||
<a class="btn btn-outline-primary" href="{{ url_for('contact_view', cid=contact.id) }}">Naar {{ contact.name }}</a>
|
<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>
|
<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 %}
|
{% 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>
|
</div>
|
||||||
|
|
||||||
<div class="card"><div class="card-body"><div class="mailbody">{{ e.body }}</div></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">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Beantwoorden</h2>
|
<h2>Beantwoorden</h2>
|
||||||
<form method="post" action="{{ url_for('mail_send') }}">
|
{{ compose({'to': e.from_addr, 'subject': e.subject if e.subject.lower().startswith('re:') else 'Re: ' ~ e.subject,
|
||||||
<input type="hidden" name="reply_to" value="{{ e.id }}">
|
'reply_to': e.id, 'contact_id': contact.id if contact else None, 'mailbox_id': e.mailbox_id}, templates, vars, mailboxes) }}
|
||||||
{% 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>
|
|
||||||
</div></div>
|
</div></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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
@@ -1,38 +1,63 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% block title %}Inbox{% endblock %}
|
{% block title %}Inbox{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% set sent = f == 'sent' %}
|
||||||
<div class="head">
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if not configured %}
|
{% if not mailboxes %}
|
||||||
<div class="flash">Koppel eerst je mailbox bij <a href="{{ url_for('settings') }}#mail">Instellingen</a> (IMAP voor ontvangen, SMTP voor versturen).</div>
|
<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 %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<a href="{{ url_for('inbox') }}" class="{{ 'on' if f == 'all' }}">Alle</a>
|
{% for k, l in [('all', 'Inbox'), ('unread', 'Ongelezen'), ('unlinked', 'Niet gekoppeld aan contact'), ('sent', 'Verzonden'), ('archived', 'Archief')] %}
|
||||||
<a href="{{ url_for('inbox', f='unread') }}" class="{{ 'on' if f == 'unread' }}">Ongelezen</a>
|
<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>
|
||||||
<a href="{{ url_for('inbox', f='unlinked') }}" class="{{ 'on' if f == 'unlinked' }}">Niet gekoppeld aan contact</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>
|
||||||
|
|
||||||
<div class="card"><div class="card-body">
|
<div class="card"><div class="card-body">
|
||||||
{% if rows %}
|
{% if rows %}
|
||||||
<div class="table-responsive"><table class="table table-hover table-stack">
|
<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>
|
<tbody>
|
||||||
{% for e in rows %}
|
{% for e in rows %}
|
||||||
<tr class="link {{ 'unread' if not e.is_read }}" onclick="location='{{ url_for('inbox_view', eid=e.id) }}'">
|
<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="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></td>
|
<td class="d-none d-md-table-cell"><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a>{% if e.attachments %} <span class="muted" title="{{ e.attachments }}">· bijlage</span>{% endif %}</td>
|
||||||
<td data-label="Contact">{{ e.contact_name or '' }}</td>
|
<td data-label="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>
|
<td data-label="Datum" class="muted num">{{ e.date|dt }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table></div>
|
</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>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -37,15 +37,16 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="card"><div class="card-body">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Bestanden</h2>
|
<h2>Bestanden</h2>
|
||||||
{% if files %}
|
{% if documents %}
|
||||||
<div class="files">
|
<div class="files">
|
||||||
{% for f in files %}
|
{% for d in documents %}{% set f = d.latest %}
|
||||||
<div class="file">
|
<div class="file">
|
||||||
<a href="{{ url_for('portal_file', name=f.stored) }}" target="_blank" class="thumb">
|
<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">
|
{% 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 %}
|
{% else %}{{ f.original.rsplit('.', 1)[-1][:4] if '.' in f.original else 'bestand' }}{% endif %}
|
||||||
</a>
|
</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>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
{% from '_bulk.html' import bulk_head, bulk_cell, bulk_bar with context %}
|
||||||
{% block title %}Producten{% endblock %}
|
{% block title %}Producten{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="head">
|
<div class="head">
|
||||||
@@ -8,7 +9,7 @@
|
|||||||
<input type="hidden" name="show" value="{{ show }}">
|
<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">
|
<input class="form-control" type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, SKU, leverancier…" aria-label="Zoeken">
|
||||||
</form>
|
</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-outline-primary" href="{{ url_for('import_start', kind='products') }}">Importeren</a>
|
||||||
<a class="btn btn-primary" href="{{ url_for('product_edit') }}">Nieuw product</a>
|
<a class="btn btn-primary" href="{{ url_for('product_edit') }}">Nieuw product</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,10 +23,11 @@
|
|||||||
<div class="card"><div class="card-body">
|
<div class="card"><div class="card-body">
|
||||||
{% if rows %}
|
{% if rows %}
|
||||||
<div class="table-responsive"><table class="table table-hover table-stack">
|
<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>
|
<tbody>
|
||||||
{% for p in rows %}
|
{% for p in rows %}
|
||||||
<tr class="link" onclick="location='{{ url_for('product_edit', pid=p.id) }}'">
|
<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="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 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>
|
<td data-label="SKU"><code>{{ p.sku }}</code></td>
|
||||||
@@ -41,4 +43,7 @@
|
|||||||
</table></div>
|
</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 %}
|
{% 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>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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
@@ -1,152 +1,22 @@
|
|||||||
<!doctype html>
|
{% extends 'base.html' %}
|
||||||
<html lang="nl">
|
{% block title %}Offerte {{ qt.number }}{% if c %} · {{ c.name }}{% endif %}{% endblock %}
|
||||||
<head>
|
{% block styles %}
|
||||||
<meta charset="utf-8">
|
<style>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
@page { size: A4; margin: 16mm 15mm; }
|
||||||
<title>Offerte {{ qt.number }}{% if c %} · {{ c.name }}{% endif %}</title>
|
.qdoc .page { margin: 0 auto 2rem; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); }
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
@media print { .qdoc .page { margin: 0; } }
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
</style>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Kantumruy+Pro:wght@500;600&family=Mukta:wght@400;600&display=swap" rel="stylesheet">
|
{% endblock %}
|
||||||
<style>
|
{% block content %}
|
||||||
@page { size: A4; margin: 16mm 15mm; }
|
<div class="head no-print">
|
||||||
* { box-sizing: border-box; }
|
<div>
|
||||||
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; }
|
<a class="label" href="{{ url_for('quote_edit', qid=qt.id) }}">← Offerte {{ qt.number }}</a>
|
||||||
.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; }
|
<h1>Offerte bekijken</h1>
|
||||||
.bar a { color: #b95913; font-weight: 600; text-decoration: none; }
|
</div>
|
||||||
.btn { font: 600 15px/1 "Mukta", sans-serif; padding: 12px 22px; border-radius: 50px; border: 1px solid #b95913; background: #b95913; color: #fff; cursor: pointer; }
|
<div class="actions">
|
||||||
.btn.ghost { background: #fff; color: #b95913; border-color: rgba(185,89,19,.35); }
|
<a class="btn btn-outline-secondary" href="{{ url_for('quote_edit', qid=qt.id) }}">Bewerken</a>
|
||||||
.page { background: #fff; max-width: 210mm; margin: 24px auto; padding: 16mm 15mm; border-radius: 20px; }
|
<button class="btn btn-primary" type="button" onclick="window.print()">Afdrukken / opslaan als PDF</button>
|
||||||
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>
|
|
||||||
</div>
|
</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>
|
||||||
</body>
|
{% include '_quote_doc.html' %}
|
||||||
</html>
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
{% from '_bulk.html' import bulk_head, bulk_cell, bulk_bar with context %}
|
||||||
{% block title %}Offertes{% endblock %}
|
{% block title %}Offertes{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<div><div class="kicker">Verkoop</div><h1>Offertes</h1></div>
|
<div><div class="kicker">Verkoop</div><h1>Offertes</h1></div>
|
||||||
<div class="actions">
|
<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>
|
<form method="post" action="{{ url_for('quote_new') }}" class="inline"><button class="btn btn-primary">Nieuwe offerte</button></form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -14,10 +16,11 @@
|
|||||||
<div class="card"><div class="card-body">
|
<div class="card"><div class="card-body">
|
||||||
{% if rows %}
|
{% if rows %}
|
||||||
<div class="table-responsive"><table class="table table-hover table-stack">
|
<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>
|
<tbody>
|
||||||
{% for qt in rows %}
|
{% for qt in rows %}
|
||||||
<tr class="link" onclick="location='{{ url_for('quote_edit', qid=qt.id) }}'">
|
<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 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="Klant">{{ qt.contact_name or '—' }}</td>
|
||||||
<td data-label="Onderwerp">{{ qt.title }}</td>
|
<td data-label="Onderwerp">{{ qt.title }}</td>
|
||||||
@@ -30,4 +33,8 @@
|
|||||||
</table></div>
|
</table></div>
|
||||||
{% else %}<p class="empty">Nog geen offertes.</p>{% endif %}
|
{% else %}<p class="empty">Nog geen offertes.</p>{% endif %}
|
||||||
</div></div>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
+17
-7
@@ -1,4 +1,6 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
{% from '_documents.html' import documents_list with context %}
|
||||||
|
{% from '_kaart.html' import kaart_block %}
|
||||||
{% block title %}{{ r.name }}{% endblock %}
|
{% block title %}{{ r.name }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="head">
|
<div class="head">
|
||||||
@@ -58,17 +60,18 @@
|
|||||||
|
|
||||||
{% if designs %}
|
{% if designs %}
|
||||||
<div class="card"><div class="card-body">
|
<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">
|
<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>
|
<tbody>
|
||||||
{% for d in designs %}{% set p = pdfs.get(d.id, {}) %}
|
{% for d in designs %}{% set p = pdfs.get(d.id, {}) %}
|
||||||
<tr>
|
<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 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"><a href="{{ url_for('contact_view', cid=d.contact_id) }}">{{ d.contact_name }}</a></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="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 %}
|
<td data-label="Versie">{{ d.latest.version }}</td>
|
||||||
{% if not p %}<span class="muted">Wordt gemaakt bij openen van het contact</span>{% endif %}</span></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 %}
|
||||||
<td data-label="Ontvangen" class="muted">{{ d.created_at|dt('%d-%m-%Y') }}</td>
|
{% 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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -76,6 +79,8 @@
|
|||||||
</div></div>
|
</div></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if kaart %}{{ kaart_block(kaart) }}{% endif %}
|
||||||
|
|
||||||
{% if r.kind == 'partner' %}
|
{% if r.kind == 'partner' %}
|
||||||
<div class="card"><div class="card-body">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Producten van deze leverancier</h2>
|
<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 %}
|
{% if r.notes %}<h3>Notities</h3><p style="white-space:pre-line;margin:0">{{ r.notes }}</p>{% endif %}
|
||||||
</div></div>
|
</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 %}
|
{% if tasks %}
|
||||||
<div class="card"><div class="card-body">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Open taken</h2>
|
<h2>Open taken</h2>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends 'base.html' %}
|
{% 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 title %}{{ 'Klanten' if kind == 'klant' else 'Partners' }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="head">
|
<div class="head">
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
{% if type %}<input type="hidden" name="type" value="{{ type }}">{% endif %}
|
{% 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">
|
<input class="form-control" type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, plaats, persoon…" aria-label="Zoeken">
|
||||||
</form>
|
</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-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>
|
<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>
|
</div>
|
||||||
@@ -28,10 +30,11 @@
|
|||||||
<div class="card"><div class="card-body">
|
<div class="card"><div class="card-body">
|
||||||
{% if rows %}
|
{% if rows %}
|
||||||
<div class="table-responsive"><table class="table table-hover table-stack">
|
<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>
|
<tbody>
|
||||||
{% for r in rows %}
|
{% for r in rows %}
|
||||||
<tr class="link" onclick="location='{{ url_for('relation_view', rid=r.id) }}'">
|
<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 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="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>
|
<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>
|
</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 %}
|
{% 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>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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
@@ -6,6 +6,32 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="head"><div><div class="kicker">Beheer</div><h1>Instellingen</h1></div></div>
|
<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">
|
<form method="post">
|
||||||
<div class="card"><div class="card-body">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Bedrijfsgegevens</h2>
|
<h2>Bedrijfsgegevens</h2>
|
||||||
@@ -33,28 +59,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div></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">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Formulieren en API</h2>
|
<h2>Formulieren en API</h2>
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -19,6 +19,8 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-primary">Opslaan</button>
|
<button class="btn btn-primary">Opslaan</button>
|
||||||
<a class="btn btn-outline-secondary" href="{{ url_for('users') }}">Annuleren</a>
|
<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>
|
||||||
</div></form>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
{% from '_bulk.html' import bulk_head, bulk_cell, bulk_bar with context %}
|
||||||
{% block title %}Gebruikers{% endblock %}
|
{% block title %}Gebruikers{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="head">
|
<div class="head">
|
||||||
@@ -7,10 +8,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card"><div class="card-body">
|
<div class="card"><div class="card-body">
|
||||||
<div class="table-responsive"><table class="table table-hover table-stack">
|
<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>
|
<tbody>
|
||||||
{% for u in rows %}
|
{% for u in rows %}
|
||||||
<tr class="link" onclick="location='{{ url_for('user_edit', uid=u.id) }}'">
|
<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 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="E-mail">{{ u.email }}</td>
|
||||||
<td data-label="Rol">{{ ROLE.get(u.role, u.role) }}</td>
|
<td data-label="Rol">{{ ROLE.get(u.role, u.role) }}</td>
|
||||||
@@ -22,4 +24,6 @@
|
|||||||
</table></div>
|
</table></div>
|
||||||
</div></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>
|
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
Executable
+41
@@ -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"
|
||||||
Reference in New Issue
Block a user