Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a97f992da | ||
|
|
0368b7c1da | ||
|
|
e9f83278c9 | ||
|
|
805448d515 | ||
|
|
b66d6a6fe6 | ||
|
|
c69c3773dc | ||
|
|
db3e26d258 | ||
|
|
3cf53bc248 | ||
|
|
cad838f1f4 | ||
|
|
8f6e1ab443 | ||
|
|
366a3f9d17 | ||
|
|
81fc27fa86 | ||
|
|
13d0d43fd9 | ||
|
|
7d302dc405 |
@@ -1,3 +1,4 @@
|
|||||||
data/
|
data/
|
||||||
.venv/
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
static/configurator/
|
||||||
|
|||||||
@@ -4,13 +4,50 @@ 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.
|
||||||
|
- **Documenten met versies** bij leads/klanten en bij relaties: elk document heeft een of meer versies (nieuwe
|
||||||
|
versie uploaden met een opmerking, oude versies blijven te openen en te verwijderen). Soorten: bestand, woning
|
||||||
|
en de tekeningen (werktekening, klant-PDF) van een woning. Bestanden van vóór v0.0.7 worden bij het starten
|
||||||
|
vanzelf versie 1 van een eigen document.
|
||||||
|
- **Woning** per lead/klant: een woningontwerp uit Tectum (`house-design`-JSON, via het formulier, de API, geüpload
|
||||||
|
bij Documenten of bewaard vanuit Tectum) is een woning met versies. Een nieuwe woning wordt de gekozen woning
|
||||||
|
(de nieuwste telt), een andere kies je op de contactpagina. Daar staat de nieuwste versie in een **3D-viewer** op
|
||||||
|
de engine van de configurator (`/files/<id>/viewer`, een oudere versie via de keuzelijst): 3D, doorsnede en
|
||||||
|
plattegrond, met maten, m², inhoud, gewicht en een indicatieve prijs (uit de kosten-CSV's van de configurator).
|
||||||
|
Zonder woning is het blok er niet. In de leadlijst staat de woning als kolom (Model).
|
||||||
|
- **Tectum** (in de stack): **Bewerken in Tectum** bij de woning opent hem op `tectum.<domein>/?woning=<id>`; daar
|
||||||
|
bewaart **Opslaan in CRM** een nieuwe versie. Tectum toont de woningen van leads en klanten ook bij Nieuw. De API
|
||||||
|
daarvoor is `/api/tectum/woningen` (lijst, `?q=`), `/api/tectum/woningen/<id>` (ontwerp, `?versie=`) en `POST
|
||||||
|
/api/tectum/woningen/<id>/versies` (`{ontwerp, opmerking, basis}`; 409 als er intussen een nieuwere versie is),
|
||||||
|
alleen met de sleutel `TECTUM_CRM_KEY` (Caddy stuurt die namens Tectum mee; leeg = uit). `TECTUM_URL` = adres
|
||||||
|
van Tectum voor de knop.
|
||||||
|
- **Kaart** bij een lead/klant (onder de woning) en een klantrelatie zodra het adres bekend is (straat met
|
||||||
|
huisnummer en postcode of plaats, of anders het adres uit het woningontwerp): de kaveltoets van de kaart-app
|
||||||
|
(`KAART_URL`, standaard `/kaart/`, in de stack op hetzelfde domein). Kwam de lead uit de kaveltoets, dan opent de
|
||||||
|
kaart die kavel met de getekende woningen. Zonder adres is het blok er niet.
|
||||||
|
Met **Lagen** in de viewer kies je een bouwfase (t/m regelwerk, elektra, beplating, …) of losse lagen
|
||||||
|
(regelwerk, elektra, kozijnen, dak, …), zoals Lagen in de configurator, zodat de ploeg de opbouw kan bekijken.
|
||||||
|
- **PDF's per ontwerp** (`HousePdf` van de configurator, gemaakt in de viewer en bewaard bij het contact): een
|
||||||
|
**werktekening** (alle tekenbladen, elektra, zaaglijst en kosten; intern, niet in het klantportaal) en een
|
||||||
|
**klant-PDF** zoals de light-configurator die maakt (`LightPdf`: voorblad met beeld, kerncijfers, keuzes, indicatieve
|
||||||
|
prijs en QR-code, dan aanzichten en plattegrond en de werkwijze; wel in het portaal). Per woningversie: ontbreken
|
||||||
|
ze voor de nieuwste versie, dan maakt de viewer ze vanzelf bij het openen; opnieuw maken via PDF in de weergave.
|
||||||
|
Op de contactpagina bij de woning en op de relatiepagina (Woningen en tekeningen, voor alle contactpersonen).
|
||||||
|
- Op mobiel staan bij contact en relatie de gegevens bovenaan, de 3D-weergave verderop.
|
||||||
- **Taken/opvolging** per contact met datum en eigenaar; "Mijn taken" op het dashboard.
|
- **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.
|
||||||
Nederlandse en Engelse veldnamen worden herkend, onbekende velden bewaard. Dubbele leads (zelfde
|
Nederlandse en Engelse veldnamen worden herkend, onbekende velden bewaard. Dubbele leads (zelfde
|
||||||
@@ -19,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
|
||||||
@@ -29,7 +92,23 @@ Eenvoudige CRM in Python (Flask) met SQLite, in de Van Emous-huisstijl. Versie 0
|
|||||||
formaat als de import.
|
formaat als de import.
|
||||||
- **Offertes** met regels (product of vrije regel), btw per tarief en een afdrukversie die je als PDF
|
- **Offertes** met regels (product of vrije regel), btw per tarief en een afdrukversie die je als PDF
|
||||||
opslaat via de browser.
|
opslaat via de browser.
|
||||||
- **Gebruikers** met de rollen beheerder (ook gebruikers en instellingen) en medewerker.
|
- **Bestek uit ontwerp** bij een offerte (of "Offerte met bestek" bij het gekozen model): de engine van de
|
||||||
|
configurator rekent het ontwerp door zoals Kosten & tijden (`static/bestek.js`, `CostModel.compute` met de
|
||||||
|
kosten-CSV's) en zet alle posten met hoeveelheden in hoofdstukken (casco, isolatie, gevel, dak, vloer,
|
||||||
|
kozijnen, afwerking, inrichting, verlichting, elektra met wcd's, schakelaars en meters leiding per type,
|
||||||
|
transport en plaatsing). Elke post krijgt zijn kostprijs plus algemene kosten en marge; samen precies het
|
||||||
|
totaal van de configurator. Schroeven, zaag- en boorwerk staan per hoofdstuk samen als "Bevestiging en
|
||||||
|
bewerking". In de offerte per post (standaard: aantal × prijs per eenheid) of per hoofdstuk; het
|
||||||
|
afrondingsverschil van de prijzen per eenheid gaat naar de laatste regel van 1 post zonder product, zodat
|
||||||
|
het totaal precies klopt. Het bestek zelf komt als bijlage (zonder prijzen) in de afdrukversie en het portaal.
|
||||||
|
- **Producten uit het bestek:** posten met een vaste prijs per eenheid (wcd, schakelaar, aansluitpunten,
|
||||||
|
leidingen per type, spots en lampen, toilet, douche, keuken per m, deuren, ramen per maat, gevel- en
|
||||||
|
dakbedekking per m², isolatie, plaatmateriaal, ...) worden gekoppeld aan een product met de kostencode van de
|
||||||
|
configurator als SKU (`elektra.wcdDubbel`, `inrichting.toilet`, `kozijnen.raam.1200x500`; dezelfde codes als
|
||||||
|
in `kosten/leveranciers.csv`). Ontbreekt het product, dan wordt het aangemaakt met de prijs per eenheid van de
|
||||||
|
berekening; bestaande productprijzen worden alleen bijgewerkt als je dat aanvinkt (met producthistorie).
|
||||||
|
De regels staan in `PRODUCT_RULES` in `static/bestek.js`.
|
||||||
|
- **Gebruikers** met de rollen beheerder (ook gebruikers en instellingen) en medewerker; 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.
|
||||||
@@ -44,22 +123,83 @@ python3 -m venv .venv
|
|||||||
.venv/bin/python app.py # http://127.0.0.1:5000
|
.venv/bin/python app.py # http://127.0.0.1:5000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
De viewer heeft de engine van de configurator nodig (niet in git, ca. 160 MB met texturen):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tools/configurator-sync.sh [../configurator] # kopieert js/, assets/, kosten/ naar static/configurator/
|
||||||
|
```
|
||||||
|
|
||||||
|
Opnieuw draaien als er in de configurator iets aan de weergave verandert; `deploy.sh` doet dit zelf bij elke deploy.
|
||||||
|
|
||||||
|
Routines en e-mail vanaf de opdrachtregel:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python app.py scheduler # blijft draaien en voert de routines uit die aan de beurt zijn
|
||||||
|
.venv/bin/python app.py tick # één keer (voor cron elke minuut)
|
||||||
|
.venv/bin/python app.py sync # nu e-mail ophalen uit alle actieve mailboxen
|
||||||
|
```
|
||||||
|
|
||||||
|
Demoleads (lorem ipsum, elk met een standaardmodel als gekozen model):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node tools/export-models.js # standaardmodellen (HouseModel.MODELS) → demo/modellen/*.json
|
||||||
|
.venv/bin/python app.py demo # leads aanmaken; bestaande demoleads worden overgeslagen
|
||||||
|
```
|
||||||
|
|
||||||
Bij de eerste keer maak je het beheerdersaccount aan. Daarna vul je onder Instellingen de
|
Bij de eerste keer maak je het beheerdersaccount aan. Daarna vul je onder Instellingen de
|
||||||
bedrijfsgegevens, de e-mail en de toegestane websites voor de API in.
|
bedrijfsgegevens, de e-mail en de toegestane websites voor de API in.
|
||||||
|
|
||||||
## Online zetten
|
## Online zetten (server)
|
||||||
|
|
||||||
Draai de app achter nginx/Apache met HTTPS, bijvoorbeeld met gunicorn:
|
Op een nieuwe Ubuntu-server (26.04 LTS) eenmalig, nadat de DNS van het domein naar de server wijst:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
.venv/bin/pip install gunicorn
|
scp -r server ubuntu@SERVER:
|
||||||
.venv/bin/gunicorn -w 2 -b 127.0.0.1:8000 app:app
|
ssh ubuntu@SERVER sudo bash server/setup.sh crm.voorbeeld.nl jij@voorbeeld.nl
|
||||||
```
|
```
|
||||||
|
|
||||||
E-mail automatisch ophalen (cron, elke 10 minuten):
|
`server/setup.sh` installeert Python, nginx, certbot, rsync en sqlite3, zet de tijdzone op Europe/Amsterdam,
|
||||||
|
maakt de gebruiker `crm` met `/srv/vanemous-crm` (`releases/`, `current`, `data/`), de systemd-dienst
|
||||||
|
`vanemous-crm` (gunicorn op 127.0.0.1:5050, `CRM_PROXY=1`), de nginx-site met HTTPS, de firewall (SSH, 80, 443),
|
||||||
|
automatische beveiligingsupdates en cron (elke minuut de routines, elke nacht een databaseback-up, 7 dagen rond).
|
||||||
|
|
||||||
|
Een versie uitrollen (of terugrollen) vanaf je eigen machine:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tools/release.sh crm@crm.voorbeeld.nl # nieuwste tag
|
||||||
|
tools/release.sh crm@crm.voorbeeld.nl v0.0.1 # bepaalde versie
|
||||||
```
|
```
|
||||||
*/10 * * * * cd /pad/naar/crm && .venv/bin/python app.py sync
|
|
||||||
|
Dat stuurt `git archive <tag>` en de 3D-engine uit `../configurator` naar de server; `server/activate.sh` maakt daar
|
||||||
|
een venv, back-upt de database, zet `current` om en herstart de dienst. Start de nieuwe versie niet, dan gaat hij
|
||||||
|
terug naar de vorige. Logboek: `sudo journalctl -u vanemous-crm`.
|
||||||
|
|
||||||
|
`CRM_PROXY=1` laat de app de `X-Forwarded-Proto`/`-Host`-headers van de proxy volgen (links naar buiten, zoals
|
||||||
|
portaaluitnodigingen, worden dan `https://domein/...`) en zet sessiecookies op alleen-HTTPS. De proxy moet
|
||||||
|
uploads tot 25 MB doorlaten (nginx: `client_max_body_size 25m`).
|
||||||
|
|
||||||
|
Lokaal zoals op de server, maar zonder nginx: `./deploy.sh` (zie het script).
|
||||||
|
|
||||||
|
Back-ups buiten de server (bijv. de back-upoptie van OVH of een rsync van `/srv/vanemous-crm/data`) regel je apart:
|
||||||
|
de nachtelijke kopie staat op dezelfde schijf en dekt `uploads/` niet.
|
||||||
|
|
||||||
|
### Tectum (configurator) op dezelfde server
|
||||||
|
|
||||||
|
De configurator (`../configurator`) staat als statische site op een eigen subdomein, helemaal achter één
|
||||||
|
wachtwoord (nginx basic auth). Eenmalig, na `server/setup.sh`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scp -r server ubuntu@SERVER:
|
||||||
|
ssh -t ubuntu@SERVER sudo bash server/tectum-setup.sh tectum.voorbeeld.nl jij@voorbeeld.nl [gebruikersnaam]
|
||||||
|
```
|
||||||
|
|
||||||
|
Dat vraagt het wachtwoord (gebruikersnaam standaard `vanemous`; wijzigen: `sudo WACHTWOORD=1 bash ...`), maakt
|
||||||
|
`/srv/www/tectum` (van `crm`, zodat releases via dezelfde SSH-toegang gaan) en de nginx-site met HTTPS.
|
||||||
|
Uitrollen of terugrollen vanaf je eigen machine, vanuit een tag van de configurator:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tools/tectum-release.sh crm@tectum.voorbeeld.nl # nieuwste tag
|
||||||
|
tools/tectum-release.sh crm@tectum.voorbeeld.nl v0.7.6 # bepaalde versie
|
||||||
```
|
```
|
||||||
|
|
||||||
## Opslag
|
## Opslag
|
||||||
@@ -76,8 +216,17 @@ 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) en logo.svg
|
static/ style.css (huisstijltokens), fx.js (GSAP-effecten), search.js (zoekveld), notify.js (bel), compose.js
|
||||||
|
(e-mail opstellen), logo.svg, viewer.js (3D-viewer), bestek.js
|
||||||
|
static/configurator/ engine van de configurator (tools/configurator-sync.sh, niet in git)
|
||||||
|
tools/ configurator-sync.sh, export-models.js (standaardmodellen als JSON)
|
||||||
|
demo/modellen standaardmodellen als house-design-JSON voor de demoleads
|
||||||
```
|
```
|
||||||
|
|
||||||
|
De plattegrond in `static/viewer.js` is overgenomen uit `js/light/light.js` (`drawPlan`) van de
|
||||||
|
configurator; verandert die, dan hier meenemen.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"configurator": "0.7.7",
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"key": "amstel",
|
||||||
|
"label": "Woning Amstel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "wetering",
|
||||||
|
"label": "Woning Wetering"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "ijssel",
|
||||||
|
"label": "Woning IJssel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "waal",
|
||||||
|
"label": "Woning Waal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "maas",
|
||||||
|
"label": "Woning Maas"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "rijn",
|
||||||
|
"label": "Woning Rijn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "reest",
|
||||||
|
"label": "Woning Reest"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vecht",
|
||||||
|
"label": "Woning Vecht"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Van Emous CRM lokaal deployen vanaf een git-tag (zelfde werkwijze als straks op de server).
|
||||||
|
#
|
||||||
|
# ./deploy.sh nieuwste tag installeren en starten
|
||||||
|
# ./deploy.sh v0.0.1 een bepaalde versie (ook om terug te rollen)
|
||||||
|
# ./deploy.sh start de huidige versie starten zonder opnieuw te installeren
|
||||||
|
# ./deploy.sh list geïnstalleerde versies tonen
|
||||||
|
#
|
||||||
|
# Elke versie komt uit `git archive <tag>` (precies wat getagd is, niet de werkkopie) in
|
||||||
|
# $CRM_HOME/releases/<tag> met een eigen venv. `current` wijst naar de actieve versie.
|
||||||
|
# De data ($CRM_HOME/data) is gedeeld en blijft bestaan; vóór elke versiewissel gaat er
|
||||||
|
# een kopie van de database naar data/backups/, omdat migraties bij het starten draaien.
|
||||||
|
#
|
||||||
|
# De 3D-engine voor de viewer (gekozen model) zit niet in git: bij elke deploy komt die uit de
|
||||||
|
# configurator ($CONFIGURATOR, standaard ../configurator) in de actieve versie (tools/configurator-sync.sh).
|
||||||
|
#
|
||||||
|
# Instellingen via omgevingsvariabelen: CRM_HOME (standaard ~/.local/share/vanemous-crm),
|
||||||
|
# PORT (5050), HOST (127.0.0.1), WORKERS (2), CONFIGURATOR (../configurator).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
CRM_HOME="${CRM_HOME:-$HOME/.local/share/vanemous-crm}"
|
||||||
|
RELEASES="$CRM_HOME/releases"
|
||||||
|
DATA="$CRM_HOME/data"
|
||||||
|
PORT="${PORT:-5050}"
|
||||||
|
HOST="${HOST:-127.0.0.1}"
|
||||||
|
WORKERS="${WORKERS:-2}"
|
||||||
|
CONFIGURATOR="${CONFIGURATOR:-$REPO/../configurator}"
|
||||||
|
|
||||||
|
say() { printf '\033[38;2;185;89;19m▸\033[0m %s\n' "$*"; }
|
||||||
|
fail() { printf 'Fout: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
current_version() { [ -L "$CRM_HOME/current" ] && basename "$(readlink "$CRM_HOME/current")" || true; }
|
||||||
|
|
||||||
|
start() {
|
||||||
|
local dir="$CRM_HOME/current"
|
||||||
|
[ -d "$dir" ] || fail "nog geen versie geïnstalleerd; draai eerst ./deploy.sh"
|
||||||
|
say "Van Emous CRM $(current_version) op http://$HOST:$PORT (Ctrl+C om te stoppen)"
|
||||||
|
say "data: $DATA"
|
||||||
|
cd "$dir"
|
||||||
|
CRM_DATA="$DATA" exec "$dir/.venv/bin/gunicorn" --workers "$WORKERS" --bind "$HOST:$PORT" \
|
||||||
|
--access-logfile - --error-logfile - app:app
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
list)
|
||||||
|
active="$(current_version)"
|
||||||
|
for d in "$RELEASES"/*/; do
|
||||||
|
[ -d "$d" ] || { echo "Nog geen versies geïnstalleerd."; exit 0; }
|
||||||
|
v="$(basename "$d")"; [ "$v" = "$active" ] && echo "* $v (actief)" || echo " $v"
|
||||||
|
done
|
||||||
|
exit 0 ;;
|
||||||
|
start) start ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- welke versie ---------------------------------------------------------------
|
||||||
|
git -C "$REPO" rev-parse --git-dir >/dev/null 2>&1 || fail "$REPO is geen git-repository"
|
||||||
|
TAG="${1:-$(git -C "$REPO" tag --list 'v*' --sort=-v:refname | head -n1)}"
|
||||||
|
[ -n "$TAG" ] || fail "geen versietags gevonden (verwacht bijv. v0.0.1)"
|
||||||
|
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "onbekende versie '$TAG' (verwacht vX.Y.Z)"
|
||||||
|
git -C "$REPO" rev-parse -q --verify "refs/tags/$TAG" >/dev/null || fail "tag $TAG bestaat niet"
|
||||||
|
|
||||||
|
# versie in de code moet bij de tag horen
|
||||||
|
code_version="$(git -C "$REPO" show "$TAG:app.py" | sed -nE "s/^__version__ = '([^']+)'.*/\1/p")"
|
||||||
|
[ "v$code_version" = "$TAG" ] || fail "tag $TAG bevat __version__ = '$code_version' in app.py; tag en code komen niet overeen"
|
||||||
|
|
||||||
|
if [ -n "$(git -C "$REPO" status --porcelain)" ]; then
|
||||||
|
say "let op: je werkkopie heeft niet-gecommitte wijzigingen; die zitten niet in $TAG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- installeren ----------------------------------------------------------------
|
||||||
|
target="$RELEASES/$TAG"
|
||||||
|
mkdir -p "$RELEASES" "$DATA/backups"
|
||||||
|
if [ -f "$target/.installed" ]; then
|
||||||
|
say "$TAG is al geïnstalleerd"
|
||||||
|
else
|
||||||
|
say "$TAG uitpakken naar $target"
|
||||||
|
rm -rf "$target"; mkdir -p "$target"
|
||||||
|
git -C "$REPO" archive "$TAG" | tar -x -C "$target"
|
||||||
|
say "venv en afhankelijkheden installeren"
|
||||||
|
python3 -m venv "$target/.venv"
|
||||||
|
"$target/.venv/bin/pip" install --quiet --upgrade pip
|
||||||
|
"$target/.venv/bin/pip" install --quiet -r "$target/requirements.txt" gunicorn
|
||||||
|
"$target/.venv/bin/python" -m py_compile "$target"/*.py
|
||||||
|
git -C "$REPO" rev-parse "$TAG^{commit}" > "$target/.installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- wisselen ---------------------------------------------------------------------
|
||||||
|
previous="$(current_version)"
|
||||||
|
if [ -f "$DATA/crm.db" ] && [ "$previous" != "$TAG" ]; then
|
||||||
|
backup="$DATA/backups/crm-$(date +%Y%m%d-%H%M%S)-${previous:-onbekend}-naar-$TAG.db"
|
||||||
|
# sqlite3 .backup is veilig bij WAL; anders een gewone kopie
|
||||||
|
if command -v sqlite3 >/dev/null; then sqlite3 "$DATA/crm.db" ".backup '$backup'"; else cp "$DATA/crm.db" "$backup"; fi
|
||||||
|
say "database geback-upt: $backup"
|
||||||
|
fi
|
||||||
|
ln -sfn "$target" "$CRM_HOME/current"
|
||||||
|
say "actief: $TAG${previous:+ (was $previous)}"
|
||||||
|
|
||||||
|
# --- 3D-engine (configurator) ------------------------------------------------------
|
||||||
|
if [ -x "$target/tools/configurator-sync.sh" ]; then
|
||||||
|
"$target/tools/configurator-sync.sh" "$(cd "$CONFIGURATOR" 2>/dev/null && pwd || echo "$CONFIGURATOR")" "$target/static/configurator" \
|
||||||
|
|| say "let op: geen configurator in $CONFIGURATOR; de viewer van het gekozen model werkt dan niet"
|
||||||
|
fi
|
||||||
|
|
||||||
|
start
|
||||||
@@ -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);
|
||||||
|
|||||||
Executable
+61
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Op de server, als gebruiker crm (tools/release.sh roept dit aan): een geüploade versie installeren en actief maken.
|
||||||
|
#
|
||||||
|
# bash /srv/vanemous-crm/releases/vX.Y.Z/server/activate.sh vX.Y.Z
|
||||||
|
#
|
||||||
|
# Eigen venv per versie; vóór een versiewissel gaat er een kopie van de database naar data/backups/ (migraties
|
||||||
|
# draaien bij het starten). Start de nieuwe versie niet, dan gaat `current` terug naar de vorige.
|
||||||
|
# De laatste 5 versies blijven staan, dus terugrollen is `tools/release.sh crm@server vOUD`.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TAG="${1:?gebruik: activate.sh vX.Y.Z}"
|
||||||
|
ROOT=/srv/vanemous-crm
|
||||||
|
APP=vanemous-crm
|
||||||
|
target="$ROOT/releases/$TAG"
|
||||||
|
|
||||||
|
say() { printf '\033[38;2;185;89;19m▸\033[0m %s\n' "$*"; }
|
||||||
|
fail() { printf 'Fout: %s\n' "$*" >&2; exit 1; }
|
||||||
|
current_version() { [ -L "$ROOT/current" ] && basename "$(readlink "$ROOT/current")" || true; }
|
||||||
|
|
||||||
|
[ "$(id -un)" = crm ] || fail "draai dit als gebruiker crm"
|
||||||
|
[ -f "$target/app.py" ] || fail "$target bestaat niet of is onvolledig"
|
||||||
|
|
||||||
|
if [ ! -f "$target/.installed" ]; then
|
||||||
|
say "venv en afhankelijkheden voor $TAG"
|
||||||
|
python3 -m venv "$target/.venv"
|
||||||
|
"$target/.venv/bin/pip" install --quiet --upgrade pip
|
||||||
|
"$target/.venv/bin/pip" install --quiet -r "$target/requirements.txt" gunicorn
|
||||||
|
"$target/.venv/bin/python" -m compileall -q "$target"/*.py
|
||||||
|
touch "$target/.installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
previous="$(current_version)"
|
||||||
|
if [ -f "$ROOT/data/crm.db" ] && [ "$previous" != "$TAG" ]; then
|
||||||
|
backup="$ROOT/data/backups/crm-$(date +%Y%m%d-%H%M%S)-${previous:-leeg}-naar-$TAG.db"
|
||||||
|
sqlite3 "$ROOT/data/crm.db" ".backup '$backup'"
|
||||||
|
say "database geback-upt: $(basename "$backup")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ln -sfn "$target" "$ROOT/current"
|
||||||
|
sudo /usr/bin/systemctl restart $APP
|
||||||
|
|
||||||
|
# even wachten en controleren of de app antwoordt
|
||||||
|
ok=
|
||||||
|
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
sleep 1
|
||||||
|
if curl -fsS -o /dev/null http://127.0.0.1:5050/login; then ok=1; break; fi
|
||||||
|
done
|
||||||
|
if [ -z "$ok" ]; then
|
||||||
|
if [ -n "$previous" ] && [ "$previous" != "$TAG" ]; then
|
||||||
|
ln -sfn "$ROOT/releases/$previous" "$ROOT/current"
|
||||||
|
sudo /usr/bin/systemctl restart $APP
|
||||||
|
fail "$TAG start niet; terug naar $previous. Log: sudo journalctl -u $APP -n 50"
|
||||||
|
fi
|
||||||
|
fail "$TAG start niet. Log: sudo journalctl -u $APP -n 50"
|
||||||
|
fi
|
||||||
|
say "actief: $TAG${previous:+ (was $previous)}"
|
||||||
|
|
||||||
|
# oude versies opruimen (de 5 nieuwste blijven)
|
||||||
|
ls -1d "$ROOT"/releases/v*/ 2>/dev/null | xargs -n1 basename | sort -V | head -n -5 | while read -r old; do
|
||||||
|
[ "$old" = "$TAG" ] || rm -rf "${ROOT:?}/releases/$old"
|
||||||
|
done
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Van Emous CRM achter nginx (server/setup.sh vult __DOMAIN__ in; certbot voegt HTTPS en de doorverwijzing toe).
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name __DOMAIN__;
|
||||||
|
|
||||||
|
# uploads tot 25 MB (MAX_CONTENT_LENGTH in app.py)
|
||||||
|
client_max_body_size 25m;
|
||||||
|
|
||||||
|
# css, js en de 3D-engine direct door nginx; steeds even navragen zodat een nieuwe release meteen zichtbaar is
|
||||||
|
location /static/ {
|
||||||
|
alias /srv/vanemous-crm/current/static/;
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:5050;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+113
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Eenmalig op een nieuwe Ubuntu-server (getest voor 26.04 LTS, OVHcloud-image): alles klaarzetten voor de CRM.
|
||||||
|
#
|
||||||
|
# scp -r server ubuntu@SERVER:
|
||||||
|
# ssh ubuntu@SERVER sudo bash server/setup.sh crm.voorbeeld.nl jij@voorbeeld.nl
|
||||||
|
#
|
||||||
|
# Daarna rolt tools/release.sh (op je eigen machine) een versie uit naar crm@SERVER.
|
||||||
|
# Opnieuw draaien kan: elke stap controleert wat er al is.
|
||||||
|
#
|
||||||
|
# Wat het doet: pakketten (Python, nginx, certbot, rsync, sqlite3), tijdzone Europe/Amsterdam, gebruiker `crm`
|
||||||
|
# met /srv/vanemous-crm, SSH-toegang voor releases (zelfde sleutels als de sudo-gebruiker), systemd-dienst,
|
||||||
|
# nginx-site met HTTPS (Let's Encrypt), firewall (alleen SSH, 80, 443), automatische beveiligingsupdates,
|
||||||
|
# cron voor de routines (elke minuut `app.py tick`: e-mail ophalen, nieuwsbrieven, …) en een dagelijkse
|
||||||
|
# databaseback-up (7 dagen rond).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DOMAIN="${1:-}"
|
||||||
|
EMAIL="${2:-}"
|
||||||
|
APP=vanemous-crm
|
||||||
|
ROOT=/srv/$APP
|
||||||
|
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 setup.sh <domein> <e-mail voor Let's Encrypt>"
|
||||||
|
|
||||||
|
# --- pakketten ------------------------------------------------------------------------
|
||||||
|
say "pakketten installeren"
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -q
|
||||||
|
apt-get upgrade -yq
|
||||||
|
apt-get install -yq python3 python3-venv python3-pip nginx certbot python3-certbot-nginx \
|
||||||
|
rsync sqlite3 ufw unattended-upgrades cron curl
|
||||||
|
python3 -c 'import sys; assert sys.version_info >= (3, 11), sys.version' || fail "Python 3.11 of nieuwer nodig"
|
||||||
|
|
||||||
|
say "tijdzone Europe/Amsterdam"
|
||||||
|
timedatectl set-timezone Europe/Amsterdam
|
||||||
|
|
||||||
|
say "automatische beveiligingsupdates aan"
|
||||||
|
dpkg-reconfigure -f noninteractive unattended-upgrades
|
||||||
|
|
||||||
|
# --- gebruiker en mappen -----------------------------------------------------------------
|
||||||
|
if ! id crm >/dev/null 2>&1; then
|
||||||
|
say "gebruiker crm aanmaken"
|
||||||
|
useradd --system --create-home --home-dir "$ROOT" --shell /bin/bash crm
|
||||||
|
fi
|
||||||
|
install -d -o crm -g crm -m 755 "$ROOT" "$ROOT/releases"
|
||||||
|
install -d -o crm -g crm -m 700 "$ROOT/data" "$ROOT/data/backups"
|
||||||
|
|
||||||
|
# SSH-sleutels van de gebruiker die sudo deed (bij OVH: ubuntu), zodat releases als crm kunnen inloggen.
|
||||||
|
src_user="${SUDO_USER:-ubuntu}"
|
||||||
|
src_keys="$(getent passwd "$src_user" | cut -d: -f6)/.ssh/authorized_keys"
|
||||||
|
install -d -o crm -g crm -m 700 "$ROOT/.ssh"
|
||||||
|
if [ -f "$src_keys" ]; then
|
||||||
|
touch "$ROOT/.ssh/authorized_keys" # bij de eerste keer bestaat hij nog niet (en cat zou dan set -e laten stoppen)
|
||||||
|
sort -u "$src_keys" "$ROOT/.ssh/authorized_keys" > "$ROOT/.ssh/authorized_keys.new"
|
||||||
|
mv "$ROOT/.ssh/authorized_keys.new" "$ROOT/.ssh/authorized_keys"
|
||||||
|
chown crm:crm "$ROOT/.ssh/authorized_keys"; chmod 600 "$ROOT/.ssh/authorized_keys"
|
||||||
|
say "SSH-sleutels van $src_user toegevoegd aan crm"
|
||||||
|
else
|
||||||
|
say "let op: geen $src_keys; zet zelf een sleutel in $ROOT/.ssh/authorized_keys"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# crm mag alleen de eigen dienst herstarten
|
||||||
|
cat > /etc/sudoers.d/$APP <<EOF
|
||||||
|
crm ALL=(root) NOPASSWD: /usr/bin/systemctl restart $APP, /usr/bin/systemctl is-active $APP, /usr/bin/journalctl -u $APP *
|
||||||
|
EOF
|
||||||
|
chmod 440 /etc/sudoers.d/$APP
|
||||||
|
visudo -cf /etc/sudoers.d/$APP >/dev/null
|
||||||
|
|
||||||
|
# --- systemd -----------------------------------------------------------------------------
|
||||||
|
say "systemd-dienst $APP"
|
||||||
|
install -m 644 "$HERE/$APP.service" /etc/systemd/system/$APP.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable $APP >/dev/null 2>&1
|
||||||
|
# starten gebeurt bij de eerste release (er is nog geen code)
|
||||||
|
|
||||||
|
# --- nginx -------------------------------------------------------------------------------
|
||||||
|
say "nginx voor $DOMAIN"
|
||||||
|
if [ ! -f /etc/nginx/sites-available/$APP ] || ! grep -q "listen 443" /etc/nginx/sites-available/$APP; then
|
||||||
|
sed "s/__DOMAIN__/$DOMAIN/g" "$HERE/nginx-site.conf" > /etc/nginx/sites-available/$APP
|
||||||
|
fi
|
||||||
|
ln -sfn /etc/nginx/sites-available/$APP /etc/nginx/sites-enabled/$APP
|
||||||
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
|
nginx -t
|
||||||
|
systemctl reload nginx
|
||||||
|
|
||||||
|
# --- firewall ------------------------------------------------------------------------------
|
||||||
|
say "firewall: alleen SSH, HTTP en HTTPS"
|
||||||
|
ufw allow OpenSSH >/dev/null
|
||||||
|
ufw allow 'Nginx Full' >/dev/null
|
||||||
|
ufw --force enable >/dev/null
|
||||||
|
|
||||||
|
# --- 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
|
||||||
|
|
||||||
|
# --- cron: routines en back-up -----------------------------------------------------------------
|
||||||
|
say "cron: routines elke minuut, database-back-up elke nacht"
|
||||||
|
cat > /etc/cron.d/$APP <<EOF
|
||||||
|
# Van Emous CRM (server/setup.sh)
|
||||||
|
* * * * * crm [ -d $ROOT/current ] && cd $ROOT/current && CRM_DATA=$ROOT/data .venv/bin/python app.py tick 2>&1 | logger -t $APP-routines
|
||||||
|
15 3 * * * crm [ -f $ROOT/data/crm.db ] && sqlite3 $ROOT/data/crm.db ".backup '$ROOT/data/backups/nacht-\$(date +\%a).db'"
|
||||||
|
EOF
|
||||||
|
chmod 644 /etc/cron.d/$APP
|
||||||
|
|
||||||
|
say "klaar. Rol nu vanaf je eigen machine een versie uit: tools/release.sh crm@$DOMAIN"
|
||||||
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,26 @@
|
|||||||
|
# Van Emous CRM: gunicorn achter nginx (server/setup.sh installeert dit naar /etc/systemd/system/).
|
||||||
|
[Unit]
|
||||||
|
Description=Van Emous CRM
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=crm
|
||||||
|
Group=crm
|
||||||
|
WorkingDirectory=/srv/vanemous-crm/current
|
||||||
|
Environment=CRM_DATA=/srv/vanemous-crm/data
|
||||||
|
Environment=CRM_PROXY=1
|
||||||
|
ExecStart=/srv/vanemous-crm/current/.venv/bin/gunicorn --workers 2 --bind 127.0.0.1:5050 \
|
||||||
|
--timeout 120 --access-logfile - --error-logfile - app:app
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
# afscherming: alleen de datamap is schrijfbaar
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/srv/vanemous-crm/data
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
/* Bestek uit een woningontwerp (house-design-JSON), voor een offerte.
|
||||||
|
*
|
||||||
|
* Rekent met de engine van de configurator (static/configurator/): HouseModel.computeParts zoals de PDF,
|
||||||
|
* HouseModel.computeTotals en CostModel.compute met de kosten-CSV's. Elke kostenpost (materiaal + arbeid +
|
||||||
|
* machine-uren, zoals CostModel.costOf) krijgt de algemene kosten en marge erbij en gaat naar een hoofdstuk;
|
||||||
|
* transport en kraan worden een eigen hoofdstuk. Samen precies het totaal excl. btw van de configurator.
|
||||||
|
* Productieposten (schroeven, zaagsneden, boorgaten, plaatsen) komen per hoofdstuk samen in één regel
|
||||||
|
* "Bevestiging en bewerking"; zo heeft elke andere post een zuivere prijs per eenheid.
|
||||||
|
* Posten met een vaste prijs per eenheid (wcd, toilet, rabat per m², ...) krijgen een product: SKU = de code uit
|
||||||
|
* de kosten van de configurator (cost-defaults.js / leveranciers.csv), bijv. elektra.wcdDubbel.
|
||||||
|
*/
|
||||||
|
(() => {
|
||||||
|
const root = document.getElementById('bestek');
|
||||||
|
const $ = (sel) => root.querySelector(sel);
|
||||||
|
const esc = (s) => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
||||||
|
const nl = (n, d = 0) => n.toLocaleString('nl-NL', { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||||
|
const eur = (n) => '€ ' + nl(n, 2);
|
||||||
|
|
||||||
|
// Hoofdstukken van het bestek; een post gaat naar het hoofdstuk van zijn activiteit (ACT) of anders zijn categorie.
|
||||||
|
const CHAPTERS = [
|
||||||
|
['casco', 'Casco en constructie'], ['isolatie', 'Isolatie'], ['gevel', 'Gevelbekleding'],
|
||||||
|
['dak', 'Dak, dakrand en overkapping'], ['vloer', 'Vloer en fundering'], ['kozijnen', 'Kozijnen, ramen en deuren'],
|
||||||
|
['afwerking', 'Binnenafwerking'], ['inrichting', 'Inrichting'], ['verlichting', 'Verlichting'], ['elektra', 'Elektra'],
|
||||||
|
['transport', 'Transport en plaatsing']
|
||||||
|
];
|
||||||
|
const BY_CAT = { Hout: 'casco', Staal: 'casco', Schroeven: 'casco', Plaatwerk: 'casco', Isolatie: 'isolatie', Gevel: 'gevel',
|
||||||
|
Dak: 'dak', Vloer: 'vloer', Kozijnen: 'kozijnen', Afwerking: 'afwerking', Inrichting: 'inrichting', Verlichting: 'verlichting', Elektra: 'elektra' };
|
||||||
|
const BY_ACT = { Latwerk: 'gevel', Dak: 'dak' };
|
||||||
|
const chapterOf = (l) => BY_ACT[l.act] || BY_CAT[l.cat] || 'casco';
|
||||||
|
// Productieposten: wel in de prijs, niet als regel in het bestek.
|
||||||
|
const hidden = (l) => l.cat === 'Schroeven' || l.unit === 'sneden' || /^(Boorgaten|Plaatsen en vastzetten|Plaatschroeven)/.test(l.label);
|
||||||
|
// Omschrijving zonder de details per element (aantallen tussen haakjes), zodat gelijke posten samengaan;
|
||||||
|
// kozijnen houden hun maat en uitvoering.
|
||||||
|
function clean(l) {
|
||||||
|
let s = l.label;
|
||||||
|
if (l.cat !== 'Kozijnen') s = s.replace(/\s*\([^)]*\)/g, '');
|
||||||
|
return s.replace(/^Regelwerk \d+ st\.$/, 'Regelwerk wanden').trim();
|
||||||
|
}
|
||||||
|
const roundQty = (q, unit) => Math.round(q * (unit === 'm³' ? 100 : 10)) / (unit === 'm³' ? 100 : 10);
|
||||||
|
const HIDDEN_LABEL = { casco: 'Bevestiging en bewerking (schroeven, zaagwerk, plaatsen)', elektra: 'Bevestiging en bewerking (boorwerk door het regelwerk)' };
|
||||||
|
const slug = (t) => t.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/²/g, '2').replace(/×/g, 'x').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||||
|
|
||||||
|
// Product bij een post: [test, code | (label, regel) => code, naam]. Alleen posten waarvan de prijs per eenheid vast
|
||||||
|
// is (niet: regelwerk, vochtdoek, latwerk, groepenkast, verdeeldozen; die hangen af van het ontwerp).
|
||||||
|
const PRODUCT_RULES = [
|
||||||
|
[/^Wandcontactdozen dubbel/, 'elektra.wcdDubbel', 'Wandcontactdoos dubbel, inbouw'],
|
||||||
|
[/^Schakelaars/, 'elektra.schakelaar', 'Schakelaar, inbouw'],
|
||||||
|
[/^Aansluitpunt koken/, 'elektra.perilex', 'Aansluitpunt kookgroep (perilex)'],
|
||||||
|
[/^Aansluitpunt warmtepomp/, 'elektra.werkschakelaar', 'Aansluitpunt warmtepomp (werkschakelaar)'],
|
||||||
|
[/^Aansluitpunt /, 'elektra.wcdEnkel', 'Aansluitpunt apparaat (wcd enkel, eigen groep)'],
|
||||||
|
[/^Vloerdoos/, 'elektra.vloerdoosSt', 'Vloerdoos met dubbele wcd'],
|
||||||
|
[/^Spotdozen/, 'elektra.spotdoosSt', 'Spotdoos plafond, luchtdicht'],
|
||||||
|
[/^Kabeldoorvoer/, 'elektra.doorvoer', 'Kabeldoorvoer naar buiten, luchtdicht'],
|
||||||
|
[/^Steekverbinder module-naad 3/, 'elektra.steekverbinder3', 'Steekverbinder module-naad 3-polig (set)'],
|
||||||
|
[/^Steekverbinder module-naad 5/, 'elektra.steekverbinder5', 'Steekverbinder module-naad 5-polig (set)'],
|
||||||
|
[(l) => l.cat === 'Elektra' && l.unit === 'm', (l) => `elektra.leiding.${slug(l.label.split(':')[0])}`, (l) => `Leiding ${l.label.split(':')[0]}, gelegd`],
|
||||||
|
[/^Gevellampen/, 'verlichting.gevellamp', 'Gevellamp'],
|
||||||
|
[/^Boeideel-spots/, 'verlichting.boeideelSpot', 'Boeideel-spot'],
|
||||||
|
[/^Binnenspots/, 'verlichting.binnenspot', 'Binnenspot'],
|
||||||
|
[/^Voordeur /, 'kozijnen.voordeur', 'Voordeur incl. kozijn'],
|
||||||
|
[/^Deur /, 'kozijnen.deur', 'Deur incl. kozijn'],
|
||||||
|
[/^Schuifdeur /, 'kozijnen.schuifdeurBlad', 'Schuifdeur per blad'],
|
||||||
|
[/^Raam \d+×\d+/, (l) => `kozijnen.raam.${l.label.match(/\d+×\d+/)[0].replace('×', 'x')}`, (l) => `Raamkozijn ${l.label.match(/\d+×\d+/)[0]} mm incl. glas`],
|
||||||
|
[/^Rabat$/, 'gevel.rabatM2', 'Rabat gevelbekleding'],
|
||||||
|
[/^Steenstrips$/, 'gevel.steenM2', 'Steenstrips'],
|
||||||
|
[/^Stucwerk$/, 'gevel.stucM2', 'Stucwerk gevel'],
|
||||||
|
[(l) => l.cat === 'Gevel' && /^Felsplaten/.test(l.label), 'gevel.felsM2', 'Felsplaten gevel (staande naad)'],
|
||||||
|
[/^Afdekker trasraam/, 'gevel.afdekkerM', 'Afdekker trasraam'],
|
||||||
|
[/^Isolatieblok steen/, 'isolatie.steenIsolatieM3', 'Isolatieblok steengevel'],
|
||||||
|
[/^Glaswol/, 'isolatie.glaswolM3', 'Glaswol isolatie'],
|
||||||
|
[/^Dakleer/, 'dak.dakleerM2', 'Dakleer (EPDM)'],
|
||||||
|
[/^Riet$/, 'dak.rietM2', 'Rieten dakbedekking'],
|
||||||
|
[(l) => l.cat === 'Dak' && /^Felsplaten/.test(l.label), 'dak.felsM2', 'Felsplaten dak (staande naad)'],
|
||||||
|
[/^Boeideel \d+/, 'dak.boeideelM', 'Boeideel'],
|
||||||
|
[/^Onderplaat boeideel/, 'dak.onderplaatM2', 'Onderplaat boeideel'],
|
||||||
|
[/^Daktrim/, 'dak.daktrimM', 'Daktrim'],
|
||||||
|
[/^Ventilatierooster/, 'dak.ventRoosterM', 'Ventilatierooster onder boeideel'],
|
||||||
|
[/^Paalvoeten/, 'dak.paalvoet', 'Paalvoet verzinkt, verstelbaar'],
|
||||||
|
[/^Dakbeschot/, 'dak.beschotM2', 'Dakbeschot OSB 18 mm'],
|
||||||
|
[/^Spaanplaat$/, 'plaat.spaanplaatM2', 'Spaanplaat'],
|
||||||
|
[/^Cementplaat$/, 'plaat.cementplaatM2', 'Cementgebonden plaat'],
|
||||||
|
[/^OSB /, 'plaat.osbM2', 'OSB-plaat'],
|
||||||
|
[/^Vloerafwerking/, 'vloer.afwerkingM2', 'Vloerafwerking'],
|
||||||
|
[/vals plafond/, 'afwerking.valsPlafondM2', 'Vals plafond (gipsplaat, afgewerkt)'],
|
||||||
|
[/FIBO-wandpanelen/, 'afwerking.fiboM2', 'FIBO-wandpaneel']
|
||||||
|
];
|
||||||
|
// Inrichting: de code volgt het stuk zelf (FixtureModel: f.cost), de omschrijving is die van CostModel.
|
||||||
|
function fixtureCodes(parts) {
|
||||||
|
const map = {};
|
||||||
|
(parts.fixtures || []).filter(f => f.cost).forEach(f => {
|
||||||
|
const perM = { keuken: ['Keukenblok', 'keukenM'], kookeiland: ['Kookeiland', 'kookeilandM'], kast: [f.label, 'kastM'] }[f.cost];
|
||||||
|
map[perM ? `${perM[0]} ${f.w} mm` : f.label] = perM ? perM[1] : f.cost;
|
||||||
|
if (f.opts && f.opts.kookplaat) map.Inductiekookplaat = 'kookplaat';
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
function productOf(row, fixtures) {
|
||||||
|
let code = null, name = null;
|
||||||
|
if (row.cat === 'Inrichting' && fixtures[row.label]) {
|
||||||
|
code = `inrichting.${fixtures[row.label]}`;
|
||||||
|
name = row.label.replace(/ \d+ mm$/, '');
|
||||||
|
} else {
|
||||||
|
const rule = PRODUCT_RULES.find(([t]) => typeof t === 'function' ? t(row) : t.test(row.label));
|
||||||
|
if (!rule) return null;
|
||||||
|
code = typeof rule[1] === 'function' ? rule[1](row) : rule[1];
|
||||||
|
name = typeof rule[2] === 'function' ? rule[2](row) : rule[2];
|
||||||
|
}
|
||||||
|
const [g, k] = code.split('.'), def = (window.COST_DEFAULTS[g] && window.COST_DEFAULTS[g].items[k]) || null;
|
||||||
|
const sup = CostCsv.entry('leveranciers', code) || {};
|
||||||
|
return { sku: code, name, description: [def && def.label, 'Prijs uit de configurator: materiaal en montage, incl. opslagen en marge.'].filter(Boolean).join('\n'),
|
||||||
|
supplier: sup.supplier || '', article: sup.article || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(st, parts, meta) {
|
||||||
|
const totals = HouseModel.computeTotals(parts);
|
||||||
|
const cost = CostModel.compute(parts, totals);
|
||||||
|
const S = cost.settings, O = S.opslag, A = S.arbeid, B = S.bedrijf;
|
||||||
|
const perHour = B.machines + B.slijtage + B.huisvesting;
|
||||||
|
const factor = (1 + O.algemeen / 100) * (1 + O.marge / 100);
|
||||||
|
// Zelfde opbouw als costOf in CostModel.compute.
|
||||||
|
const lineCost = (l) => {
|
||||||
|
const direct = l.sec / 3600, indirect = direct * A.toeslagUren / 100, qa = (direct + indirect) * A.qa / 100;
|
||||||
|
return l.mat * (1 + (O.verlies + O.klein) / 100) + (direct + indirect + qa) * A.uurloon + l.clock / 3600 * (1 + A.toeslagUren / 100) * perHour;
|
||||||
|
};
|
||||||
|
const chapters = new Map(CHAPTERS.map(([key, name]) => [key, { key, name, price: 0, rows: new Map(), info: [] }]));
|
||||||
|
cost.elements.forEach(el => el.lines.forEach(l => {
|
||||||
|
const ch = chapters.get(chapterOf(l)), price = lineCost(l) * factor;
|
||||||
|
ch.price += price;
|
||||||
|
const label = hidden(l) ? (HIDDEN_LABEL[ch.key] || 'Bevestiging en bewerking') : clean(l);
|
||||||
|
const unit = hidden(l) ? 'post' : l.unit, key = `${hidden(l) ? '~' : ''}${label}|${unit}`;
|
||||||
|
const row = ch.rows.get(key) || { label, unit, qty: 0, n: 0, cost: 0, cat: l.cat, hidden: hidden(l) };
|
||||||
|
row.qty = row.hidden ? 1 : row.qty + l.qty; row.n += 1; row.cost += price;
|
||||||
|
ch.rows.set(key, row);
|
||||||
|
}));
|
||||||
|
const fixtures = fixtureCodes(parts);
|
||||||
|
const n = parts.units.length, tr = chapters.get('transport');
|
||||||
|
tr.rows.set('t', { label: `Transport modules naar de bouwplaats`, unit: 'unit', qty: n, n: 1, cost: n * B.transportUnit * factor });
|
||||||
|
tr.rows.set('k', { label: 'Plaatsen met kraan', unit: 'post', qty: 1, n: 1, cost: B.kraan * factor });
|
||||||
|
tr.price = (n * B.transportUnit + B.kraan) * factor;
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
chapters.forEach(ch => {
|
||||||
|
if (!(ch.price > 0.005)) return;
|
||||||
|
// Bevestiging en bewerking als laatste regel van het hoofdstuk (daar komt ook het afrondingsverschil).
|
||||||
|
const rows = Array.from(ch.rows.values()).filter(r => r.qty > 0 && (r.cost > 0.005 || !r.hidden)).sort((a, b) => (a.hidden ? 1 : 0) - (b.hidden ? 1 : 0));
|
||||||
|
const lines = rows.map(r => {
|
||||||
|
// Ramen tellen per stuk (de prijs rekent per m²); een raam van een andere maat is een andere post.
|
||||||
|
const perPiece = r.cat === 'Kozijnen' && r.unit === 'm²';
|
||||||
|
const q0 = roundQty(r.qty, r.unit), qty = perPiece ? r.n : q0 > 0 ? q0 : Math.round(r.qty * 1000) / 1000 || 1;
|
||||||
|
const line = { label: r.label, qty, unit: perPiece ? 'st.' : r.unit, unitPrice: Math.round(r.cost / qty * 10000) / 10000, price: Math.round(r.cost * 100) / 100 };
|
||||||
|
const product = r.hidden ? null : productOf(r, fixtures);
|
||||||
|
if (product) line.product = product;
|
||||||
|
return line;
|
||||||
|
});
|
||||||
|
if (ch.key === 'elektra') {
|
||||||
|
const m = rows.filter(r => r.unit === 'm').reduce((t, r) => t + r.qty, 0);
|
||||||
|
if (m > 0) ch.info.push({ label: 'Totaal leidingwerk (buis en kabel)', qty: roundQty(m, 'm'), unit: 'm' });
|
||||||
|
}
|
||||||
|
out.push({ key: ch.key, name: ch.name, price: Math.round(ch.price * 100) / 100, lines, info: ch.info });
|
||||||
|
});
|
||||||
|
const sum = out.reduce((t, c) => t + c.price, 0);
|
||||||
|
if (Math.abs(sum - cost.summary.exBtw) > 1) console.warn('Bestek wijkt af van de configurator', sum, cost.summary.exBtw);
|
||||||
|
return Object.assign({ version: 1, computedAt: new Date().toISOString(), vat: O.btw, chapters: out,
|
||||||
|
totals: { exBtw: Math.round(cost.summary.exBtw * 100) / 100, btw: Math.round(cost.summary.btw * 100) / 100, inclBtw: Math.round(cost.summary.inclBtw * 100) / 100 },
|
||||||
|
units: n }, meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(b) {
|
||||||
|
$('.bs-body').innerHTML = b.chapters.map(ch => `
|
||||||
|
<section class="bs-ch">
|
||||||
|
<div class="bs-ch-head"><h2>${esc(ch.name)}</h2><b>${eur(ch.price)}</b></div>
|
||||||
|
<table><tbody>${ch.lines.map(l => `<tr><td>${esc(l.label)}${l.product ? ` <span class="chip" title="Product ${esc(l.product.name)}">${esc(l.product.sku)}</span>` : ''}</td>
|
||||||
|
<td class="num">${nl(l.qty, l.qty % 1 ? (l.unit === 'm³' ? 2 : 1) : 0)} ${esc(l.unit)}</td><td class="num muted">× ${eur(l.unitPrice)}</td><td class="num">${eur(l.price)}</td></tr>`).join('')}
|
||||||
|
${ch.info.map(l => `<tr class="bs-info"><td>${esc(l.label)}</td><td class="num">${nl(l.qty, 1)} ${esc(l.unit)}</td><td></td><td></td></tr>`).join('')}</tbody></table>
|
||||||
|
</section>`).join('');
|
||||||
|
$('.bs-total').innerHTML = `<div><span>Totaal excl. btw</span><b>${eur(b.totals.exBtw)}</b></div>
|
||||||
|
<div><span>Btw ${nl(b.vat)}%</span><b>${eur(b.totals.btw)}</b></div>
|
||||||
|
<div class="grand"><span>Totaal incl. btw</span><b>${eur(b.totals.inclBtw)}</b></div>`;
|
||||||
|
root.querySelector('[name=bestek]').value = JSON.stringify(b);
|
||||||
|
root.querySelector('button[type=submit]').disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let st, parts;
|
||||||
|
try {
|
||||||
|
const design = JSON.parse(document.getElementById('design').textContent);
|
||||||
|
st = HouseModel.normalize(HouseModel.clone(design));
|
||||||
|
parts = HouseModel.computeParts(st, {
|
||||||
|
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials(),
|
||||||
|
elektraRough: false
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
$('.bs-body').innerHTML = `<p class="empty">Dit ontwerp kan niet worden doorgerekend: ${esc(err.message)}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const meta = JSON.parse(document.getElementById('bestek-meta').textContent);
|
||||||
|
const run = () => {
|
||||||
|
try { render(build(st, parts, meta)); } catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
$('.bs-body').innerHTML = `<p class="empty">Het bestek kon niet worden berekend: ${esc(err.message)}</p>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Prijzen komen uit kosten/*.csv (async geladen bij het starten van de engine); daarna rekenen.
|
||||||
|
let done = false;
|
||||||
|
const once = () => { if (!done) { done = true; run(); } };
|
||||||
|
if (CostCsv.status('intern').loadedAt) once();
|
||||||
|
else { CostCsv.onChange(once); setTimeout(once, 6000); }
|
||||||
|
CostModel.onChange(() => { if (done) run(); });
|
||||||
|
})();
|
||||||
@@ -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(); }
|
||||||
|
});
|
||||||
|
})();
|
||||||
+332
-110
@@ -1,4 +1,6 @@
|
|||||||
/* Van Emous huisstijl — tokens uit het design system */
|
/* Van Emous huisstijl bovenop Bootstrap 5.3 — tokens uit het design system.
|
||||||
|
Layout, grid, formulieren, tabellen en het mobiele menu komen uit Bootstrap;
|
||||||
|
hier staan alleen de merkkleuren en de onderdelen die Bootstrap niet heeft. */
|
||||||
:root {
|
:root {
|
||||||
--orange: #b95913;
|
--orange: #b95913;
|
||||||
--orange-dark: #a24e11;
|
--orange-dark: #a24e11;
|
||||||
@@ -22,77 +24,180 @@
|
|||||||
--radius-md: 1rem;
|
--radius-md: 1rem;
|
||||||
--radius-lg: 1.25rem;
|
--radius-lg: 1.25rem;
|
||||||
--radius-pill: 50px;
|
--radius-pill: 50px;
|
||||||
|
|
||||||
|
/* Bootstrap op de huisstijl zetten */
|
||||||
|
--bs-body-font-family: var(--font-body);
|
||||||
|
--bs-body-font-size: 1rem;
|
||||||
|
--bs-body-line-height: 1.6;
|
||||||
|
--bs-body-color: var(--ink);
|
||||||
|
--bs-body-color-rgb: 33, 33, 33;
|
||||||
|
--bs-body-bg: var(--ultra-light);
|
||||||
|
--bs-heading-color: var(--ink);
|
||||||
|
--bs-primary: #b95913;
|
||||||
|
--bs-primary-rgb: 185, 89, 19;
|
||||||
|
--bs-secondary-color: var(--ink-muted);
|
||||||
|
--bs-border-color: var(--line);
|
||||||
|
--bs-border-radius: var(--radius-sm);
|
||||||
|
--bs-border-radius-lg: var(--radius-lg);
|
||||||
|
--bs-border-radius-pill: var(--radius-pill);
|
||||||
|
--bs-link-color: #b95913;
|
||||||
|
--bs-link-color-rgb: 185, 89, 19;
|
||||||
|
--bs-link-hover-color: #a24e11;
|
||||||
|
--bs-link-hover-color-rgb: 162, 78, 17;
|
||||||
|
--bs-focus-ring-color: var(--orange-focus);
|
||||||
|
--bs-code-color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
html { font-variant-numeric: lining-nums tabular-nums; }
|
html { font-variant-numeric: lining-nums tabular-nums; }
|
||||||
body { margin: 0; font-family: var(--font-body); font-size: 16px; line-height: 1.6; color: var(--ink); background: var(--ultra-light); display: flex; min-height: 100vh; }
|
body { min-height: 100vh; }
|
||||||
h1, h2, h3 { font-family: var(--font-heading); font-weight: 500; line-height: 1.2; margin: 0 0 0.75rem; }
|
h1, h2, h3 { font-family: var(--font-heading); font-weight: 500; line-height: 1.2; margin: 0 0 0.75rem; }
|
||||||
h1 { font-size: 2rem; line-height: 1.1; letter-spacing: -0.01em; }
|
h1 { font-size: 2rem; line-height: 1.1; letter-spacing: -0.01em; }
|
||||||
h2 { font-size: 1.3rem; }
|
h2 { font-size: 1.3rem; }
|
||||||
h3 { font-size: 1.05rem; margin-top: 1.25rem; }
|
h3 { font-size: 1.05rem; margin-top: 1.25rem; }
|
||||||
a { color: var(--orange); font-weight: 600; text-decoration: none; }
|
a { font-weight: 600; text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
a:focus-visible, button:focus-visible, .btn:focus-visible { outline: 3px solid var(--orange); outline-offset: 2px; }
|
a:focus-visible, button:focus-visible, .btn:focus-visible { outline: 3px solid var(--orange); outline-offset: 2px; box-shadow: none; }
|
||||||
|
|
||||||
/* zijbalk */
|
/* ---------- app-layout: zijbalk (desktop) / uitschuifmenu (mobiel) ---------- */
|
||||||
.side { width: 240px; flex: none; background: var(--white); border-right: 1px solid var(--line); padding: 1.5rem 1rem; display: flex; flex-direction: column; position: sticky; top: 0; height: 100vh; }
|
.side-panel { --bs-offcanvas-width: 270px; --bs-offcanvas-bg: var(--white); }
|
||||||
|
.side .offcanvas-header img { width: 130px; }
|
||||||
|
.side .offcanvas-body { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||||
.brand img { width: 170px; display: block; margin: 0 0.75rem 2rem; }
|
.brand img { width: 170px; display: block; margin: 0 0.75rem 2rem; }
|
||||||
.side nav { display: flex; flex-direction: column; gap: 0.25rem; flex: 1; }
|
.side nav { display: flex; flex-direction: column; gap: 0.25rem; flex: 1; }
|
||||||
.side nav a, .logout { color: var(--ink); padding: 0.6rem 1rem; border-radius: var(--radius-pill); display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; white-space: nowrap; }
|
.side nav a, .side .logout { color: var(--ink); padding: 0.6rem 1rem; border-radius: var(--radius-pill); display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; white-space: nowrap; }
|
||||||
.side nav a:hover { background: var(--orange-tint-soft); text-decoration: none; }
|
.side nav a:hover { background: var(--orange-tint-soft); text-decoration: none; }
|
||||||
.side nav a.active { background: var(--orange); color: var(--white); }
|
.side nav a.active { background: var(--orange); color: var(--white); }
|
||||||
|
.side .logout { color: var(--ink-muted); }
|
||||||
.count { background: var(--orange-tint); color: var(--orange-dark); font-size: 0.75rem; padding: 0 0.5rem; border-radius: 999px; }
|
.count { background: var(--orange-tint); color: var(--orange-dark); font-size: 0.75rem; padding: 0 0.5rem; border-radius: 999px; }
|
||||||
.active .count { background: var(--white); }
|
.active .count { background: var(--white); }
|
||||||
.logout { color: var(--ink-muted); }
|
|
||||||
|
|
||||||
main { flex: 1; min-width: 0; padding: 2rem 2.5rem; max-width: 1400px; }
|
.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-logo img { width: 120px; display: block; }
|
||||||
|
.topbar .menu-btn { border: 1px solid var(--line); background: var(--white); border-radius: var(--radius-pill); padding: 0.45rem 0.9rem; display: inline-flex; align-items: center; gap: 0.5rem; font-weight: 600; color: var(--ink); }
|
||||||
|
.topbar .menu-btn svg { width: 20px; height: 20px; }
|
||||||
|
.topbar-tools { margin-left: auto; display: flex; align-items: center; gap: 0.5rem; }
|
||||||
|
.icon-btn { width: 40px; height: 40px; border: 1px solid var(--line); background: var(--white); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: var(--ink); padding: 0; }
|
||||||
|
.icon-btn svg { width: 20px; height: 20px; }
|
||||||
|
.icon-btn:hover, .icon-btn[aria-expanded="true"] { background: var(--orange-tint-soft); border-color: var(--orange-line); }
|
||||||
|
.new-btn { --bs-btn-padding-x: 0.9rem; height: 40px; }
|
||||||
|
.new-btn svg { width: 18px; height: 18px; }
|
||||||
|
@media (max-width: 575.98px) { .new-btn { width: 40px; padding: 0; } }
|
||||||
|
.new-menu { --bs-dropdown-link-active-bg: var(--orange); --bs-dropdown-border-color: var(--line); --bs-dropdown-border-radius: var(--radius-md); min-width: 12rem; padding: 0.4rem; }
|
||||||
|
.new-menu .dropdown-item { border-radius: var(--radius-sm); padding: 0.45rem 0.8rem; font-weight: 600; }
|
||||||
|
.new-menu .dropdown-item:hover { background: var(--orange-tint-soft); }
|
||||||
|
|
||||||
|
/* zoeken: live resultaten onder het veld (desktop) of in het uitklapmenu (mobiel) */
|
||||||
|
.tsearch { position: relative; }
|
||||||
|
.tsearch-icon { position: absolute; left: 0.8rem; top: 50%; width: 18px; height: 18px; transform: translateY(-50%); color: var(--ink-muted); pointer-events: none; }
|
||||||
|
.tsearch input { padding-left: 2.4rem; border-radius: var(--radius-pill); width: 340px; max-width: 100%; padding-top: 0.45rem; padding-bottom: 0.45rem; }
|
||||||
|
.tsearch-results { display: none; position: absolute; right: 0; top: calc(100% + 0.4rem); width: min(440px, 90vw); max-height: 70vh; overflow-y: auto; background: var(--white); border: 1px solid var(--line); border-radius: var(--radius-md); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12); padding: 0.4rem; z-index: 1030; }
|
||||||
|
.tsearch-results.open { display: block; }
|
||||||
|
.ts-group { font-size: 0.72rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); padding: 0.5rem 0.6rem 0.2rem; }
|
||||||
|
.ts-item { display: block; padding: 0.4rem 0.6rem; border-radius: var(--radius-sm); color: var(--ink); line-height: 1.3; }
|
||||||
|
.ts-item small { display: block; color: var(--ink-muted); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.ts-item:hover, .ts-item:focus { background: var(--orange-tint-soft); text-decoration: none; outline: none; }
|
||||||
|
.ts-all { display: block; text-align: center; padding: 0.5rem; border-top: 1px solid var(--line); margin-top: 0.3rem; }
|
||||||
|
.ts-empty { color: var(--ink-muted); padding: 0.6rem; }
|
||||||
|
.tsearch-pop { width: calc(100vw - 2rem); padding: 0.75rem; border-radius: var(--radius-md); }
|
||||||
|
.tsearch-pop .tsearch input { width: 100%; }
|
||||||
|
.tsearch-pop .tsearch-results { position: static; width: auto; box-shadow: none; border: 0; padding: 0.4rem 0 0; max-height: 60vh; }
|
||||||
|
|
||||||
|
.app-main { flex: 1; min-width: 0; }
|
||||||
|
.content { padding: 1.25rem 1rem 2rem; max-width: 1400px; }
|
||||||
|
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.topbar { padding: 0.75rem 2.5rem; }
|
||||||
|
.app { display: flex; min-height: 100vh; }
|
||||||
|
.side { width: 240px; flex: none; position: sticky; top: 0; height: 100vh; background: var(--white); border-right: 1px solid var(--line); padding: 1.5rem 1rem; display: flex; flex-direction: column; }
|
||||||
|
.side-panel { display: flex; flex-direction: column; }
|
||||||
|
.side .offcanvas-body { flex-grow: 1; overflow-y: auto; }
|
||||||
|
.content { padding: 2rem 2.5rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- paginakop en hulpklassen ---------- */
|
||||||
.head { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
|
.head { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
|
||||||
.head h1 { margin: 0; }
|
.head h1 { margin: 0; overflow-wrap: anywhere; }
|
||||||
.kicker { font-size: 0.75rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--orange); }
|
.kicker { font-size: 0.75rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--orange); }
|
||||||
.label { font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); }
|
.label { font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); }
|
||||||
.muted { color: var(--ink-muted); }
|
.muted { color: var(--ink-muted); }
|
||||||
.empty { color: var(--ink-muted); padding: 1rem 0; }
|
.empty { color: var(--ink-muted); padding: 1rem 0; margin: 0; }
|
||||||
|
|
||||||
.card { background: var(--white); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 1.5rem; margin-bottom: 1.5rem; }
|
|
||||||
.grid { display: grid; gap: 1.5rem; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); margin-bottom: 1.5rem; }
|
|
||||||
.grid .card { margin: 0; }
|
|
||||||
.two { display: grid; grid-template-columns: minmax(0, 1fr) 380px; gap: 1.5rem; align-items: start; }
|
|
||||||
.kpis { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); margin-bottom: 1.5rem; }
|
|
||||||
.kpi { background: var(--white); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 1.25rem 1.5rem; }
|
|
||||||
.kpi .value { font-family: var(--font-heading); font-weight: 600; font-size: 2rem; line-height: 1.1; margin: 0.35rem 0 0.15rem; }
|
|
||||||
|
|
||||||
/* knoppen en formulieren */
|
|
||||||
.btn { display: inline-flex; align-items: center; gap: 0.5rem; font: 600 15px/1 var(--font-body); padding: 0.75rem 1.4rem; border-radius: var(--radius-pill); border: 1px solid var(--orange); background: var(--orange); color: var(--white); cursor: pointer; white-space: nowrap; }
|
|
||||||
.btn:hover { background: var(--orange-dark); text-decoration: none; }
|
|
||||||
.btn.ghost { background: var(--white); color: var(--orange); border-color: var(--orange-line); }
|
|
||||||
.btn.ghost:hover { background: var(--orange-tint-soft); border-color: var(--orange); }
|
|
||||||
.btn.quiet { background: var(--white); color: var(--ink-muted); border-color: var(--line); }
|
|
||||||
.btn.small { padding: 0.45rem 0.9rem; font-size: 14px; }
|
|
||||||
form.inline { display: inline; }
|
|
||||||
label { display: block; font-weight: 600; font-size: 0.9rem; margin-bottom: 0.3rem; }
|
|
||||||
input, select, textarea { width: 100%; font: 16px/1.4 var(--font-body); color: var(--ink); padding: 0.6rem 0.9rem; border: 1px solid var(--control); border-radius: var(--radius-sm); background: var(--white); }
|
|
||||||
input[type=checkbox] { width: auto; margin-right: 0.4rem; accent-color: var(--orange); }
|
|
||||||
input[type=file] { border-style: dashed; }
|
|
||||||
textarea { min-height: 110px; resize: vertical; }
|
|
||||||
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--orange); box-shadow: 0 0 0 0.25rem var(--orange-focus); }
|
|
||||||
.fields { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
|
||||||
.fields .full { grid-column: 1 / -1; }
|
|
||||||
.actions { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; margin-top: 1.25rem; }
|
|
||||||
.head .actions { margin: 0; }
|
|
||||||
.hint { font-size: 0.875rem; color: var(--ink-muted); margin: 0.3rem 0 0; }
|
.hint { font-size: 0.875rem; color: var(--ink-muted); margin: 0.3rem 0 0; }
|
||||||
|
.actions { display: flex; gap: 0.6rem; flex-wrap: wrap; align-items: center; margin-top: 1.25rem; }
|
||||||
|
.head .actions, .card-top .actions { margin: 0; }
|
||||||
|
form.inline { display: inline; }
|
||||||
|
.search input { width: 260px; }
|
||||||
|
|
||||||
/* tabellen */
|
@media (max-width: 767.98px) {
|
||||||
.table-wrap { overflow-x: auto; }
|
h1 { font-size: 1.6rem; }
|
||||||
table { width: 100%; border-collapse: collapse; }
|
.head { flex-direction: column; align-items: stretch; }
|
||||||
th { text-align: left; font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
.head .actions > * { flex: 1 1 auto; }
|
||||||
td { padding: 0.7rem 0.75rem; border-bottom: 1px solid var(--line); vertical-align: top; }
|
.head .actions .btn, .head .actions .form-select { width: 100%; }
|
||||||
tbody tr:last-child td { border-bottom: 0; }
|
.head .actions .search { flex-basis: 100%; }
|
||||||
|
.search input { width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- kaarten en kerncijfers ---------- */
|
||||||
|
.card { --bs-card-spacer-y: 1.5rem; --bs-card-spacer-x: 1.5rem; --bs-card-border-color: var(--line); --bs-card-border-radius: var(--radius-lg); --bs-card-inner-border-radius: calc(var(--radius-lg) - 1px); --bs-card-bg: var(--white); margin-bottom: 1.5rem; }
|
||||||
|
.card-body > :last-child { margin-bottom: 0; }
|
||||||
|
.card-grid > * > .card { margin-bottom: 0; height: 100%; }
|
||||||
|
.card-top { display: flex; justify-content: space-between; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
||||||
|
.card-top h2 { margin: 0; }
|
||||||
|
.card-top .actions { gap: 0.5rem; }
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
.card { --bs-card-spacer-y: 1.1rem; --bs-card-spacer-x: 1rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi { background: var(--white); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 1.25rem 1.5rem; height: 100%; }
|
||||||
|
.kpi .value { font-family: var(--font-heading); font-weight: 600; font-size: 2rem; line-height: 1.1; margin: 0.35rem 0 0.15rem; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ---------- knoppen ---------- */
|
||||||
|
.btn { --bs-btn-font-family: var(--font-body); --bs-btn-font-weight: 600; --bs-btn-font-size: 15px; --bs-btn-line-height: 1.1; --bs-btn-padding-x: 1.4rem; --bs-btn-padding-y: 0.72rem; --bs-btn-border-radius: var(--radius-pill); --bs-btn-focus-box-shadow: 0 0 0 0.25rem var(--orange-focus); display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; white-space: nowrap; }
|
||||||
|
.btn-sm { --bs-btn-padding-x: 0.9rem; --bs-btn-padding-y: 0.45rem; --bs-btn-font-size: 14px; --bs-btn-border-radius: var(--radius-pill); }
|
||||||
|
.btn-primary { --bs-btn-color: #fff; --bs-btn-bg: #b95913; --bs-btn-border-color: #b95913; --bs-btn-hover-color: #fff; --bs-btn-hover-bg: #a24e11; --bs-btn-hover-border-color: #a24e11; --bs-btn-active-color: #fff; --bs-btn-active-bg: #8f450f; --bs-btn-active-border-color: #8f450f; --bs-btn-disabled-color: #fff; --bs-btn-disabled-bg: #b95913; --bs-btn-disabled-border-color: #b95913; }
|
||||||
|
.btn-outline-primary { --bs-btn-color: #b95913; --bs-btn-bg: #fff; --bs-btn-border-color: var(--orange-line); --bs-btn-hover-color: #b95913; --bs-btn-hover-bg: var(--orange-tint-soft); --bs-btn-hover-border-color: #b95913; --bs-btn-active-color: #b95913; --bs-btn-active-bg: var(--orange-tint); --bs-btn-active-border-color: #b95913; --bs-btn-disabled-color: #b95913; --bs-btn-disabled-bg: #fff; --bs-btn-disabled-border-color: var(--orange-line); }
|
||||||
|
.btn-outline-secondary { --bs-btn-color: var(--ink-muted); --bs-btn-bg: #fff; --bs-btn-border-color: rgba(0, 0, 0, 0.12); --bs-btn-hover-color: var(--ink); --bs-btn-hover-bg: var(--light); --bs-btn-hover-border-color: rgba(0, 0, 0, 0.2); --bs-btn-active-color: var(--ink); --bs-btn-active-bg: var(--light); --bs-btn-active-border-color: rgba(0, 0, 0, 0.2); }
|
||||||
|
.linkbtn { background: none; border: 0; padding: 0; color: var(--ink-muted); font: 600 0.8rem var(--font-body); cursor: pointer; text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ---------- formulieren ---------- */
|
||||||
|
.form-label { font-weight: 600; font-size: 0.9rem; margin-bottom: 0.3rem; }
|
||||||
|
.form-control, .form-select { --bs-border-color: var(--control); font-size: 16px; line-height: 1.4; padding-top: 0.6rem; padding-bottom: 0.6rem; padding-left: 0.9rem; color: var(--ink); }
|
||||||
|
.form-control { padding-right: 0.9rem; }
|
||||||
|
.form-control-sm, .form-select-sm { font-size: 15px; padding-top: 0.45rem; padding-bottom: 0.45rem; padding-left: 0.6rem; }
|
||||||
|
.form-control-sm { padding-right: 0.6rem; }
|
||||||
|
textarea.form-control { min-height: 110px; resize: vertical; }
|
||||||
|
textarea.form-control-sm { min-height: 42px; }
|
||||||
|
input[type=file].form-control { border-style: dashed; }
|
||||||
|
.form-control:focus, .form-select:focus { border-color: var(--orange); box-shadow: 0 0 0 0.25rem var(--orange-focus); }
|
||||||
|
.form-check-input { border-color: var(--control); }
|
||||||
|
.form-check-input:checked { background-color: var(--orange); border-color: var(--orange); }
|
||||||
|
.form-check-input:focus { border-color: var(--orange); box-shadow: 0 0 0 0.25rem var(--orange-focus); }
|
||||||
|
.form-check-label { font-weight: 400; }
|
||||||
|
|
||||||
|
/* ---------- tabellen ---------- */
|
||||||
|
.table { --bs-table-bg: transparent; --bs-table-color: var(--ink); --bs-table-border-color: var(--line); --bs-table-hover-bg: var(--orange-tint-soft); --bs-table-hover-color: var(--ink); margin-bottom: 0; }
|
||||||
|
.table > :not(caption) > * > * { padding: 0.7rem 0.75rem; vertical-align: top; }
|
||||||
|
.table > thead th { font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); padding-top: 0.5rem; padding-bottom: 0.5rem; white-space: nowrap; }
|
||||||
|
.table > tbody > tr:last-child > * { border-bottom-width: 0; }
|
||||||
tr.link { cursor: pointer; }
|
tr.link { cursor: pointer; }
|
||||||
tr.link:hover td { background: var(--orange-tint-soft); }
|
|
||||||
.num { text-align: right; white-space: nowrap; }
|
.num { text-align: right; white-space: nowrap; }
|
||||||
.thumb-sm { width: 48px; height: 48px; border-radius: 10px; object-fit: cover; background: var(--blue); display: block; }
|
.thumb-sm { width: 48px; height: 48px; border-radius: 10px; object-fit: cover; background: var(--blue); display: block; }
|
||||||
|
tr.unread td { font-weight: 600; }
|
||||||
|
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--orange); margin-right: 0.4rem; vertical-align: middle; }
|
||||||
|
|
||||||
|
/* mobiel: elke tabelrij wordt een blokje met label · waarde */
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.table-stack thead { display: none; }
|
||||||
|
.table-stack > tbody > tr { display: block; padding: 0.8rem 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.table-stack > tbody > tr:last-child { border-bottom: 0; }
|
||||||
|
.table-stack > tbody > tr > td { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding: 0.1rem 0; border: 0; text-align: right; box-shadow: none; }
|
||||||
|
.table-stack > tbody > tr > td::before { content: attr(data-label); flex: none; text-align: left; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); }
|
||||||
|
.table-stack > tbody > tr > td.stack-title { display: block; text-align: left; padding-bottom: 0.3rem; }
|
||||||
|
.table-stack > tbody > tr > td.stack-title::before { content: none; }
|
||||||
|
.table-stack > tbody > tr > td:empty { display: none; }
|
||||||
|
.table-stack > tbody > tr.link:active { background: var(--orange-tint-soft); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- labels / statussen ---------- */
|
||||||
.chip { display: inline-block; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; padding: 0.15rem 0.65rem; border-radius: 999px; background: var(--blue); color: var(--ink); white-space: nowrap; }
|
.chip { display: inline-block; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; padding: 0.15rem 0.65rem; border-radius: 999px; background: var(--blue); color: var(--ink); white-space: nowrap; }
|
||||||
.chip.nieuw, .chip.concept { background: var(--orange-tint); color: var(--orange-dark); }
|
.chip.nieuw, .chip.concept { background: var(--orange-tint); color: var(--orange-dark); }
|
||||||
.chip.offerte, .chip.verzonden { background: var(--blue-dark); }
|
.chip.offerte, .chip.verzonden { background: var(--blue-dark); }
|
||||||
@@ -100,77 +205,79 @@ tr.link:hover td { background: var(--orange-tint-soft); }
|
|||||||
.chip.verloren, .chip.afgewezen { background: var(--light); color: var(--ink-muted); }
|
.chip.verloren, .chip.afgewezen { background: var(--light); color: var(--ink-muted); }
|
||||||
|
|
||||||
.filters { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; align-items: center; }
|
.filters { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; align-items: center; }
|
||||||
.filters a { padding: 0.35rem 0.9rem; border-radius: 999px; border: 1px solid var(--line); color: var(--ink); background: var(--white); font-size: 0.9rem; }
|
.filters a { padding: 0.35rem 0.9rem; border-radius: 999px; border: 1px solid var(--line); color: var(--ink); background: var(--white); font-size: 0.9rem; white-space: nowrap; }
|
||||||
.filters a:hover { border-color: var(--orange-line); text-decoration: none; }
|
.filters a:hover { border-color: var(--orange-line); text-decoration: none; }
|
||||||
.filters a.on { background: var(--orange); border-color: var(--orange); color: var(--white); }
|
.filters a.on { background: var(--orange); border-color: var(--orange); color: var(--white); }
|
||||||
.search { display: flex; gap: 0.5rem; }
|
.filters a.on .muted { color: inherit; }
|
||||||
.search input { width: 260px; }
|
@media (max-width: 767.98px) {
|
||||||
|
/* filterknoppen horizontaal schuiven in plaats van vijf regels hoog */
|
||||||
|
.filters { flex-wrap: nowrap; overflow-x: auto; margin-left: -1rem; margin-right: -1rem; padding: 0 1rem 0.25rem; scrollbar-width: none; }
|
||||||
|
.filters::-webkit-scrollbar { display: none; }
|
||||||
|
.filters > * { flex: none; }
|
||||||
|
}
|
||||||
|
|
||||||
.flash { padding: 0.8rem 1.2rem; border-radius: var(--radius-md); background: var(--blue-light); border: 1px solid var(--blue); margin-bottom: 1rem; }
|
.flash { padding: 0.8rem 1.2rem; border-radius: var(--radius-md); background: var(--blue-light); border: 1px solid var(--blue); margin-bottom: 1rem; }
|
||||||
.flash.error { background: var(--orange-tint-soft); border-color: var(--orange-line); }
|
.flash.error { background: var(--orange-tint-soft); border-color: var(--orange-line); }
|
||||||
|
|
||||||
/* grafieken */
|
/* ---------- grafieken ---------- */
|
||||||
.bars { display: flex; flex-direction: column; gap: 0.6rem; }
|
.bars { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||||
.bar { display: grid; grid-template-columns: 110px 1fr 40px; gap: 0.75rem; align-items: center; color: var(--ink); font-weight: 400; }
|
.bar { display: grid; grid-template-columns: minmax(80px, 110px) 1fr 40px; gap: 0.75rem; align-items: center; color: var(--ink); font-weight: 400; }
|
||||||
|
.bar > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
a.bar:hover { text-decoration: none; color: var(--orange); }
|
a.bar:hover { text-decoration: none; color: var(--orange); }
|
||||||
.bar .track { display: block; background: var(--blue-light); border-radius: 999px; height: 10px; overflow: hidden; }
|
.bar .track { display: block; background: var(--blue-light); border-radius: 999px; height: 10px; overflow: hidden; }
|
||||||
.bar .fill { display: block; background: var(--orange); height: 100%; border-radius: 999px; }
|
.bar .fill { display: block; background: var(--orange); height: 100%; border-radius: 999px; }
|
||||||
.cols { display: flex; align-items: flex-end; gap: 6px; height: 170px; }
|
.cols { display: flex; align-items: flex-end; gap: 6px; height: 170px; }
|
||||||
.cols > div { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; height: 100%; gap: 4px; font-size: 0.75rem; color: var(--ink-muted); }
|
.cols > div { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; height: 100%; gap: 4px; font-size: 0.75rem; color: var(--ink-muted); }
|
||||||
.cols .c { width: 100%; background: var(--orange); border-radius: 6px 6px 0 0; min-height: 2px; }
|
.cols .c { width: 100%; background: var(--orange); border-radius: 6px 6px 0 0; min-height: 2px; }
|
||||||
|
|
||||||
/* contactpagina */
|
/* ---------- contact- en relatiepagina ---------- */
|
||||||
dl.props { display: grid; grid-template-columns: max-content 1fr; gap: 0.4rem 1rem; margin: 0; }
|
dl.props { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 0.4rem 1rem; margin: 0; }
|
||||||
dl.props dt { color: var(--ink-muted); }
|
dl.props dt { color: var(--ink-muted); font-weight: 400; }
|
||||||
dl.props dd { margin: 0; word-break: break-word; white-space: pre-line; }
|
dl.props dd { margin: 0; overflow-wrap: anywhere; white-space: pre-line; }
|
||||||
.timeline { list-style: none; margin: 0; padding: 0; }
|
.timeline { list-style: none; margin: 0; padding: 0; }
|
||||||
.timeline li { padding: 0.8rem 0; border-bottom: 1px solid var(--line); }
|
.timeline li { padding: 0.8rem 0; border-bottom: 1px solid var(--line); }
|
||||||
.timeline li:last-child { border: 0; }
|
.timeline li:last-child { border: 0; }
|
||||||
.timeline .meta { font-size: 0.8125rem; color: var(--ink-muted); margin-bottom: 0.15rem; }
|
.timeline .meta { font-size: 0.8125rem; color: var(--ink-muted); margin-bottom: 0.15rem; }
|
||||||
.timeline .body { white-space: pre-wrap; word-break: break-word; }
|
.timeline .body { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
.files { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 0.75rem; margin-bottom: 1rem; }
|
.files { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 0.75rem; margin-bottom: 1rem; }
|
||||||
.file { border: 1px solid var(--line); border-radius: var(--radius-md); overflow: hidden; font-size: 0.8rem; background: var(--white); }
|
.file { border: 1px solid var(--line); border-radius: var(--radius-md); overflow: hidden; font-size: 0.8rem; background: var(--white); }
|
||||||
.file .thumb { aspect-ratio: 1; background: var(--blue); display: flex; align-items: center; justify-content: center; color: var(--ink); font-weight: 600; text-transform: uppercase; }
|
.file .thumb { aspect-ratio: 1; background: var(--blue); display: flex; align-items: center; justify-content: center; color: var(--ink); font-weight: 600; text-transform: uppercase; overflow: hidden; }
|
||||||
.file img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
.file img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
.file .name { padding: 0.35rem 0.5rem 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
|
.file .name { padding: 0.35rem 0.5rem 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
|
||||||
.file form { padding: 0 0.5rem 0.35rem; }
|
.file form { padding: 0 0.5rem 0.35rem; }
|
||||||
.linkbtn { background: none; border: 0; padding: 0; color: var(--ink-muted); font: 600 0.8rem var(--font-body); cursor: pointer; text-decoration: underline; }
|
.file .muted { padding: 0 0.5rem 0.35rem; font-size: 0.8rem; }
|
||||||
|
.viewer-frame { aspect-ratio: 16 / 10; min-height: 320px; border: 1px solid var(--line); border-radius: var(--radius-md); overflow: hidden; background: var(--blue); }
|
||||||
|
.viewer-frame iframe { display: block; width: 100%; height: 100%; border: 0; }
|
||||||
|
.kaart-frame { height: 520px; max-height: 75vh; border: 1px solid var(--line); border-radius: var(--radius-md); overflow: hidden; background: var(--blue); }
|
||||||
|
.kaart-frame iframe { display: block; width: 100%; height: 100%; border: 0; }
|
||||||
|
.kavel-meters { display: flex; flex-wrap: wrap; gap: 0.25rem 2rem; margin: 0 0 1rem; }
|
||||||
|
.kavel-meters[hidden] { display: none; }
|
||||||
|
.kavel-meters > div { display: flex; gap: 0.5rem; }
|
||||||
|
.kavel-meters dt { color: var(--ink-muted); font-weight: 400; }
|
||||||
|
.kavel-meters dd { margin: 0; }
|
||||||
|
/* documenten met versies (contact, relatie, portaal) */
|
||||||
|
.docs { margin-bottom: 1rem; }
|
||||||
|
.doc { border-top: 1px solid var(--line); }
|
||||||
|
.doc:first-child { border-top: 0; }
|
||||||
|
.doc > summary { list-style: none; display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0; font: inherit; }
|
||||||
|
.doc > summary::-webkit-details-marker { display: none; }
|
||||||
|
.doc[open] > summary { margin-bottom: 0.25rem; }
|
||||||
|
.doc .doc-icon { flex: 0 0 40px; height: 40px; border-radius: 10px; background: var(--blue); display: flex; align-items: center; justify-content: center; overflow: hidden; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; color: var(--ink); }
|
||||||
|
.doc .doc-icon img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.doc .doc-main { min-width: 0; flex: 1; }
|
||||||
|
.doc .doc-title { display: block; color: var(--ink); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.doc .doc-main .muted { display: block; font-size: 0.8rem; }
|
||||||
|
.doc .versions { list-style: none; padding: 0 0 0 52px; margin: 0 0 0.5rem; font-size: 0.85rem; }
|
||||||
|
.doc .versions li { display: flex; flex-wrap: wrap; gap: 0 0.5rem; align-items: baseline; padding: 0.2rem 0; }
|
||||||
|
.doc .versions form { margin-left: auto; }
|
||||||
|
.doc .doc-forms { padding: 0 0 0.75rem 52px; }
|
||||||
|
.doc .doc-forms .actions { margin-top: 0.5rem; }
|
||||||
details summary { cursor: pointer; font-family: var(--font-heading); font-size: 1.3rem; font-weight: 500; }
|
details 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; }
|
||||||
/* inbox */
|
.members { display: grid; }
|
||||||
.mailbody { white-space: pre-wrap; word-break: break-word; font-size: 15px; line-height: 1.7; }
|
.member { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem 1rem; padding: 0.6rem 0; border-bottom: 1px solid var(--line); }
|
||||||
tr.unread td { font-weight: 600; }
|
.member:last-child { border-bottom: 0; }
|
||||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--orange); margin-right: 0.4rem; vertical-align: middle; }
|
|
||||||
|
|
||||||
/* offerte-editor */
|
|
||||||
.lines td { padding: 0.4rem 0.3rem; vertical-align: middle; }
|
|
||||||
.lines input, .lines select, .lines textarea { padding: 0.45rem 0.6rem; font-size: 15px; }
|
|
||||||
.lines textarea { min-height: 42px; height: 42px; }
|
|
||||||
.totals { margin-left: auto; width: 320px; max-width: 100%; margin-top: 1rem; }
|
|
||||||
.totals td { padding: 0.3rem 0.75rem; border: 0; }
|
|
||||||
.totals tr.grand td { font-family: var(--font-heading); font-weight: 600; font-size: 1.2rem; border-top: 1px solid var(--line); padding-top: 0.6rem; }
|
|
||||||
|
|
||||||
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; background: var(--blue-light); border-radius: var(--radius-sm); }
|
|
||||||
code { padding: 0.1rem 0.35rem; word-break: break-all; }
|
|
||||||
pre { padding: 1rem; overflow-x: auto; }
|
|
||||||
|
|
||||||
/* inloggen */
|
|
||||||
.auth { margin: auto; width: 100%; max-width: 420px; padding: 16px; }
|
|
||||||
.auth .card { padding: 2rem; }
|
|
||||||
.auth img { width: 200px; margin-bottom: 1.5rem; }
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
body { flex-direction: column; }
|
|
||||||
.side { width: auto; height: auto; position: static; flex-direction: row; align-items: center; padding: 0.75rem 16px; gap: 0.75rem; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); }
|
|
||||||
.brand img { width: 110px; margin: 0; }
|
|
||||||
.side nav { flex-direction: row; }
|
|
||||||
.side nav a, .logout { padding: 0.45rem 0.8rem; }
|
|
||||||
main { padding: 1.25rem 16px; }
|
|
||||||
.two { grid-template-columns: 1fr; }
|
|
||||||
.search, .search input { width: 100%; }
|
|
||||||
h1 { font-size: 1.6rem; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* taken */
|
/* taken */
|
||||||
.task { display: flex; gap: 0.75rem; align-items: flex-start; padding: 0.6rem 0; border-bottom: 1px solid var(--line); }
|
.task { display: flex; gap: 0.75rem; align-items: flex-start; padding: 0.6rem 0; border-bottom: 1px solid var(--line); }
|
||||||
@@ -179,28 +286,59 @@ pre { padding: 1rem; overflow-x: auto; }
|
|||||||
.check { flex: none; width: 22px; height: 22px; margin-top: 2px; border-radius: 50%; border: 2px solid var(--orange); background: var(--white); cursor: pointer; padding: 0; }
|
.check { flex: none; width: 22px; height: 22px; margin-top: 2px; border-radius: 50%; border: 2px solid var(--orange); background: var(--white); cursor: pointer; padding: 0; }
|
||||||
.check:hover, .task.done .check { background: var(--orange); }
|
.check:hover, .task.done .check { background: var(--orange); }
|
||||||
.overdue { color: var(--orange-dark); font-weight: 600; }
|
.overdue { color: var(--orange-dark); font-weight: 600; }
|
||||||
.inline-fields { display: grid; grid-template-columns: 1fr 160px; gap: 0.5rem; }
|
|
||||||
|
|
||||||
/* portaal */
|
/* contact/relatie op mobiel: gegevens bovenaan, de 3D-weergave en de rest daaronder */
|
||||||
.portal-top { background: var(--white); border-bottom: 1px solid var(--line); padding: 1rem 16px; }
|
@media (max-width: 991.98px) {
|
||||||
.portal-top .wrap, .portal-main { max-width: 1000px; margin: 0 auto; }
|
.stack { display: flex; flex-direction: column; margin: 0; }
|
||||||
.portal-top .wrap { display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
|
.stack > * { display: contents; }
|
||||||
|
.stack .o-1 { order: -3; }
|
||||||
|
.stack .o-2 { order: -2; }
|
||||||
|
.stack .o-3 { order: -1; }
|
||||||
|
.viewer-frame { aspect-ratio: 4 / 5; min-height: 0; max-height: 70vh; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- offerte-editor ---------- */
|
||||||
|
.lines > :not(caption) > * > * { padding: 0.4rem 0.3rem; vertical-align: middle; }
|
||||||
|
.lines textarea.form-control { height: 42px; }
|
||||||
|
.totals { margin-left: auto; width: 320px; max-width: 100%; margin-top: 1rem; }
|
||||||
|
.totals td { padding: 0.3rem 0.75rem; border: 0; }
|
||||||
|
.totals tr.grand td { font-family: var(--font-heading); font-weight: 600; font-size: 1.2rem; border-top: 1px solid var(--line); padding-top: 0.6rem; }
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
/* elke regel als blokje: product en omschrijving breed, de getallen twee aan twee */
|
||||||
|
.lines thead { display: none; }
|
||||||
|
.lines, .lines > tbody { display: block; }
|
||||||
|
.lines > tbody > tr { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem 0.75rem; padding: 0.9rem 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.lines > tbody > tr > td { display: block; padding: 0; border: 0; }
|
||||||
|
.lines > tbody > tr > td::before { content: attr(data-label); display: block; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); margin-bottom: 0.15rem; }
|
||||||
|
.lines > tbody > tr > td:nth-child(-n+2) { grid-column: 1 / -1; }
|
||||||
|
.lines .amt { text-align: left; font-weight: 600; align-self: end; }
|
||||||
|
.lines .del-cell { text-align: right; align-self: end; }
|
||||||
|
.lines .del-cell::before { content: none; }
|
||||||
|
.lines .del { font-size: 0.9rem; }
|
||||||
|
.lines .del::after { content: " verwijderen"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; background: var(--blue-light); border-radius: var(--radius-sm); }
|
||||||
|
code { padding: 0.1rem 0.35rem; word-break: break-all; color: var(--ink); }
|
||||||
|
pre { padding: 1rem; overflow-x: auto; }
|
||||||
|
|
||||||
|
/* ---------- inloggen ---------- */
|
||||||
|
.auth-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 16px; }
|
||||||
|
.auth { width: 100%; max-width: 420px; }
|
||||||
|
.auth .card { --bs-card-spacer-y: 2rem; --bs-card-spacer-x: 2rem; }
|
||||||
|
.auth img { width: 200px; margin-bottom: 1.5rem; }
|
||||||
|
|
||||||
|
/* ---------- portaal ---------- */
|
||||||
|
.portal-top { background: var(--white); border-bottom: 1px solid var(--line); padding: 1rem 0; }
|
||||||
.portal-top img { width: 160px; display: block; }
|
.portal-top img { width: 160px; display: block; }
|
||||||
.portal-main { padding: 2rem 16px; width: 100%; }
|
.portal-main { padding-top: 2rem; padding-bottom: 2rem; }
|
||||||
body.portal { flex-direction: column; }
|
|
||||||
|
|
||||||
/* dialoog */
|
/* ---------- dialoog ---------- */
|
||||||
.dialog { border: 0; border-radius: var(--radius-lg); padding: 1.75rem; width: min(480px, calc(100vw - 32px)); box-shadow: var(--shadow-menu, 0 10px 30px rgba(0,0,0,.1)); }
|
.dialog { border: 0; border-radius: var(--radius-lg); padding: 1.75rem; width: min(480px, calc(100vw - 32px)); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); }
|
||||||
.dialog::backdrop { background: rgba(255, 255, 255, 0.6); backdrop-filter: blur(2px); }
|
.dialog::backdrop { background: rgba(255, 255, 255, 0.6); backdrop-filter: blur(2px); }
|
||||||
.dialog label { font-weight: 600; }
|
|
||||||
|
|
||||||
/* relaties */
|
/* ---------- beweging (huisstijl: lift 4–6px met shadow-hover en oranje rand, beeld schaalt 1.03–1.06) ---------- */
|
||||||
.members { display: grid; gap: 0.5rem; }
|
.fx-wait .content, .fx-wait .portal-main, .fx-wait .auth { opacity: 0; }
|
||||||
.member { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 0.6rem 0; border-bottom: 1px solid var(--line); }
|
|
||||||
.member:last-child { border-bottom: 0; }
|
|
||||||
|
|
||||||
/* beweging (huisstijl: lift 4–6px met shadow-hover en oranje rand, beeld schaalt 1.03–1.06) */
|
|
||||||
.fx-wait main, .fx-wait .portal-main, .fx-wait .auth { opacity: 0; }
|
|
||||||
.kpi, .file, a.bar, .btn, .side nav a { transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.6s cubic-bezier(0.22, 1, 0.36, 1), border-color 0.4s, background-color 0.25s, color 0.25s; }
|
.kpi, .file, a.bar, .btn, .side nav a { transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.6s cubic-bezier(0.22, 1, 0.36, 1), border-color 0.4s, background-color 0.25s, color 0.25s; }
|
||||||
.kpi:hover, .file:hover { transform: translateY(-5px); box-shadow: 0 1.25rem 2.5rem -1rem rgba(0, 0, 0, 0.18); border-color: var(--orange-line); }
|
.kpi:hover, .file:hover { transform: translateY(-5px); box-shadow: 0 1.25rem 2.5rem -1rem rgba(0, 0, 0, 0.18); border-color: var(--orange-line); }
|
||||||
.file img { transition: transform 0.8s cubic-bezier(0.22, 1, 0.36, 1); }
|
.file img { transition: transform 0.8s cubic-bezier(0.22, 1, 0.36, 1); }
|
||||||
@@ -213,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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,512 @@
|
|||||||
|
/* Viewer voor een woningontwerp (house-design-JSON uit de configurator), alleen bekijken.
|
||||||
|
*
|
||||||
|
* Draait op de engine van de configurator (static/configurator/, bijwerken met tools/configurator-sync.sh):
|
||||||
|
* dezelfde HouseModel.computeParts als de configurator en de light-versie, afgewerkte woning in een
|
||||||
|
* dag-omgeving. Weergaven 3D, Doorsnede en Plattegrond zoals de light-versie (js/light/light.js); de
|
||||||
|
* plattegrond is daaruit overgenomen, dus wijzigingen daar hier meenemen.
|
||||||
|
* Kerncijfers (maten, m², inhoud, gewicht, indicatieve prijs) rekent de engine; de prijs komt uit kosten/*.csv.
|
||||||
|
* Lagen: afgewerkt (zoals de light-versie) of per bouwfase / laag (ViewLayers, zoals Lagen in de configurator),
|
||||||
|
* zodat de ploeg bijv. regelwerk of elektra kan bekijken. PDF: werktekening = HousePdf.generate (alle tekenbladen),
|
||||||
|
* klant = LightPdf.generate zoals de light-configurator (voorblad met beeld, kerncijfers, keuzes, prijs en QR-code,
|
||||||
|
* aanzichten en plattegrond, werkwijze); bewaard bij het contact (POST naar de CRM); ontbreekt er een, dan maakt de
|
||||||
|
* viewer hem vanzelf aan.
|
||||||
|
*/
|
||||||
|
(() => {
|
||||||
|
const root = document.getElementById('viewer');
|
||||||
|
const $ = (sel) => root.querySelector(sel);
|
||||||
|
const nl = (n, d = 0) => n.toLocaleString('nl-NL', { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||||
|
const meters = (mm) => `${nl(mm / 1000, 2)} m`;
|
||||||
|
const SECTION = { start: 1500, min: 300, max: 3000 };
|
||||||
|
const HINTS = {
|
||||||
|
'3d': 'Slepen = draaien · rechts of Shift+slepen = schuiven · klik en scroll = zoomen',
|
||||||
|
cut: 'Slepen = draaien · rechts of Shift+slepen = schuiven · klik en scroll = zoomen',
|
||||||
|
plan: 'Noord boven · maten in mm'
|
||||||
|
};
|
||||||
|
|
||||||
|
let st;
|
||||||
|
try {
|
||||||
|
st = HouseModel.normalize(HouseModel.clone(JSON.parse(document.getElementById('design').textContent)));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
root.classList.add('is-error');
|
||||||
|
$('.vw-msg').textContent = `Dit ontwerp kan niet worden getoond: ${err.message}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3D: afgewerkte woning, dag-omgeving (zoals de light-versie).
|
||||||
|
const stage = $('.vw-3d');
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0xdfe6ec);
|
||||||
|
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 400);
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
|
||||||
|
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
renderer.localClippingEnabled = true;
|
||||||
|
stage.appendChild(renderer.domElement);
|
||||||
|
const controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||||
|
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
|
||||||
|
const sun = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||||
|
sun.position.set(5, 8, 5);
|
||||||
|
scene.add(ambient, sun);
|
||||||
|
const sky = Skybox.createEnvironment(scene);
|
||||||
|
const mats = {
|
||||||
|
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials(),
|
||||||
|
lightingMaterials: LightingModel.createMaterials(), sandwichMaterials: SandwichModel.createMaterials(), postMaterials: PostModel.createMaterials(),
|
||||||
|
chimneyMaterials: ChimneyModel.createMaterials(), elektraMaterials: ElektraModel.createMaterials()
|
||||||
|
};
|
||||||
|
const S = st.settings;
|
||||||
|
if (S.interiorPaint) mats.wallMaterials.spaanplaatMaterial.color.set(S.interiorColor);
|
||||||
|
mats.roofMaterials.ceilingMaterial.color.set(S.ceilingColor || '#f4f3ef');
|
||||||
|
|
||||||
|
const cfg = JSON.parse((document.getElementById('viewer-cfg') || {}).textContent || '{}');
|
||||||
|
const disposeGeo = (g) => g && g.traverse(x => { if (x.geometry) x.geometry.dispose(); });
|
||||||
|
|
||||||
|
// Lagen: 'af' = afgewerkte woning (finishedOnly, zoals de light-versie); anders de gekozen lagen (bouwfase of los).
|
||||||
|
let layers = ViewLayers.all(), finished = true, parts = null, group = null;
|
||||||
|
function build() {
|
||||||
|
const next = HouseModel.computeParts(st, Object.assign({ layers, finishedOnly: finished }, mats));
|
||||||
|
HouseModel.placeGroups(next);
|
||||||
|
if (group) { scene.remove(group); disposeGeo(group); caps = null; }
|
||||||
|
parts = next;
|
||||||
|
group = new THREE.Group();
|
||||||
|
HouseModel.rootGroups(parts).forEach(g => group.add(g));
|
||||||
|
scene.add(group);
|
||||||
|
}
|
||||||
|
let caps = null;
|
||||||
|
build();
|
||||||
|
|
||||||
|
const toM = 0.001, rs = parts.rects;
|
||||||
|
const bx0 = Math.min(...rs.map(r => r.x0)), bx1 = Math.max(...rs.map(r => r.x1));
|
||||||
|
const bz0 = Math.min(...rs.map(r => r.z0)), bz1 = Math.max(...rs.map(r => r.z1));
|
||||||
|
const center = { x: (bx0 + bx1) / 2 * toM, z: (bz0 + bz1) / 2 * toM, size: Math.max(bx1 - bx0, bz1 - bz0) * toM };
|
||||||
|
const floorTop = Math.max(...parts.units.map(u => u.layout.floorTop));
|
||||||
|
sky.fitTo(group, (floorTop - S.groundLevel - 2) * toM);
|
||||||
|
Skybox.apply('day', { env: sky, controls, sun, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: group });
|
||||||
|
controls.target.set(center.x, 1.2, center.z);
|
||||||
|
camera.position.set(center.x - center.size * 0.9, center.size * 0.7, center.z - center.size * 1.3);
|
||||||
|
controls.update();
|
||||||
|
|
||||||
|
// Weergave: 3D, doorsnede (dak en lampen weg, wanden afgesneden, snede wit afgedekt) of plattegrond.
|
||||||
|
let view = '3d', sectionMM = SECTION.start;
|
||||||
|
const poses = {};
|
||||||
|
const cutPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0);
|
||||||
|
const capMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
|
||||||
|
function clip(g, planes) {
|
||||||
|
if (g) g.traverse(x => (Array.isArray(x.material) ? x.material : x.material ? [x.material] : []).forEach(m => {
|
||||||
|
if ((m.clippingPlanes || []).length === planes.length) return;
|
||||||
|
m.clippingPlanes = planes;
|
||||||
|
m.needsUpdate = true;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
function placeCut() {
|
||||||
|
cutPlane.constant = (floorTop + sectionMM) * toM;
|
||||||
|
if (caps) { group.remove(caps); disposeGeo(caps); caps = null; }
|
||||||
|
if (view === 'cut') { caps = HouseModel.sectionCaps(parts, floorTop + sectionMM, capMaterial, ViewLayers.all()); group.add(caps); }
|
||||||
|
}
|
||||||
|
function applyView() {
|
||||||
|
const cut = view === 'cut', roof = !cut && layers.dak !== false;
|
||||||
|
parts.units.forEach(u => { if (u.roof.group) u.roof.group.visible = roof; });
|
||||||
|
[parts.roofEdgeGroup, parts.postsGroup].forEach(g => { if (g) g.visible = roof; });
|
||||||
|
if (parts.roomsGroup && parts.roomsGroup.userData.ceilings) parts.roomsGroup.userData.ceilings.visible = roof;
|
||||||
|
const planes = cut ? [cutPlane] : [];
|
||||||
|
HouseModel.allWallPieces(parts).forEach(x => clip(x.group, planes));
|
||||||
|
[parts.elektraGroup, parts.chimneysGroup, parts.roomsGroup, parts.fixturesGroup, parts.lightingGroup].forEach(g => clip(g, planes));
|
||||||
|
placeCut();
|
||||||
|
if (view === 'plan') drawPlan();
|
||||||
|
}
|
||||||
|
// Snijhoogte niet hoger dan de laagste goot (plat dak: de dakhoogte), zodat de snede door alle gevels gaat.
|
||||||
|
const cutInput = $('.vw-cutbar input');
|
||||||
|
const lows = st.settings.roof.house && st.settings.roof.house.enabled && st.settings.roof.house.form
|
||||||
|
? [st.settings.roof.house.form.low] : st.units.map(u => UnitModel.roofForm(u.unit).low);
|
||||||
|
cutInput.max = Math.max(SECTION.min, Math.floor(Math.min(...lows.filter(Number.isFinite), SECTION.max) / 50) * 50);
|
||||||
|
sectionMM = Math.min(sectionMM, Number(cutInput.max));
|
||||||
|
cutInput.value = sectionMM;
|
||||||
|
$('.vw-cutbar output').textContent = meters(sectionMM);
|
||||||
|
cutInput.addEventListener('input', () => {
|
||||||
|
sectionMM = Number(cutInput.value);
|
||||||
|
$('.vw-cutbar output').textContent = meters(sectionMM);
|
||||||
|
placeCut();
|
||||||
|
});
|
||||||
|
function setView(v) {
|
||||||
|
if (v === view) return;
|
||||||
|
if (view !== 'plan') poses[view] = { pos: camera.position.clone(), target: controls.target.clone() };
|
||||||
|
view = v;
|
||||||
|
if (v !== 'plan') {
|
||||||
|
const pose = poses[v];
|
||||||
|
if (pose) { camera.position.copy(pose.pos); controls.target.copy(pose.target); }
|
||||||
|
else {
|
||||||
|
controls.target.set(center.x, 0.6, center.z);
|
||||||
|
camera.position.set(center.x - center.size * 0.45, center.size * 1.05 + 2, center.z + center.size * 0.75);
|
||||||
|
}
|
||||||
|
controls.update();
|
||||||
|
}
|
||||||
|
root.classList.toggle('view-plan', v === 'plan');
|
||||||
|
root.classList.toggle('view-cut', v === 'cut');
|
||||||
|
root.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('on', b.dataset.view === v));
|
||||||
|
$('.vw-hint').textContent = HINTS[v];
|
||||||
|
applyView();
|
||||||
|
}
|
||||||
|
$('.vw-hint').textContent = HINTS['3d'];
|
||||||
|
$('.vw-viewbar').addEventListener('click', (e) => { const b = e.target.closest('[data-view]'); if (b) setView(b.dataset.view); });
|
||||||
|
|
||||||
|
// Plattegrond (noord boven), overgenomen uit js/light/light.js (drawPlan).
|
||||||
|
function drawPlan() {
|
||||||
|
const cv = $('.vw-plan'), W = cv.clientWidth, H = cv.clientHeight;
|
||||||
|
if (!W || !H) return;
|
||||||
|
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||||
|
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
|
||||||
|
const ctx = cv.getContext('2d');
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
const p = parts;
|
||||||
|
const ext = rs.concat(p.units.map(u => u.roof.rect));
|
||||||
|
const ex0 = Math.min(...ext.map(r => r.x0)), ex1 = Math.max(...ext.map(r => r.x1));
|
||||||
|
const ez0 = Math.min(...ext.map(r => r.z0)), ez1 = Math.max(...ext.map(r => r.z1));
|
||||||
|
const top = 64, side = 56;
|
||||||
|
const scale = Math.min((W - 2 * side) / (ex1 - ex0), (H - top - side) / (ez1 - ez0));
|
||||||
|
const ox = W / 2 - (ex0 + ex1) / 2 * scale, oy = top + (H - top - side) / 2 - (ez0 + ez1) / 2 * scale;
|
||||||
|
const P = (x, z) => [ox + x * scale, oy + z * scale];
|
||||||
|
const path = (pts, closed) => { ctx.beginPath(); pts.forEach((q, i) => ctx[i ? 'lineTo' : 'moveTo'](...P(q.x, q.z))); if (closed) ctx.closePath(); };
|
||||||
|
const corners = (pl, lx0, lx1) => { const t = pl.thickness / 2; return [[lx0, -t], [lx1, -t], [lx1, t], [lx0, t]].map(([a, b]) => UnitModel.localToWorld(pl, a, b)); };
|
||||||
|
const rgba = (hex, a) => `rgba(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)}, ${a})`;
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
|
||||||
|
// Dakrand
|
||||||
|
ctx.setLineDash([5, 4]); ctx.strokeStyle = '#9aa3ad'; ctx.lineWidth = 1;
|
||||||
|
if (p.roofEdge) p.roofEdge.segments.forEach(sg => { path([sg.outerA, sg.outerB]); ctx.stroke(); });
|
||||||
|
else p.units.forEach(u => { const [x, y] = P(u.roof.rect.x0, u.roof.rect.z0); ctx.strokeRect(x, y, u.roof.rect.W * scale, u.roof.rect.D * scale); });
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// Ruimtes
|
||||||
|
const R = p.rooms;
|
||||||
|
R.rooms.forEach(r => { ctx.fillStyle = rgba(r.color, 0.22); r.rects.forEach(c => { const [x, y] = P(c.x0, c.z0); ctx.fillRect(x, y, (c.x1 - c.x0) * scale, (c.z1 - c.z0) * scale); }); });
|
||||||
|
|
||||||
|
// Inrichting
|
||||||
|
(p.fixtures || []).forEach(it => FixtureModel.planShapes(it).forEach(sh => {
|
||||||
|
ctx.strokeStyle = '#8b939b';
|
||||||
|
ctx.lineWidth = sh.kind === 'thin' ? 0.6 : 1;
|
||||||
|
ctx.setLineDash(sh.kind === 'dash' ? [3, 3] : []);
|
||||||
|
if (sh.circle) { ctx.beginPath(); ctx.arc(...P(sh.circle.x, sh.circle.z), Math.max(1, sh.circle.r * scale), 0, Math.PI * 2); ctx.stroke(); return; }
|
||||||
|
if (sh.text) return;
|
||||||
|
path(sh.pts, sh.closed);
|
||||||
|
if (sh.fill) { ctx.fillStyle = 'rgba(139, 147, 155, 0.18)'; ctx.fill(); }
|
||||||
|
ctx.stroke();
|
||||||
|
}));
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// Wanden met openingen en kozijnsymbolen
|
||||||
|
const piece = (x, fill) => {
|
||||||
|
const pl = x.place, Wp = pl.width;
|
||||||
|
path(corners(pl, -Wp / 2, Wp / 2), true);
|
||||||
|
ctx.fillStyle = fill; ctx.fill();
|
||||||
|
x.config.openings.filter(op => op.type !== 'schoorsteen').forEach(op => {
|
||||||
|
path(corners(pl, -Wp / 2 + op.x, -Wp / 2 + op.x + op.w), true);
|
||||||
|
ctx.fillStyle = '#ffffff'; ctx.fill();
|
||||||
|
ctx.strokeStyle = '#33383d'; ctx.lineWidth = 0.8; ctx.stroke();
|
||||||
|
if (!op.fill) return;
|
||||||
|
KozijnModel.planSymbol(op, op.fill, { zIn: -pl.thickness / 2, zOut: pl.thickness / 2 }).forEach(sh => {
|
||||||
|
ctx.lineWidth = sh.kind === 'leaf' ? 1.3 : 0.8;
|
||||||
|
ctx.setLineDash(sh.kind === 'arc' || sh.kind === 'rail' ? [3, 3] : []);
|
||||||
|
path(sh.pts.map(q => UnitModel.localToWorld(pl, -Wp / 2 + op.x + q.x, q.z)), !!sh.closed);
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
p.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(x => piece(x, '#3d4247'))));
|
||||||
|
p.interior.forEach(x => piece(x, '#7a828a'));
|
||||||
|
ctx.fillStyle = '#a07c54';
|
||||||
|
p.posts.posts.forEach(pp => { const [x, y] = P(pp.x - pp.size / 2, pp.z - pp.size / 2); ctx.fillRect(x, y, pp.size * scale, pp.size * scale); });
|
||||||
|
p.posts.walls.forEach(w => { const [x, y] = P(w.x0, w.z0); ctx.fillStyle = '#3d4247'; ctx.fillRect(x, y, (w.x1 - w.x0) * scale, (w.z1 - w.z0) * scale); });
|
||||||
|
(p.chimneys || []).forEach(c => { path(c.footprint, true); ctx.fillStyle = '#8b8f93'; ctx.fill(); });
|
||||||
|
// Binnenspots
|
||||||
|
ctx.fillStyle = '#ffd36b'; ctx.strokeStyle = '#a07a1e'; ctx.lineWidth = 0.8;
|
||||||
|
(p.lighting.indoor || []).forEach(sp => { ctx.beginPath(); ctx.arc(...P(sp.x, sp.z), Math.max(2.5, 90 * scale), 0, Math.PI * 2); ctx.fill(); ctx.stroke(); });
|
||||||
|
|
||||||
|
// Ruimtenamen
|
||||||
|
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||||
|
const fs = Math.max(9, Math.min(13, scale * 260));
|
||||||
|
R.rooms.forEach(r => {
|
||||||
|
const [x, y] = P(r.label.x, r.label.z);
|
||||||
|
ctx.fillStyle = '#212121'; ctx.font = `600 ${fs}px sans-serif`;
|
||||||
|
ctx.fillText(r.name, x, y - fs * 0.6);
|
||||||
|
ctx.fillStyle = '#5b6673'; ctx.font = `${fs * 0.9}px sans-serif`;
|
||||||
|
ctx.fillText(`${nl(r.areaM2, 1)} m²`, x, y + fs * 0.6);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Buitenmaten (units) en noordpijl
|
||||||
|
ctx.strokeStyle = '#1d5f99'; ctx.fillStyle = '#1d5f99'; ctx.lineWidth = 1; ctx.font = '12px sans-serif';
|
||||||
|
const dim = (a, b, horiz, at) => {
|
||||||
|
const [x0, y0] = horiz ? P(a, 0) : P(0, a), [x1, y1] = horiz ? P(b, 0) : P(0, b);
|
||||||
|
const A = horiz ? [x0, at] : [at, y0], B = horiz ? [x1, at] : [at, y1];
|
||||||
|
ctx.beginPath(); ctx.moveTo(...A); ctx.lineTo(...B);
|
||||||
|
[A, B].forEach(([x, y]) => { ctx.moveTo(x - 4, y + 4); ctx.lineTo(x + 4, y - 4); ctx.moveTo(...(horiz ? [x, y - 6] : [x - 6, y])); ctx.lineTo(...(horiz ? [x, y + 6] : [x + 6, y])); });
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate((A[0] + B[0]) / 2, (A[1] + B[1]) / 2);
|
||||||
|
if (!horiz) ctx.rotate(-Math.PI / 2);
|
||||||
|
ctx.fillText(nl(Math.round(b - a)), 0, -9);
|
||||||
|
ctx.restore();
|
||||||
|
};
|
||||||
|
dim(bx0, bx1, true, P(0, ez0)[1] - 22);
|
||||||
|
dim(bz0, bz1, false, P(ex0, 0)[0] - 22);
|
||||||
|
const nx = W - 28, ny = top + 6;
|
||||||
|
ctx.beginPath(); ctx.moveTo(nx, ny); ctx.lineTo(nx - 7, ny + 18); ctx.lineTo(nx + 7, ny + 18); ctx.closePath(); ctx.fill();
|
||||||
|
ctx.fillText('N', nx, ny + 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kerncijfers; de prijs volgt zodra de kosten-CSV's geladen zijn.
|
||||||
|
function facts() {
|
||||||
|
const totals = HouseModel.computeTotals(parts);
|
||||||
|
const d = HouseModel.computeDimensions(parts, S.groundLevel);
|
||||||
|
let price = null;
|
||||||
|
try { price = CostModel.compute(parts, totals).summary.inclBtw; } catch (err) { console.warn(err); }
|
||||||
|
const rows = [
|
||||||
|
['Buitenmaten', `${nl(d.lengthX / 1000, 2)} × ${nl(d.lengthZ / 1000, 2)} m`],
|
||||||
|
['Bouwhoogte', meters(d.height)],
|
||||||
|
['Bruto / leefbaar', `${nl(d.brutoM2, 1)} / ${nl(d.leefbaarM2, 1)} m²`],
|
||||||
|
['Inhoud', `${nl(d.inhoudM3, 0)} m³`],
|
||||||
|
['Units', String(st.units.length)],
|
||||||
|
['Gewicht', `${nl(totals.totalKg / 1000, 1)} t`]
|
||||||
|
];
|
||||||
|
if (price) rows.push(['Indicatief incl. btw', `€ ${nl(Math.round(price / 100) * 100)}`]);
|
||||||
|
$('.vw-facts').innerHTML = rows.map(([k, v]) => `<div><span>${k}</span><b>${v}</b></div>`).join('');
|
||||||
|
}
|
||||||
|
facts();
|
||||||
|
CostModel.onChange(facts);
|
||||||
|
|
||||||
|
// Scrollwiel: in de CRM-pagina scrolt het de pagina, tenzij je in de 3D hebt geklikt of Ctrl/⌘ ingedrukt houdt.
|
||||||
|
const stageEl = $('.vw-stage');
|
||||||
|
let engaged = false, toastTimer = null;
|
||||||
|
const fsEl = () => document.fullscreenElement || document.webkitFullscreenElement;
|
||||||
|
function toast(msg) {
|
||||||
|
const t = $('.vw-toast');
|
||||||
|
t.textContent = msg;
|
||||||
|
t.classList.add('on');
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => t.classList.remove('on'), 3000);
|
||||||
|
}
|
||||||
|
stageEl.addEventListener('pointerdown', () => { engaged = true; });
|
||||||
|
stageEl.addEventListener('pointerleave', () => { engaged = false; });
|
||||||
|
stageEl.addEventListener('wheel', (e) => {
|
||||||
|
if (engaged || e.ctrlKey || e.metaKey || view === 'plan' || fsEl() || window.parent === window) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
toast('Klik eerst in de weergave (of houd Ctrl ingedrukt) om te zoomen.');
|
||||||
|
}, { capture: true });
|
||||||
|
|
||||||
|
// Schermvullend
|
||||||
|
const fullBtn = $('.vw-full');
|
||||||
|
if (root.requestFullscreen || root.webkitRequestFullscreen) {
|
||||||
|
fullBtn.hidden = false;
|
||||||
|
fullBtn.addEventListener('click', () => {
|
||||||
|
const r = fsEl() ? (document.exitFullscreen || document.webkitExitFullscreen).call(document)
|
||||||
|
: (root.requestFullscreen || root.webkitRequestFullscreen).call(root);
|
||||||
|
if (r && r.catch) r.catch(() => toast('Schermvullend lukt hier niet.'));
|
||||||
|
});
|
||||||
|
const onFull = () => { root.classList.toggle('is-full', !!fsEl()); fullBtn.title = fsEl() ? 'Schermvullend uit (Esc)' : 'Schermvullend'; };
|
||||||
|
document.addEventListener('fullscreenchange', onFull);
|
||||||
|
document.addEventListener('webkitfullscreenchange', onFull);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Afbeelding van de huidige 3D-weergave
|
||||||
|
$('.vw-shot').addEventListener('click', () => {
|
||||||
|
if (view === 'plan') { const a = document.createElement('a'); a.href = $('.vw-plan').toDataURL('image/png'); a.download = 'plattegrond.png'; a.click(); return; }
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = renderer.domElement.toDataURL('image/png');
|
||||||
|
a.download = `${(st.project && st.project.naam) || 'ontwerp'}.png`;
|
||||||
|
a.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lagenmenu: bouwfase (alles tot en met) of losse lagen; elke wijziging bouwt de woning opnieuw op.
|
||||||
|
const layerPop = $('.vw-pop-layers'), phaseSel = layerPop.querySelector('select');
|
||||||
|
phaseSel.innerHTML = `<option value="" hidden>Eigen keuze</option><option value="af">Afgewerkte woning</option>` +
|
||||||
|
ViewLayers.PHASES.slice(0, -1).map((p, i) => `<option value="${i}">${i + 1}. t/m ${p.toLowerCase()}</option>`).join('');
|
||||||
|
const LAYER_KEYS = ViewLayers.LIST.filter(l => l.key !== 'maten');
|
||||||
|
layerPop.querySelector('.vw-checks').innerHTML = LAYER_KEYS.map(l =>
|
||||||
|
`<label title="${l.hint || ''}"><input type="checkbox" data-layer="${l.key}" checked> ${l.label}</label>`).join('');
|
||||||
|
const syncChecks = () => layerPop.querySelectorAll('[data-layer]').forEach(c => { c.checked = layers[c.dataset.layer] !== false; });
|
||||||
|
function relayer(next, fin) {
|
||||||
|
layers = next; finished = fin;
|
||||||
|
root.classList.add('is-busy');
|
||||||
|
$('.vw-layers-btn').classList.toggle('on', !fin);
|
||||||
|
setTimeout(() => {
|
||||||
|
try { build(); Skybox.apply('day', { env: sky, controls, sun, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: group }); applyView(); }
|
||||||
|
catch (err) { console.error(err); toast('Deze lagen konden niet worden opgebouwd.'); }
|
||||||
|
root.classList.remove('is-busy');
|
||||||
|
}, 20);
|
||||||
|
}
|
||||||
|
phaseSel.addEventListener('change', () => {
|
||||||
|
const v = phaseSel.value;
|
||||||
|
// Bij een bouwfase het dak eraf, zodat je in de woning kijkt (aanzetten kan met de laag Dak).
|
||||||
|
relayer(v === 'af' ? ViewLayers.all() : Object.assign(ViewLayers.upTo(Number(v)), { dak: false, maten: true }), v === 'af');
|
||||||
|
syncChecks();
|
||||||
|
});
|
||||||
|
layerPop.addEventListener('change', (e) => {
|
||||||
|
const c = e.target.closest('[data-layer]');
|
||||||
|
if (!c) return;
|
||||||
|
const next = Object.assign({}, layers, { [c.dataset.layer]: c.checked });
|
||||||
|
const phase = ViewLayers.phaseOf(next);
|
||||||
|
phaseSel.value = LAYER_KEYS.every(l => next[l.key] !== false) ? 'af' : phase >= 0 && phase < ViewLayers.PHASES.length - 1 ? String(phase) : '';
|
||||||
|
relayer(next, phaseSel.value === 'af');
|
||||||
|
});
|
||||||
|
// Uitklappanelen (lagen, PDF): één open; klik ernaast sluit.
|
||||||
|
root.querySelectorAll('[data-pop]').forEach(btn => btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const pop = $(btn.dataset.pop), open = pop.hidden;
|
||||||
|
root.querySelectorAll('.vw-pop').forEach(x => { x.hidden = true; });
|
||||||
|
pop.hidden = !open;
|
||||||
|
}));
|
||||||
|
root.querySelectorAll('.vw-pop').forEach(p => p.addEventListener('click', (e) => e.stopPropagation()));
|
||||||
|
document.addEventListener('click', () => root.querySelectorAll('.vw-pop').forEach(x => { x.hidden = true; }));
|
||||||
|
|
||||||
|
// PDF (werktekening / klant): de PDF-modules van de configurator pas laden als ze nodig zijn.
|
||||||
|
const PDF_SCRIPTS = ['js/vendor/jspdf.umd.min.js', 'js/core/logo-data.js', 'js/core/pdf-sheet.js', 'js/wall/wall-pdf.js',
|
||||||
|
'js/floor/floor-pdf.js', 'js/elektra/elektra-pdf.js', 'js/house/house-pdf.js', 'js/vendor/qrcode.js', 'js/light/light-pdf.js'];
|
||||||
|
let pdfLoading = null;
|
||||||
|
const loadScript = (src) => new Promise((ok, fail) => {
|
||||||
|
const sc = document.createElement('script');
|
||||||
|
sc.src = src; sc.onload = ok; sc.onerror = () => fail(new Error(`${src} niet geladen`));
|
||||||
|
document.head.appendChild(sc);
|
||||||
|
});
|
||||||
|
const loadPdf = () => pdfLoading || (pdfLoading = PDF_SCRIPTS.reduce((p, src) => p.then(() => loadScript(src)), Promise.resolve())
|
||||||
|
.then(() => Promise.all(HousePdf.materialThumbUrls(st).map(url => new Promise(ok => {
|
||||||
|
PdfSheet.preloadImage(url);
|
||||||
|
const img = new Image(); img.onload = img.onerror = ok; img.src = url;
|
||||||
|
})))).then(() => new Promise(ok => setTimeout(ok, 100))));
|
||||||
|
const costsReady = new Promise(ok => {
|
||||||
|
if (CostCsv.status('intern').loadedAt) ok();
|
||||||
|
else { CostCsv.onChange(ok); setTimeout(ok, 6000); }
|
||||||
|
});
|
||||||
|
const PDF_LABEL = { werk: 'Werktekening', klant: 'Klant-PDF' };
|
||||||
|
const pdfs = cfg.pdfs || {};
|
||||||
|
function renderPdfs() {
|
||||||
|
$('.vw-pop-pdf .vw-pdfs').innerHTML = ['werk', 'klant'].map(k => {
|
||||||
|
const f = pdfs[k];
|
||||||
|
return `<div class="vw-pdf"><div><b>${PDF_LABEL[k]}</b><span>${k === 'werk' ? 'Alle tekenbladen, elektra, zaaglijst en kosten (intern)' : 'Zoals de configurator: voorblad, aanzichten, plattegrond en werkwijze (ook in het klantportaal)'}</span>
|
||||||
|
${f ? `<span>Gemaakt ${f.date}</span>` : ''}</div>
|
||||||
|
<div>${f ? `<a href="${f.url}" target="_blank" rel="noopener">Openen</a>` : ''}
|
||||||
|
${cfg.pdfUrl ? `<button type="button" data-make="${k}">${f ? 'Opnieuw' : 'Maken'}</button>` : ''}</div></div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Klant-PDF zoals de light-configurator (js/light/light.js savePdf): LightPdf met beeld, kerncijfers en keuzes.
|
||||||
|
// De keuzes leest de CRM uit het ontwerp zelf (de light-keuzes komen niet mee in de house-design-JSON);
|
||||||
|
// zelfde onderwerpen en woorden als LightModel.describe.
|
||||||
|
const ROOF_NAMES = { plat: 'plat dak', lessenaar: 'lessenaardak', zadel: 'zadeldak', wolf: 'wolfsdak' };
|
||||||
|
const SIDE_NAMES = { voor: 'noord', achter: 'zuid', links: 'west', rechts: 'oost' };
|
||||||
|
const FACADES = { rabat: 'Hout', fels: 'Felsplaten', stuc: 'Stucwerk', steen: 'Steenstrips' };
|
||||||
|
const LIGHTING = { geen: 'Geen', spots: 'Spots in de dakrand', gevel: 'Gevellampen' };
|
||||||
|
const cap = (t) => t ? t.charAt(0).toUpperCase() + t.slice(1) : t;
|
||||||
|
const label = (list, key) => (list && list[key] && list[key].label) || '';
|
||||||
|
function choices() {
|
||||||
|
const R = S.roof, F = S.facade, rows = [];
|
||||||
|
const way = (f) => f.type === 'lessenaar' ? ` (hoog aan de ${SIDE_NAMES[f.dir] || f.dir}kant)`
|
||||||
|
: UnitModel.hasRidge(f.type) && UnitModel.RIDGES[f.ridge] ? ` (nok ${UnitModel.RIDGES[f.ridge].toLowerCase()})` : '';
|
||||||
|
const nok = (f) => f.type === 'plat' ? '' : `, ${f.type === 'lessenaar' ? 'hoge kant' : 'nok'} ${nl(f.high / 1000, 2)} m`;
|
||||||
|
const house = R.house && R.house.enabled && R.house.form;
|
||||||
|
const forms = house ? [R.house.form] : st.units.map(u => UnitModel.roofForm(u.unit));
|
||||||
|
const per = (fn) => { const v = forms.map(fn); return v.every(x => x === v[0]) ? v[0] : v.map((x, i) => `unit ${st.units[i].id}: ${x}`).join(', '); };
|
||||||
|
rows.push(['Dakvorm', cap(per(f => (ROOF_NAMES[f.type] || f.type) + way(f)) + (house ? ' over de hele woning' : ''))]);
|
||||||
|
rows.push(['Hoogte', cap(per(f => `goot ${nl(f.low / 1000, 2)} m${nok(f)}`))]);
|
||||||
|
const covers = [...new Set(st.units.map(u => label(RoofModel.COVERINGS, u.roof.covering) || label(RoofModel.COVERINGS, 'dakleer')))];
|
||||||
|
rows.push(['Dakbedekking', covers.join(', ')]);
|
||||||
|
const color = F.type === 'steen' ? `steen ${F.stone}` : F.type === 'rabat' ? label(WallModel.RABAT_COLORS, F.rabatColor)
|
||||||
|
: F.type === 'stuc' ? label(WallModel.STUC_COLORS, F.stucColor) : label(WallModel.FELS.COLORS, F.felsColor);
|
||||||
|
rows.push(['Gevel', cap([(FACADES[F.type] || F.type || '').toLowerCase(), F.type === 'rabat' ? S.cladding : null, color.toLowerCase()].filter(Boolean).join(', '))]);
|
||||||
|
const tr = S.trasraam || {};
|
||||||
|
rows.push(['Trasraam', tr.enabled ? `Steen ${tr.finish}, ${nl(tr.height / 1000, 2)} m hoog` : 'Geen']);
|
||||||
|
const fascia = st.units.some(u => u.roof.fascia !== false);
|
||||||
|
rows.push(['Dakrand', fascia ? `Boeidelen ${label(RoofModel.FASCIA_COLORS, R.fasciaColor).toLowerCase()}`.trim() : 'Daktrim']);
|
||||||
|
const L = S.lighting || {};
|
||||||
|
rows.push(['Buitenverlichting', (LIGHTING[L.mode] || 'Geen') + (L.mode === 'gevel' && LightingModel.DIRECTIONS[L.direction] ? ` (${LightingModel.DIRECTIONS[L.direction].toLowerCase()})` : '')]);
|
||||||
|
rows.push(['Binnenverlichting', L.indoor ? 'Inbouwspots in het plafond' : 'Geen']);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
// Beeld voor het voorblad: de afgewerkte woning in 3D (ook vanuit doorsnede, plattegrond of een laag), vaste resolutie.
|
||||||
|
function snapshot() {
|
||||||
|
const W = 1600, H = 1500, pr = renderer.getPixelRatio();
|
||||||
|
const keep = { view, layers, finished, pos: camera.position.clone(), target: controls.target.clone() };
|
||||||
|
const rebuild = () => { build(); Skybox.apply('day', { env: sky, controls, sun, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: group }); };
|
||||||
|
if (!finished) { layers = ViewLayers.all(); finished = true; rebuild(); }
|
||||||
|
if (view !== '3d') {
|
||||||
|
view = '3d'; applyView();
|
||||||
|
if (poses['3d']) { camera.position.copy(poses['3d'].pos); controls.target.copy(poses['3d'].target); }
|
||||||
|
}
|
||||||
|
renderer.setPixelRatio(1);
|
||||||
|
renderer.setSize(W, H, false);
|
||||||
|
camera.aspect = W / H;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
controls.update();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
const url = renderer.domElement.toDataURL('image/jpeg', 0.88);
|
||||||
|
renderer.setPixelRatio(pr);
|
||||||
|
if (!keep.finished) { layers = keep.layers; finished = false; rebuild(); }
|
||||||
|
view = keep.view;
|
||||||
|
applyView();
|
||||||
|
camera.position.copy(keep.pos);
|
||||||
|
controls.target.copy(keep.target);
|
||||||
|
controls.update();
|
||||||
|
resize();
|
||||||
|
return { url, width: W, height: H };
|
||||||
|
}
|
||||||
|
function customerPdf() {
|
||||||
|
const raw = JSON.parse(document.getElementById('design').textContent);
|
||||||
|
const key = raw.model || '', model = (HouseModel.MODELS || {})[key];
|
||||||
|
const name = (st.project && st.project.naam) || (model && model.label) || 'Uw woning';
|
||||||
|
// Afgewerkte woning met 3D, zoals light.js: met skip3D hebben de wandstukken geen specs (gewicht) en faalt computeTotals.
|
||||||
|
const full = HouseModel.computeParts(st, Object.assign({ layers: ViewLayers.all(), finishedOnly: true }, mats));
|
||||||
|
HouseModel.rootGroups(full).forEach(disposeGeo);
|
||||||
|
const totals = HouseModel.computeTotals(full), d = HouseModel.computeDimensions(full, S.groundLevel);
|
||||||
|
const info = {
|
||||||
|
price: CostModel.compute(full, totals).summary.inclBtw, kg: totals.totalKg, units: st.units.length,
|
||||||
|
lengthX: d.lengthX, lengthZ: d.lengthZ, height: d.height, brutoM2: d.brutoM2, leefbaarM2: d.leefbaarM2, inhoudM3: d.inhoudM3
|
||||||
|
};
|
||||||
|
let image = null;
|
||||||
|
try { image = snapshot(); } catch (err) { console.warn(err); }
|
||||||
|
const pdfSt = HouseModel.clone(st);
|
||||||
|
pdfSt.project = Object.assign({}, pdfSt.project, { naam: name, opmerking: (pdfSt.project && pdfSt.project.opmerking) || 'Indicatief ontwerp' });
|
||||||
|
return LightPdf.generate({ st: pdfSt, label: name, info, choices: choices(), url: LightPdf.modelUrl(key), image });
|
||||||
|
}
|
||||||
|
|
||||||
|
let making = Promise.resolve();
|
||||||
|
function makePdf(kind) {
|
||||||
|
making = making.then(() => {
|
||||||
|
toast(`${PDF_LABEL[kind]} wordt gemaakt…`);
|
||||||
|
return Promise.all([loadPdf(), costsReady]);
|
||||||
|
}).then(() => new Promise(ok => setTimeout(ok, 30))).then(() => {
|
||||||
|
const doc = kind === 'klant' ? customerPdf() : HousePdf.generate(st);
|
||||||
|
const body = new FormData();
|
||||||
|
body.append('kind', kind);
|
||||||
|
body.append('pdf', doc.output('blob'), `${kind}.pdf`);
|
||||||
|
return fetch(cfg.pdfUrl, { method: 'POST', body, credentials: 'same-origin' }).then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)));
|
||||||
|
}).then(res => {
|
||||||
|
pdfs[kind] = res;
|
||||||
|
renderPdfs();
|
||||||
|
toast(`${PDF_LABEL[kind]} opgeslagen bij het contact.`);
|
||||||
|
if (window.parent !== window) window.parent.postMessage({ type: 'tectum-pdf', kind, url: res.url, date: res.date }, location.origin);
|
||||||
|
}).catch(err => { console.error(err); toast(`${PDF_LABEL[kind]} maken lukte niet: ${err.message}`); });
|
||||||
|
return making;
|
||||||
|
}
|
||||||
|
$('.vw-pop-pdf').addEventListener('click', (e) => { const b = e.target.closest('[data-make]'); if (b) makePdf(b.dataset.make); });
|
||||||
|
renderPdfs();
|
||||||
|
// Direct aanwezig: ontbrekende PDF's maken zodra de weergave staat.
|
||||||
|
if (cfg.pdfUrl && cfg.auto) setTimeout(() => ['werk', 'klant'].filter(k => !pdfs[k]).forEach(makePdf), 1500);
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
const w = stage.clientWidth, h = stage.clientHeight;
|
||||||
|
if (!w || !h) return;
|
||||||
|
camera.aspect = w / h;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(w, h);
|
||||||
|
}
|
||||||
|
new ResizeObserver(() => { resize(); if (view === 'plan') drawPlan(); }).observe(stage);
|
||||||
|
resize();
|
||||||
|
applyView();
|
||||||
|
root.classList.remove('is-busy');
|
||||||
|
(function loop() {
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
if (view !== 'plan' && !document.hidden) renderer.render(scene, camera);
|
||||||
|
})();
|
||||||
|
})();
|
||||||
@@ -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>
|
||||||
+38
-6
@@ -2,12 +2,44 @@
|
|||||||
{% 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="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="fields">
|
<div class="row g-3">
|
||||||
<div class="full"><label for="old">Huidig wachtwoord</label><input 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>
|
||||||
<div class="full"><label for="new">Nieuw wachtwoord</label><input id="new" type="password" name="new_password" minlength="8" required autocomplete="new-password"></div>
|
<div class="col-12"><label class="form-label" for="new">Nieuw wachtwoord</label><input class="form-control" id="new" type="password" name="new_password" minlength="8" required autocomplete="new-password"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions"><button class="btn">Opslaan</button></div>
|
<div class="actions"><button class="btn btn-primary">Opslaan</button></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 %}
|
||||||
|
|||||||
+17
-13
@@ -2,32 +2,36 @@
|
|||||||
{% set titles = {'setup': 'Welkom', 'login': 'Inloggen', 'portal_login': 'Klantportaal', 'invite': 'Wachtwoord instellen'} %}
|
{% set titles = {'setup': 'Welkom', 'login': 'Inloggen', 'portal_login': 'Klantportaal', 'invite': 'Wachtwoord instellen'} %}
|
||||||
{% block title %}{{ titles[mode] }}{% endblock %}
|
{% block title %}{{ titles[mode] }}{% endblock %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
|
<div class="auth-wrap">
|
||||||
<div class="auth">
|
<div class="auth">
|
||||||
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous">
|
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous">
|
||||||
{% with msgs = get_flashed_messages(with_categories=true) %}
|
{% with msgs = get_flashed_messages(with_categories=true) %}
|
||||||
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
|
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<h1>{{ titles[mode] }}</h1>
|
<h1>{{ titles[mode] }}</h1>
|
||||||
|
<div class="row g-3">
|
||||||
{% if mode == 'setup' %}
|
{% if mode == 'setup' %}
|
||||||
<p class="muted">Maak het eerste beheerdersaccount aan.</p>
|
<p class="muted mb-0">Maak het eerste beheerdersaccount aan.</p>
|
||||||
<label for="name">Naam</label><input id="name" name="name" required autofocus>
|
<div class="col-12"><label class="form-label" for="name">Naam</label><input class="form-control" id="name" name="name" required autofocus></div>
|
||||||
<label for="email" style="margin-top:1rem">E-mailadres</label><input id="email" type="email" name="email" required>
|
<div class="col-12"><label class="form-label" for="email">E-mailadres</label><input class="form-control" id="email" type="email" name="email" required></div>
|
||||||
{% elif mode == 'invite' %}
|
{% elif mode == 'invite' %}
|
||||||
<p class="muted">Welkom {{ c.name }}. Kies een wachtwoord voor het klantportaal. Daarna logt u in met {{ c.email }}.</p>
|
<p class="muted mb-0">Welkom {{ c.name }}. Kies een wachtwoord voor het klantportaal. Daarna logt u in met {{ c.email }}.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if mode == 'portal_login' %}<p class="muted">Bekijk uw offertes en deel bestanden met ons.</p>{% endif %}
|
{% if mode == 'portal_login' %}<p class="muted mb-0">Bekijk uw offertes en deel bestanden met ons.</p>{% endif %}
|
||||||
<label for="email">E-mailadres</label><input id="email" type="email" name="email" required autofocus>
|
<div class="col-12"><label class="form-label" for="email">E-mailadres</label><input class="form-control" id="email" type="email" name="email" required autofocus autocomplete="username"></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<label for="pw" style="margin-top:1rem">Wachtwoord</label>
|
<div class="col-12"><label class="form-label" for="pw">Wachtwoord</label>
|
||||||
<input id="pw" type="password" name="password" {{ 'minlength=8' if mode in ('setup', 'invite') }} required>
|
<input class="form-control" id="pw" type="password" name="password" {{ 'minlength=8' if mode in ('setup', 'invite') }} required autocomplete="{{ 'new-password' if mode in ('setup', 'invite') else 'current-password' }}"></div>
|
||||||
{% if mode in ('setup', 'invite') %}
|
{% if mode in ('setup', 'invite') %}
|
||||||
<label for="pw2" style="margin-top:1rem">Herhaal wachtwoord</label>
|
<div class="col-12"><label class="form-label" for="pw2">Herhaal wachtwoord</label>
|
||||||
<input id="pw2" type="password" name="password2" minlength="8" required>
|
<input class="form-control" id="pw2" type="password" name="password2" minlength="8" required autocomplete="new-password"></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="actions"><button class="btn">{{ 'Inloggen' if mode in ('login', 'portal_login') else 'Opslaan en doorgaan' }}</button></div>
|
</div>
|
||||||
|
<div class="actions"><button class="btn btn-primary w-100">{{ 'Inloggen' if mode in ('login', 'portal_login') else 'Opslaan en doorgaan' }}</button></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+106
-26
@@ -3,10 +3,12 @@
|
|||||||
<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>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Kantumruy+Pro:wght@500;600&family=Mukta:wght@400;600;800&display=swap" rel="stylesheet">
|
<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') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
<script>
|
<script>
|
||||||
/* inhoud verbergen tot de GSAP-intro klaar staat; na 3 s altijd zichtbaar */
|
/* inhoud verbergen tot de GSAP-intro klaar staat; na 3 s altijd zichtbaar */
|
||||||
@@ -15,40 +17,118 @@
|
|||||||
setTimeout(() => document.documentElement.classList.remove('fx-wait'), 3000);
|
setTimeout(() => document.documentElement.classList.remove('fx-wait'), 3000);
|
||||||
}
|
}
|
||||||
</script>
|
</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 %}
|
||||||
{% set ep = request.endpoint or '' %}
|
{% set ep = request.endpoint or '' %}
|
||||||
|
<div class="app">
|
||||||
<aside class="side">
|
<aside class="side">
|
||||||
<a href="{{ url_for('dashboard') }}" class="brand"><img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous"></a>
|
<div class="offcanvas-lg offcanvas-start side-panel" id="sidenav" tabindex="-1" aria-label="Menu">
|
||||||
{% set ctype = request.args.get('type') or (c.type if c is defined and c and c.type is defined else '') %}
|
<div class="offcanvas-header">
|
||||||
{% set rkind = request.args.get('kind') or (r.kind if r is defined and r and r.kind is defined else '') %}
|
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous">
|
||||||
<nav>
|
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#sidenav" aria-label="Menu sluiten"></button>
|
||||||
<a href="{{ url_for('dashboard') }}" class="{{ 'active' if ep == 'dashboard' }}">Dashboard</a>
|
</div>
|
||||||
<a href="{{ url_for('contacts', type='lead') }}" class="{{ 'active' if ep.startswith('contact') and ctype == 'lead' }}">Leads</a>
|
<div class="offcanvas-body">
|
||||||
<a href="{{ url_for('relations', kind='klant') }}" class="{{ 'active' if ep.startswith('relation') and rkind == 'klant' }}">Klanten</a>
|
<a href="{{ url_for('dashboard') }}" class="brand d-none d-lg-block"><img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous"></a>
|
||||||
<a href="{{ url_for('relations', kind='partner') }}" class="{{ 'active' if ep.startswith('relation') and rkind == 'partner' }}">Partners</a>
|
{% set ctype = request.args.get('type') or (c.type if c is defined and c and c.type is defined else '') %}
|
||||||
<a href="{{ url_for('contacts') }}" class="{{ 'active' if ep.startswith('contact') and ctype != 'lead' }}">Contactpersonen</a>
|
{% set rkind = request.args.get('kind') or (r.kind if r is defined and r and r.kind is defined else '') %}
|
||||||
<a href="{{ url_for('inbox') }}" class="{{ 'active' if ep.startswith('inbox') }}">Inbox {% if unread %}<span class="count">{{ unread }}</span>{% endif %}</a>
|
<nav>
|
||||||
<a href="{{ url_for('products') }}" class="{{ 'active' if ep.startswith('product') }}">Producten</a>
|
<a href="{{ url_for('dashboard') }}" class="{{ 'active' if ep == 'dashboard' }}">Dashboard</a>
|
||||||
<a href="{{ url_for('quotes') }}" class="{{ 'active' if ep.startswith('quote') }}">Offertes</a>
|
<a href="{{ url_for('contacts', type='lead') }}" class="{{ 'active' if ep.startswith('contact') and ctype == 'lead' }}">Leads</a>
|
||||||
<a href="{{ url_for('import_start') }}" class="{{ 'active' if ep.startswith('import') }}">Importeren</a>
|
<a href="{{ url_for('relations', kind='klant') }}" class="{{ 'active' if ep.startswith('relation') and rkind == 'klant' }}">Klanten</a>
|
||||||
{% if g.user.role == 'admin' %}
|
<a href="{{ url_for('relations', kind='partner') }}" class="{{ 'active' if ep.startswith('relation') and rkind == 'partner' }}">Partners</a>
|
||||||
<a href="{{ url_for('users') }}" class="{{ 'active' if ep.startswith('user') }}">Gebruikers</a>
|
<a href="{{ url_for('contacts') }}" class="{{ 'active' if ep.startswith('contact') and ctype != 'lead' }}">Contactpersonen</a>
|
||||||
<a href="{{ url_for('settings') }}" class="{{ 'active' if ep == 'settings' }}">Instellingen</a>
|
<a href="{{ url_for('calendar_view') }}" class="{{ 'active' if ep.startswith('calendar') }}">Agenda</a>
|
||||||
{% endif %}
|
<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>
|
||||||
</nav>
|
<a href="{{ url_for('mail_templates') }}" class="{{ 'active' if ep.startswith('mail_template') }}">E-mailtemplates</a>
|
||||||
<a href="{{ url_for('account') }}" class="logout">{{ g.user.name }}</a>
|
<a href="{{ url_for('campaigns') }}" class="{{ 'active' if ep.startswith('campaign') or ep.startswith('segment') }}">Nieuwsbrieven</a>
|
||||||
<a href="{{ url_for('logout') }}" class="logout">Uitloggen</a>
|
<a href="{{ url_for('products') }}" class="{{ 'active' if ep.startswith('product') }}">Producten</a>
|
||||||
|
<a href="{{ url_for('quotes') }}" class="{{ 'active' if ep.startswith('quote') }}">Offertes</a>
|
||||||
|
<a href="{{ url_for('import_start') }}" class="{{ 'active' if ep.startswith('import') }}">Importeren</a>
|
||||||
|
{% if g.user.role == 'admin' %}
|
||||||
|
<a href="{{ url_for('users') }}" class="{{ 'active' if ep.startswith('user') }}">Gebruikers</a>
|
||||||
|
<a href="{{ url_for('routines') }}" class="{{ 'active' if ep.startswith('routine') }}">Routines</a>
|
||||||
|
<a href="{{ url_for('settings') }}" class="{{ 'active' if ep == 'settings' or ep.startswith('mailbox') }}">Instellingen</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
<a href="{{ url_for('account') }}" class="logout">{{ g.user.name }}</a>
|
||||||
|
<a href="{{ url_for('logout') }}" class="logout">Uitloggen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<main>
|
<div class="app-main">
|
||||||
{% with msgs = get_flashed_messages(with_categories=true) %}
|
<header class="topbar">
|
||||||
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
|
<button class="menu-btn d-lg-none" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidenav" aria-controls="sidenav">
|
||||||
{% endwith %}
|
<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
|
||||||
{% block content %}{% endblock %}
|
</button>
|
||||||
</main>
|
<a href="{{ url_for('dashboard') }}" class="topbar-logo d-lg-none"><img src="{{ url_for('static', filename='logo.svg') }}" alt="Van Emous"></a>
|
||||||
|
<div class="topbar-tools">
|
||||||
|
{% set sq = request.args.get('q', '') if ep == 'search' else '' %}
|
||||||
|
{% macro search_form(id, sq) %}
|
||||||
|
<form class="tsearch" action="{{ url_for('search') }}" method="get" role="search" data-live="{{ url_for('search_live') }}">
|
||||||
|
<svg class="tsearch-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
|
||||||
|
<input class="form-control" id="{{ id }}" type="search" name="q" value="{{ sq }}" placeholder="Zoek lead, klant, offerte…" autocomplete="off" aria-label="Zoeken" aria-controls="{{ id }}-results" aria-autocomplete="list">
|
||||||
|
<div class="tsearch-results" id="{{ id }}-results" role="listbox" aria-label="Zoekresultaten"></div>
|
||||||
|
</form>
|
||||||
|
{% endmacro %}
|
||||||
|
<div class="d-none d-md-block">{{ search_form('q-top', sq) }}</div>
|
||||||
|
<div class="dropdown d-md-none">
|
||||||
|
<button class="icon-btn" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false" aria-label="Zoeken">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu dropdown-menu-end tsearch-pop">{{ search_form('q-pop', sq) }}</div>
|
||||||
|
</div>
|
||||||
|
{% if unread %}<a href="{{ url_for('inbox') }}" class="count d-lg-none" aria-label="{{ unread }} ongelezen e-mail">{{ unread }}</a>{% endif %}
|
||||||
|
<div class="dropdown bell" data-poll="{{ url_for('notifications_poll') }}" data-last="{{ bell[0].id if bell else 0 }}">
|
||||||
|
<button class="icon-btn" type="button" data-bs-toggle="dropdown" aria-expanded="false" aria-label="Meldingen{% if bell_unread %}: {{ bell_unread }} ongelezen{% endif %}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
|
||||||
|
<span class="bell-count" {{ 'hidden' if not bell_unread }}>{{ bell_unread }}</span>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu dropdown-menu-end bell-menu">
|
||||||
|
<div class="bell-head"><b>Meldingen</b>
|
||||||
|
{% if bell_unread %}<form method="post" action="{{ url_for('notifications_read_all') }}" class="inline"><button class="btn btn-link btn-sm p-0">Alles gelezen</button></form>{% endif %}</div>
|
||||||
|
{% for n in bell %}
|
||||||
|
<a class="dropdown-item {{ 'unread' if not n.read_at }}" href="{{ url_for('notification_open', nid=n.id) }}"><b>{{ n.title }}</b>{% if n.body %}<span>{{ n.body }}</span>{% endif %}<small>{{ n.created_at|dt }}</small></a>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty px-3">Geen meldingen.</p>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="bell-foot"><a href="{{ url_for('notifications') }}">Alle meldingen</a><button type="button" class="btn btn-link btn-sm p-0 bell-desktop" hidden>Bureaubladmeldingen aanzetten</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-primary btn-sm new-btn" type="button" data-bs-toggle="dropdown" aria-expanded="false" aria-label="Nieuw">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg><span class="d-none d-sm-inline">Nieuw</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end new-menu">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('contact_new', type='lead') }}">Lead</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('contact_new', type='klant') }}">Contactpersoon</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('relation_edit', kind='klant') }}">Klant</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('relation_edit', kind='partner') }}">Partner</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><form method="post" action="{{ url_for('quote_new') }}"><button class="dropdown-item">Offerte</button></form></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('product_edit') }}">Product</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('calendar_edit') }}">Afspraak</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('mail_new') }}">E-mail</a></li>
|
||||||
|
<li><form method="post" action="{{ url_for('campaign_new') }}"><button class="dropdown-item">Nieuwsbrief</button></form></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="content">
|
||||||
|
{% with msgs = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block head %}
|
||||||
|
{# De engine haalt kosten/*.csv en assets/ relatief op: base = de gekopieerde configurator. Alle CRM-links zijn absoluut. #}
|
||||||
|
<base href="{{ url_for('static', filename='configurator/') }}">
|
||||||
|
{% endblock %}
|
||||||
|
{% block title %}Bestek · Offerte {{ qt.number }}{% endblock %}
|
||||||
|
{% block styles %}
|
||||||
|
<style>
|
||||||
|
.bestek-page { max-width: 980px; }
|
||||||
|
.bs-ch { margin-bottom: 1.5rem; }
|
||||||
|
.bs-ch-head { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; border-bottom: 2px solid var(--ink); padding-bottom: 0.3rem; margin-bottom: 0.25rem; }
|
||||||
|
.bs-ch-head h2 { margin: 0; font-size: 1.1rem; }
|
||||||
|
.bs-ch table { width: 100%; border-collapse: collapse; }
|
||||||
|
.bs-ch td { padding: 0.35rem 0.25rem; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||||
|
.bs-ch td.num { text-align: right; white-space: nowrap; width: 9rem; }
|
||||||
|
.bs-info td { color: var(--ink-muted); font-style: italic; }
|
||||||
|
.bs-ch td.num.muted { width: 7rem; }
|
||||||
|
.bs-ch .chip { text-transform: none; letter-spacing: 0; font-weight: 400; font-size: 0.7rem; margin-left: 0.3rem; }
|
||||||
|
.bs-total { margin-left: auto; width: 320px; max-width: 100%; }
|
||||||
|
.bs-total div { display: flex; justify-content: space-between; padding: 0.2rem 0; }
|
||||||
|
.bs-total .grand { border-top: 2px solid var(--ink); margin-top: 0.3rem; padding-top: 0.5rem; font-family: var(--font-heading); font-size: 1.15rem; }
|
||||||
|
.bs-opts .form-check { margin-bottom: 0.4rem; }
|
||||||
|
@media (max-width: 640px) { .bs-ch td.num { width: auto; } }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="bestek-page">
|
||||||
|
<div class="head">
|
||||||
|
<div>
|
||||||
|
<a class="label" href="{{ url_for('quote_edit', qid=qt.id) }}">← Offerte {{ qt.number }}</a>
|
||||||
|
<h1>Bestek uit ontwerp</h1>
|
||||||
|
<div class="muted">{{ design_file.title }}{% if c %} · {{ c.name }}{% endif %} · ontvangen {{ design_file.created_at|dt }}</div>
|
||||||
|
</div>
|
||||||
|
{% if designs|length > 1 %}
|
||||||
|
<form method="get" class="actions" action="{{ url_for('quote_bestek', qid=qt.id) }}">
|
||||||
|
<select class="form-select w-auto" name="file" onchange="this.form.submit()" aria-label="Ontwerp">
|
||||||
|
{% for d in designs %}<option value="{{ d.id }}" {{ 'selected' if d.id == design_file.id }}>{{ d.title }} · {{ d.created_at|dt('%d-%m-%Y') }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bestek">
|
||||||
|
<div class="card"><div class="card-body">
|
||||||
|
<p class="muted">Hoeveelheden en prijzen uit de configurator (zelfde berekening als Kosten & tijden: kostprijs met
|
||||||
|
algemene kosten en marge, prijzen uit de kosten-CSV's). Bedragen excl. btw. Schroeven, zaag- en boorwerk staan per hoofdstuk samen onder "Bevestiging en bewerking".
|
||||||
|
Posten met een code worden gekoppeld aan het product met die SKU (bestaat het nog niet, dan wordt het aangemaakt).</p>
|
||||||
|
<div class="bs-body">{% if engine %}<p class="empty">Bestek berekenen…</p>{% else %}<p class="empty">De 3D-engine van de configurator ontbreekt. Draai tools/configurator-sync.sh in de CRM-map.</p>{% endif %}</div>
|
||||||
|
<div class="bs-total"></div>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('quote_bestek_save', qid=qt.id) }}" class="card bs-opts"><div class="card-body">
|
||||||
|
<input type="hidden" name="bestek">
|
||||||
|
<input type="hidden" name="file_id" value="{{ design_file.id }}">
|
||||||
|
<h2>In de offerte zetten</h2>
|
||||||
|
<div class="form-check"><input class="form-check-input" type="radio" id="mode-post" name="mode" value="post" checked><label class="form-check-label" for="mode-post"><strong>Per post</strong>: elke regel van het bestek als offerteregel met aantal en prijs per eenheid (bijv. 42 st. × prijs per spot)</label></div>
|
||||||
|
<div class="form-check"><input class="form-check-input" type="radio" id="mode-ch" name="mode" value="hoofdstuk"><label class="form-check-label" for="mode-ch"><strong>Per hoofdstuk</strong>: één regel per hoofdstuk (1 post) met de posten in de omschrijving</label></div>
|
||||||
|
<div class="form-check"><input class="form-check-input" type="checkbox" id="opt-replace" name="replace" value="1" {{ 'checked' if has_lines }}><label class="form-check-label" for="opt-replace">Bestaande regels van de offerte vervangen{% if has_lines %} ({{ has_lines }} regel{{ 's' if has_lines != 1 }}){% endif %}</label></div>
|
||||||
|
<div class="form-check"><input class="form-check-input" type="checkbox" id="opt-prices" name="update_prices" value="1"><label class="form-check-label" for="opt-prices">Prijzen van bestaande producten bijwerken naar deze berekening (wordt vastgelegd in de producthistorie)</label></div>
|
||||||
|
<p class="hint">Het bestek (alle posten met hoeveelheden) komt als bijlage bij de offerte, ook in de afdrukversie en het klantportaal.</p>
|
||||||
|
<div class="actions"><button class="btn btn-primary" type="submit" disabled>In offerte zetten</button>
|
||||||
|
<a class="btn btn-outline-secondary" href="{{ url_for('quote_edit', qid=qt.id) }}">Annuleren</a></div>
|
||||||
|
</div></form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
{% if engine %}
|
||||||
|
<script type="application/json" id="design">{{ design|tojson }}</script>
|
||||||
|
<script type="application/json" id="bestek-meta">{{ {'design': {'file_id': design_file.id, 'title': design_file.design_title or design_file.title}, 'configurator': design.get('configurator') or ''}|tojson }}</script>
|
||||||
|
{% for src in scripts %}<script src="{{ src }}"></script>
|
||||||
|
{% endfor %}<script src="{{ url_for('static', filename='bestek.js') }}"></script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -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 %}
|
||||||
+155
-108
@@ -1,25 +1,8 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% block title %}{{ c.name }}{% if c.type == 'lead' %}
|
{% from '_compose.html' import compose %}
|
||||||
<dialog id="convert" class="dialog">
|
{% from '_documents.html' import documents_list with context %}
|
||||||
<form method="post" action="{{ url_for('contact_convert', cid=c.id) }}">
|
{% from '_kaart.html' import kaart_block %}
|
||||||
<h2>Omzetten naar klant</h2>
|
{% block title %}{{ c.name }}{% endblock %}
|
||||||
<p class="muted">De contactpersoon komt in een klantrelatie, bijvoorbeeld een huishouden of familie.</p>
|
|
||||||
<label><input type="radio" name="mode" value="new" checked onchange="this.form.relation_id.disabled=true;this.form.new_relation.disabled=false"> Nieuwe klantrelatie</label>
|
|
||||||
<input name="new_relation" value="{{ c.company or ('Familie ' ~ c.name.split(' ')[-1]) }}" aria-label="Naam relatie" style="margin:.4rem 0 1rem">
|
|
||||||
{% if klant_relations %}
|
|
||||||
<label><input type="radio" name="mode" value="existing" onchange="this.form.relation_id.disabled=false;this.form.new_relation.disabled=true"> Bestaande klantrelatie</label>
|
|
||||||
<select name="relation_id" disabled aria-label="Bestaande relatie" style="margin-top:.4rem">
|
|
||||||
{% for r in klant_relations %}<option value="{{ r.id }}">{{ r.name }}{% if r.type %} · {{ r.type }}{% endif %}</option>{% endfor %}
|
|
||||||
</select>
|
|
||||||
{% endif %}
|
|
||||||
<div class="actions">
|
|
||||||
<button class="btn">Omzetten</button>
|
|
||||||
<button class="btn quiet" type="button" onclick="this.closest('dialog').close()">Annuleren</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<div>
|
<div>
|
||||||
@@ -30,24 +13,93 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<form method="post" action="{{ url_for('contact_status', cid=c.id) }}" class="inline">
|
<form method="post" action="{{ url_for('contact_status', cid=c.id) }}" class="inline">
|
||||||
<select name="status" onchange="this.form.submit()" aria-label="Status" style="width:auto">
|
<select class="form-select w-auto" name="status" onchange="this.form.submit()" aria-label="Status">
|
||||||
{% for k, l in STATUSES %}<option value="{{ k }}" {{ 'selected' if c.status == k }}>{{ l }}</option>{% endfor %}
|
{% for k, l in STATUSES %}<option value="{{ k }}" {{ 'selected' if c.status == k }}>{{ l }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</form>
|
</form>
|
||||||
{% if c.type == 'lead' %}
|
{% if c.type == 'lead' %}
|
||||||
<button class="btn ghost" type="button" onclick="document.getElementById('convert').showModal()">Omzetten naar klant</button>
|
<button class="btn btn-outline-primary" type="button" onclick="document.getElementById('convert').showModal()">Omzetten naar klant</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" action="{{ url_for('quote_new') }}" class="inline">
|
<form method="post" action="{{ url_for('quote_new') }}" class="inline">
|
||||||
<input type="hidden" name="contact_id" value="{{ c.id }}">
|
<input type="hidden" name="contact_id" value="{{ c.id }}">
|
||||||
<button class="btn ghost">Nieuwe offerte</button>
|
<button class="btn btn-outline-primary">Nieuwe offerte</button>
|
||||||
</form>
|
</form>
|
||||||
<a class="btn" href="{{ url_for('contact_edit', cid=c.id) }}">Bewerken</a>
|
<a class="btn btn-primary" href="{{ url_for('contact_edit', cid=c.id) }}">Bewerken</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="two">
|
<div class="row gx-4 stack">
|
||||||
<div>
|
<div class="col-lg-7 col-xl-8">
|
||||||
<div class="card">
|
{% if woningen %}
|
||||||
|
<div class="card"><div class="card-body">
|
||||||
|
<div class="card-top">
|
||||||
|
<h2>Woning</h2>
|
||||||
|
<div class="actions">
|
||||||
|
{% if woningen|length > 1 or not woning %}
|
||||||
|
<form method="post" action="{{ url_for('contact_design', cid=c.id) }}" class="inline">
|
||||||
|
<select class="form-select form-select-sm w-auto" name="document_id" onchange="this.form.submit()" aria-label="Andere woning kiezen">
|
||||||
|
{% if not woning %}<option value="">Kies een woning…</option>{% endif %}
|
||||||
|
{% for d in woningen %}<option value="{{ d.id }}" {{ 'selected' if woning and d.id == woning.id }}>{{ d.title }} · {{ d.updated_at|dt('%d-%m-%Y') }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if woning %}
|
||||||
|
{% if woning.versions|length > 1 %}
|
||||||
|
<form method="get" action="{{ url_for('contact_view', cid=c.id) }}" class="inline">
|
||||||
|
<select class="form-select form-select-sm w-auto" name="versie" onchange="this.form.submit()" aria-label="Versie">
|
||||||
|
{% for v in woning.versions %}<option value="{{ v.id }}" {{ 'selected' if v.id == shown.id }}>Versie {{ v.version }}{{ ' (nieuwste)' if loop.first }} · {{ v.created_at|dt('%d-%m-%Y') }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="{{ url_for('quote_new') }}" class="inline">
|
||||||
|
<input type="hidden" name="contact_id" value="{{ c.id }}"><input type="hidden" name="bestek" value="1">
|
||||||
|
<button class="btn btn-primary btn-sm">Offerte met bestek</button>
|
||||||
|
</form>
|
||||||
|
{% if tectum_url %}<a class="btn btn-outline-primary btn-sm" href="{{ tectum_url }}" target="_blank" title="Openen in Tectum; Opslaan in CRM maakt een nieuwe versie">Bewerken in Tectum</a>{% endif %}
|
||||||
|
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('design_viewer', fid=shown.id) }}" target="_blank">Openen</a>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('upload_file', name=shown.stored) }}" download="{{ shown.original }}">JSON</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if woning %}
|
||||||
|
<div class="viewer-frame"><iframe src="{{ url_for('design_viewer', fid=shown.id) }}" title="3D-weergave {{ woning.title }}" loading="lazy" allowfullscreen></iframe></div>
|
||||||
|
<p class="muted" style="margin:.75rem 0 0"><strong style="color:var(--ink)">{{ woning.title }}</strong>
|
||||||
|
· versie {{ shown.version }}{% if shown.id != woning.latest.id %} (nieuwste: {{ woning.latest.version }}){% endif %}{% if shown.note %} · {{ shown.note }}{% endif %}
|
||||||
|
· {{ design_meta.units }} unit{{ 's' if design_meta.units != 1 }}{% if design_meta.address %} · {{ design_meta.address }}{% endif %}
|
||||||
|
· {{ 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 shown.id == woning.latest.id %}
|
||||||
|
<p class="pdf-links" style="margin:.5rem 0 0">Tekeningen van deze versie:
|
||||||
|
{% for k, label in PDF_KINDS.items() %}{% set p = pdfs.get(k) if pdfs.get(k) and pdfs.get(k).current else None %}
|
||||||
|
<a data-pdf="{{ k }}" href="{{ p.url if p else '#' }}" target="_blank" {{ 'hidden' if not p }}>{{ (label[:1]|upper) ~ label[1:] }}</a><span class="muted" data-pdf-wait="{{ k }}" {{ 'hidden' if p }}>{{ label }} wordt gemaakt…</span>{% if k == 'werk' %} <span class="muted">(intern)</span> ·{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<span class="muted">· opnieuw maken via PDF in de weergave</span></p>
|
||||||
|
<script>
|
||||||
|
addEventListener('message', (e) => {
|
||||||
|
if (e.origin !== location.origin || !e.data || e.data.type !== 'tectum-pdf') return;
|
||||||
|
const a = document.querySelector(`[data-pdf="${e.data.kind}"]`), w = document.querySelector(`[data-pdf-wait="${e.data.kind}"]`);
|
||||||
|
if (a) { a.href = e.data.url; a.hidden = false; }
|
||||||
|
if (w) w.hidden = true;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
<details style="margin-top:1rem">
|
||||||
|
<summary style="font-size:1rem">Nieuwe versie uploaden</summary>
|
||||||
|
<form method="post" action="{{ url_for('document_version', did=woning.id) }}" enctype="multipart/form-data" class="row g-2">
|
||||||
|
<div class="col-md-6"><input class="form-control" type="file" name="file" accept=".json,application/json" required aria-label="Woningontwerp (JSON)"></div>
|
||||||
|
<div class="col-md"><input class="form-control" name="note" placeholder="Wat is er veranderd?" aria-label="Opmerking"></div>
|
||||||
|
<div class="col-md-auto"><button class="btn btn-outline-primary">Opslaan als versie {{ woning.latest.version + 1 }}</button></div>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted" style="margin:0">Er is geen woning gekozen.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if kaart %}{{ kaart_block(kaart) }}{% endif %}
|
||||||
|
|
||||||
|
<div class="card o-2"><div class="card-body">
|
||||||
<h2>Taken</h2>
|
<h2>Taken</h2>
|
||||||
{% for t in tasks %}
|
{% for t in tasks %}
|
||||||
<form method="post" action="{{ url_for('task_done', tid=t.id) }}" class="task {{ 'done' if t.done }}">
|
<form method="post" action="{{ url_for('task_done', tid=t.id) }}" class="task {{ 'done' if t.done }}">
|
||||||
@@ -56,46 +108,54 @@
|
|||||||
<div class="muted">{{ t.user_name or '' }}{% if t.due_date %} · <span class="{{ 'overdue' if not t.done and t.due_date < today }}">{{ t.due_date|dt('%d-%m-%Y') }}</span>{% endif %}</div></div>
|
<div class="muted">{{ t.user_name or '' }}{% if t.due_date %} · <span class="{{ 'overdue' if not t.done and t.due_date < today }}">{{ t.due_date|dt('%d-%m-%Y') }}</span>{% endif %}</div></div>
|
||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<form method="post" action="{{ url_for('contact_task', cid=c.id) }}" style="margin-top:1rem">
|
<form method="post" action="{{ url_for('contact_task', cid=c.id) }}" class="row g-2 mt-2">
|
||||||
<div class="inline-fields">
|
<div class="col-12 col-md"><input class="form-control" name="title" placeholder="Bijv. terugbellen, offerte nabellen" required aria-label="Taak"></div>
|
||||||
<input name="title" placeholder="Bijv. terugbellen, offerte nabellen" required aria-label="Taak">
|
<div class="col-6 col-md-auto"><input class="form-control" type="date" name="due_date" aria-label="Datum"></div>
|
||||||
<input type="date" name="due_date" aria-label="Datum">
|
<div class="col-6 col-md-auto">
|
||||||
</div>
|
<select class="form-select" name="user_id" aria-label="Voor">
|
||||||
<div class="actions" style="margin-top:.5rem">
|
|
||||||
<select name="user_id" style="width:auto" aria-label="Voor">
|
|
||||||
{% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if u.id == g.user.id }}>{{ u.name }}</option>{% endfor %}
|
{% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if u.id == g.user.id }}>{{ u.name }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<button class="btn ghost small">Taak toevoegen</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-12 col-md-auto"><button class="btn btn-outline-primary w-100">Taak toevoegen</button></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div></div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
|
<div class="card-top"><h2>Afspraken</h2>
|
||||||
|
<div class="actions"><a class="btn btn-outline-primary btn-sm" href="{{ url_for('calendar_edit', contact_id=c.id, next=request.path) }}">Afspraak plannen</a></div></div>
|
||||||
|
{% for i in appointments %}
|
||||||
|
<div class="agenda-row"><div class="when">{{ i.when }}</div>
|
||||||
|
<div><a href="{{ url_for('calendar_edit', eid=i.id, next=request.path) }}">{{ i.title }}</a>{% if i.repeats %} ↻{% endif %}
|
||||||
|
<div class="muted">{{ i.user }}{% if i.location %} · {{ i.location }}{% endif %}</div></div></div>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty">Geen afspraken gepland.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<div class="card o-3"><div class="card-body">
|
||||||
<h2>Notitie toevoegen</h2>
|
<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) }}">
|
||||||
<textarea name="body" required placeholder="Gebeld, afspraak gemaakt, wensen…" aria-label="Notitie"></textarea>
|
<textarea class="form-control" name="body" required placeholder="Gebeld, afspraak gemaakt, wensen…" aria-label="Notitie"></textarea>
|
||||||
<div class="actions"><button class="btn">Opslaan</button></div>
|
<div class="actions"><button class="btn btn-primary">Opslaan</button></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div></div>
|
||||||
|
|
||||||
{% if c.email %}
|
{% if c.unsubscribed_at or c.bounced_at %}
|
||||||
<div class="card">
|
<div class="flash error d-flex justify-content-between align-items-center gap-3 flex-wrap">
|
||||||
<details>
|
<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>
|
||||||
<summary>E-mail sturen</summary>
|
<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>
|
||||||
<form method="post" action="{{ url_for('mail_send') }}">
|
|
||||||
<input type="hidden" name="contact_id" value="{{ c.id }}">
|
|
||||||
<div class="fields">
|
|
||||||
<div class="full"><label>Aan</label><input type="email" name="to" value="{{ c.email }}" required></div>
|
|
||||||
<div class="full"><label>Onderwerp</label><input name="subject" required></div>
|
|
||||||
<div class="full"><label>Bericht</label><textarea name="body" rows="8" required></textarea></div>
|
|
||||||
</div>
|
|
||||||
<div class="actions"><button class="btn">Versturen</button></div>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if c.email %}
|
||||||
|
<div class="card"><div class="card-body">
|
||||||
|
<details>
|
||||||
|
<summary>E-mail sturen</summary>
|
||||||
|
{{ compose({'to': c.email, 'contact_id': c.id}, mail_templates, mail_vars, mailboxes) }}
|
||||||
|
</details>
|
||||||
|
</div></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Tijdlijn</h2>
|
<h2>Tijdlijn</h2>
|
||||||
<ul class="timeline">
|
<ul class="timeline">
|
||||||
{% for a in activities %}
|
{% for a in activities %}
|
||||||
@@ -107,28 +167,28 @@
|
|||||||
<li class="empty">Nog geen activiteit.</li>
|
<li class="empty">Nog geen activiteit.</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div></div>
|
||||||
|
|
||||||
{% if emails %}
|
{% if emails %}
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
<h2>E-mail</h2>
|
<h2>E-mail</h2>
|
||||||
<div class="table-wrap"><table>
|
<div class="table-responsive"><table class="table table-hover table-stack">
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for e in emails %}
|
{% for e in emails %}
|
||||||
<tr class="link" onclick="location='{{ url_for('inbox_view', eid=e.id) }}'">
|
<tr class="link" onclick="location='{{ url_for('inbox_view', eid=e.id) }}'">
|
||||||
<td><span class="chip">{{ 'Ontvangen' if e.direction == 'in' else 'Verstuurd' }}</span></td>
|
<td class="stack-title"><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td>
|
||||||
<td><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td>
|
<td data-label="Richting"><span class="chip">{{ 'Ontvangen' if e.direction == 'in' else 'Verstuurd' }}</span></td>
|
||||||
<td 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>
|
||||||
</div>
|
</div></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="col-lg-5 col-xl-4">
|
||||||
<div class="card">
|
<div class="card o-1"><div class="card-body">
|
||||||
<h2>Gegevens</h2>
|
<h2>Gegevens</h2>
|
||||||
<dl class="props">
|
<dl class="props">
|
||||||
<dt>Status</dt><dd><span class="chip {{ c.status }}">{{ STATUS.get(c.status, c.status) }}</span></dd>
|
<dt>Status</dt><dd><span class="chip {{ c.status }}">{{ STATUS.get(c.status, c.status) }}</span></dd>
|
||||||
@@ -139,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>
|
||||||
@@ -151,33 +211,14 @@
|
|||||||
{% for k, v in extra.items() %}<dt>{{ k }}</dt><dd>{{ v if v is string else v|tojson }}</dd>{% endfor %}
|
{% for k, v in extra.items() %}<dt>{{ k }}</dt><dd>{{ v if v is string else v|tojson }}</dd>{% endfor %}
|
||||||
</dl>
|
</dl>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div></div>
|
||||||
|
|
||||||
<div class="card">
|
<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">
|
</div></div>
|
||||||
{% for f in files %}
|
|
||||||
<div class="file">
|
|
||||||
<a href="{{ url_for('upload_file', name=f.stored) }}" target="_blank" class="thumb">
|
|
||||||
{% if f.mime.startswith('image/') %}<img src="{{ url_for('upload_file', name=f.stored) }}" alt="{{ f.original }}" loading="lazy">
|
|
||||||
{% else %}{{ f.original.rsplit('.', 1)[-1][:4] if '.' in f.original else 'bestand' }}{% endif %}
|
|
||||||
</a>
|
|
||||||
<div class="name" title="{{ f.original }}">{{ f.original }}</div>
|
|
||||||
<form method="post" action="{{ url_for('file_delete', fid=f.id) }}" onsubmit="return confirm('Bestand verwijderen?')">
|
|
||||||
<button class="linkbtn">Verwijderen</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<form method="post" action="{{ url_for('contact_upload', cid=c.id) }}" enctype="multipart/form-data">
|
|
||||||
<input type="file" name="files" multiple required aria-label="Bestanden kiezen">
|
|
||||||
<div class="actions"><button class="btn ghost small">Uploaden</button></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Offertes</h2>
|
<h2>Offertes</h2>
|
||||||
{% for qt in quotes %}
|
{% for qt in quotes %}
|
||||||
<p style="margin:0 0 .6rem"><a href="{{ url_for('quote_edit', qid=qt.id) }}">{{ qt.number }}</a> <span class="chip {{ qt.status }}">{{ QSTATUS.get(qt.status) }}</span><br>
|
<p style="margin:0 0 .6rem"><a href="{{ url_for('quote_edit', qid=qt.id) }}">{{ qt.number }}</a> <span class="chip {{ qt.status }}">{{ QSTATUS.get(qt.status) }}</span><br>
|
||||||
@@ -185,27 +226,27 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<p class="empty">Nog geen offertes.</p>
|
<p class="empty">Nog geen offertes.</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div></div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Klantportaal</h2>
|
<h2>Klantportaal</h2>
|
||||||
{% if c.portal_password_hash %}
|
{% if c.portal_password_hash %}
|
||||||
<p class="muted" style="margin-top:0">Actief{% if c.portal_last_login %} · laatst ingelogd {{ c.portal_last_login|dt }}{% endif %}</p>
|
<p class="muted">Actief{% if c.portal_last_login %} · laatst ingelogd {{ c.portal_last_login|dt }}{% endif %}</p>
|
||||||
{% elif portal_link %}
|
{% elif portal_link %}
|
||||||
<p class="muted" style="margin-top:0">Uitnodiging klaar, nog niet geactiveerd:</p>
|
<p class="muted">Uitnodiging klaar, nog niet geactiveerd:</p>
|
||||||
<p><code>{{ portal_link }}</code></p>
|
<p><code>{{ portal_link }}</code></p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted" style="margin-top:0">De klant kan via het portaal offertes bekijken en accepteren, en bestanden en berichten sturen.</p>
|
<p class="muted">De klant kan via het portaal offertes bekijken en accepteren, en bestanden en berichten sturen.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" action="{{ url_for('contact_portal', cid=c.id) }}" class="actions">
|
<form method="post" action="{{ url_for('contact_portal', cid=c.id) }}" class="actions">
|
||||||
{% if portal_link and c.email %}<button class="btn small" name="action" value="mail">Mail uitnodiging</button>{% endif %}
|
{% if portal_link and c.email %}<button class="btn btn-primary btn-sm" name="action" value="mail">Mail uitnodiging</button>{% endif %}
|
||||||
{% if not c.portal_password_hash %}<button class="btn ghost small" name="action" value="invite">{{ 'Nieuwe link' if portal_link else 'Uitnodiging maken' }}</button>{% endif %}
|
{% if not c.portal_password_hash %}<button class="btn btn-outline-primary btn-sm" name="action" value="invite">{{ 'Nieuwe link' if portal_link else 'Uitnodiging maken' }}</button>{% endif %}
|
||||||
{% if c.portal_password_hash or portal_link %}<button class="btn quiet small" name="action" value="revoke">Toegang intrekken</button>{% endif %}
|
{% if c.portal_password_hash or portal_link %}<button class="btn btn-outline-secondary btn-sm" name="action" value="revoke">Toegang intrekken</button>{% endif %}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div></div>
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('contact_delete', cid=c.id) }}" onsubmit="return confirm('Contact met alle notities en bestanden verwijderen?')">
|
<form method="post" action="{{ url_for('contact_delete', cid=c.id) }}" class="mb-4" onsubmit="return confirm('Contact met alle notities en bestanden verwijderen?')">
|
||||||
<button class="btn quiet small">Contact verwijderen</button>
|
<button class="btn btn-outline-secondary btn-sm">Contact verwijderen</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,17 +255,23 @@
|
|||||||
<form method="post" action="{{ url_for('contact_convert', cid=c.id) }}">
|
<form method="post" action="{{ url_for('contact_convert', cid=c.id) }}">
|
||||||
<h2>Omzetten naar klant</h2>
|
<h2>Omzetten naar klant</h2>
|
||||||
<p class="muted">De contactpersoon komt in een klantrelatie, bijvoorbeeld een huishouden of familie.</p>
|
<p class="muted">De contactpersoon komt in een klantrelatie, bijvoorbeeld een huishouden of familie.</p>
|
||||||
<label><input type="radio" name="mode" value="new" checked onchange="this.form.relation_id.disabled=true;this.form.new_relation.disabled=false"> Nieuwe klantrelatie</label>
|
<div class="form-check">
|
||||||
<input name="new_relation" value="{{ c.company or ('Familie ' ~ c.name.split(' ')[-1]) }}" aria-label="Naam relatie" style="margin:.4rem 0 1rem">
|
<input class="form-check-input" type="radio" id="mode-new" name="mode" value="new" checked onchange="this.form.relation_id && (this.form.relation_id.disabled=true);this.form.new_relation.disabled=false">
|
||||||
|
<label class="form-check-label" for="mode-new">Nieuwe klantrelatie</label>
|
||||||
|
</div>
|
||||||
|
<input class="form-control" name="new_relation" value="{{ c.company or ('Familie ' ~ c.name.split(' ')[-1]) }}" aria-label="Naam relatie" style="margin:.4rem 0 1rem">
|
||||||
{% if klant_relations %}
|
{% if klant_relations %}
|
||||||
<label><input type="radio" name="mode" value="existing" onchange="this.form.relation_id.disabled=false;this.form.new_relation.disabled=true"> Bestaande klantrelatie</label>
|
<div class="form-check">
|
||||||
<select name="relation_id" disabled aria-label="Bestaande relatie" style="margin-top:.4rem">
|
<input class="form-check-input" type="radio" id="mode-existing" name="mode" value="existing" onchange="this.form.relation_id.disabled=false;this.form.new_relation.disabled=true">
|
||||||
|
<label class="form-check-label" for="mode-existing">Bestaande klantrelatie</label>
|
||||||
|
</div>
|
||||||
|
<select class="form-select" name="relation_id" disabled aria-label="Bestaande relatie" style="margin-top:.4rem">
|
||||||
{% for r in klant_relations %}<option value="{{ r.id }}">{{ r.name }}{% if r.type %} · {{ r.type }}{% endif %}</option>{% endfor %}
|
{% for r in klant_relations %}<option value="{{ r.id }}">{{ r.name }}{% if r.type %} · {{ r.type }}{% endif %}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn">Omzetten</button>
|
<button class="btn btn-primary">Omzetten</button>
|
||||||
<button class="btn quiet" type="button" onclick="this.closest('dialog').close()">Annuleren</button>
|
<button class="btn btn-outline-secondary" type="button" onclick="this.closest('dialog').close()">Annuleren</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|||||||
+24
-24
@@ -7,33 +7,33 @@
|
|||||||
<h1>{{ ('Nieuwe ' ~ TYPE.get(c.type, 'lead')|lower) if new else 'Contact bewerken' }}</h1>
|
<h1>{{ ('Nieuwe ' ~ TYPE.get(c.type, 'lead')|lower) if new else 'Contact bewerken' }}</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" class="card">
|
<form method="post" class="card"><div class="card-body">
|
||||||
<div class="fields">
|
<div class="row g-3">
|
||||||
<div><label for="name">Naam *</label><input id="name" name="name" value="{{ c.name }}" required></div>
|
<div class="col-md-6"><label class="form-label" for="name">Naam *</label><input class="form-control" id="name" name="name" value="{{ c.name }}" required></div>
|
||||||
<div><label for="company">Bedrijf</label><input id="company" name="company" value="{{ c.company }}"></div>
|
<div class="col-md-6"><label class="form-label" for="company">Bedrijf</label><input class="form-control" id="company" name="company" value="{{ c.company }}"></div>
|
||||||
<div><label for="email">E-mail</label><input id="email" type="email" name="email" value="{{ c.email }}"></div>
|
<div class="col-md-6"><label class="form-label" for="email">E-mail</label><input class="form-control" id="email" type="email" name="email" value="{{ c.email }}"></div>
|
||||||
<div><label for="phone">Telefoon</label><input id="phone" name="phone" value="{{ c.phone }}"></div>
|
<div class="col-md-6"><label class="form-label" for="phone">Telefoon</label><input class="form-control" id="phone" type="tel" name="phone" value="{{ c.phone }}"></div>
|
||||||
<div class="full"><label for="address">Adres</label><input id="address" name="address" value="{{ c.address }}"></div>
|
<div class="col-12"><label class="form-label" for="address">Adres</label><input class="form-control" id="address" name="address" value="{{ c.address }}"></div>
|
||||||
<div><label for="postcode">Postcode</label><input id="postcode" name="postcode" value="{{ c.postcode }}"></div>
|
<div class="col-sm-4"><label class="form-label" for="postcode">Postcode</label><input class="form-control" id="postcode" name="postcode" value="{{ c.postcode }}"></div>
|
||||||
<div><label for="city">Plaats</label><input id="city" name="city" value="{{ c.city }}"></div>
|
<div class="col-sm-8"><label class="form-label" for="city">Plaats</label><input class="form-control" id="city" name="city" value="{{ c.city }}"></div>
|
||||||
<div><label for="status">Status</label>
|
<div class="col-md-6 col-xl-4"><label class="form-label" for="status">Status</label>
|
||||||
<select id="status" name="status">
|
<select class="form-select" id="status" name="status">
|
||||||
{% for k, l in STATUSES %}<option value="{{ k }}" {{ 'selected' if c.status == k }}>{{ l }}</option>{% endfor %}
|
{% for k, l in STATUSES %}<option value="{{ k }}" {{ 'selected' if c.status == k }}>{{ l }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div><label for="type">Soort</label>
|
<div class="col-md-6 col-xl-4"><label class="form-label" for="type">Soort</label>
|
||||||
<select id="type" name="type">
|
<select class="form-select" id="type" name="type">
|
||||||
{% for k, l in TYPES %}<option value="{{ k }}" {{ 'selected' if c.type == k }}>{{ l }}</option>{% endfor %}
|
{% for k, l in TYPES %}<option value="{{ k }}" {{ 'selected' if c.type == k }}>{{ l }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div><label for="assigned">Toegewezen aan</label>
|
<div class="col-md-6 col-xl-4"><label class="form-label" for="assigned">Toegewezen aan</label>
|
||||||
<select id="assigned" name="assigned_user_id">
|
<select class="form-select" id="assigned" name="assigned_user_id">
|
||||||
<option value="">— Niemand —</option>
|
<option value="">— Niemand —</option>
|
||||||
{% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if c.assigned_user_id|string == u.id|string }}>{{ u.name }}</option>{% endfor %}
|
{% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if c.assigned_user_id|string == u.id|string }}>{{ u.name }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div><label for="relation">Relatie</label>
|
<div class="col-md-6"><label class="form-label" for="relation">Relatie</label>
|
||||||
<select id="relation" name="relation_id">
|
<select class="form-select" id="relation" name="relation_id">
|
||||||
<option value="">— Geen —</option>
|
<option value="">— Geen —</option>
|
||||||
{% for kind, label in RELATION_KINDS %}
|
{% for kind, label in RELATION_KINDS %}
|
||||||
<optgroup label="{{ label }}s">
|
<optgroup label="{{ label }}s">
|
||||||
@@ -43,14 +43,14 @@
|
|||||||
</select>
|
</select>
|
||||||
<p class="hint">Huishouden, familie of bedrijf van de klant, of de partner waar deze persoon werkt.</p>
|
<p class="hint">Huishouden, familie of bedrijf van de klant, of de partner waar deze persoon werkt.</p>
|
||||||
</div>
|
</div>
|
||||||
<div><label for="role">Rol binnen relatie</label><input id="role" name="relation_role" value="{{ c.relation_role }}" placeholder="Bijv. bewoner, mantelzorger, inkoper"></div>
|
<div class="col-md-6"><label class="form-label" for="role">Rol binnen relatie</label><input class="form-control" id="role" name="relation_role" value="{{ c.relation_role }}" placeholder="Bijv. bewoner, mantelzorger, inkoper"></div>
|
||||||
<div><label for="value">Verwachte waarde (€)</label><input id="value" type="number" step="0.01" name="value" value="{{ c.value or '' }}"></div>
|
<div class="col-md-6"><label class="form-label" for="value">Verwachte waarde (€)</label><input class="form-control" id="value" type="number" step="0.01" name="value" value="{{ c.value or '' }}" inputmode="decimal"></div>
|
||||||
<div><label for="source">Bron</label><input id="source" name="source" value="{{ c.source }}" placeholder="Bijv. website, beurs, telefoon"></div>
|
<div class="col-md-6"><label class="form-label" for="source">Bron</label><input class="form-control" id="source" name="source" value="{{ c.source }}" placeholder="Bijv. website, beurs, telefoon"></div>
|
||||||
<div class="full"><label for="notes">Vaste notities</label><textarea id="notes" name="notes">{{ c.notes }}</textarea></div>
|
<div class="col-12"><label class="form-label" for="notes">Vaste notities</label><textarea class="form-control" id="notes" name="notes">{{ c.notes }}</textarea></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn">Opslaan</button>
|
<button class="btn btn-primary">Opslaan</button>
|
||||||
<a class="btn quiet" href="{{ url_for('contacts') if new else url_for('contact_view', cid=c.id) }}">Annuleren</a>
|
<a class="btn btn-outline-secondary" href="{{ url_for('contacts') if new else url_for('contact_view', cid=c.id) }}">Annuleren</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div></form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+24
-15
@@ -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">
|
||||||
@@ -6,10 +7,10 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<form class="search" method="get">
|
<form class="search" method="get">
|
||||||
{% 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 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 ghost" 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" 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>
|
||||||
|
|
||||||
@@ -30,25 +31,33 @@
|
|||||||
<a href="{{ url_for('contacts', owner='none', **base) }}" class="{{ 'on' if a.owner == 'none' }}">Niet toegewezen</a>
|
<a href="{{ url_for('contacts', owner='none', **base) }}" class="{{ 'on' if a.owner == 'none' }}">Niet toegewezen</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
{% if rows %}
|
{% if rows %}
|
||||||
<div class="table-wrap"><table>
|
<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>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) }}'">
|
||||||
<td><a href="{{ url_for('contact_view', cid=c.id) }}">{{ c.name }}</a><div class="muted">{{ c.email }}</div></td>
|
{{ bulk_cell(c.id, c.name) }}
|
||||||
<td>{% if c.relation_id %}<a href="{{ url_for('relation_view', rid=c.relation_id) }}" onclick="event.stopPropagation()">{{ c.relation_name }}</a>{% if c.relation_role %}<div class="muted">{{ c.relation_role }}</div>{% endif %}{% else %}{{ c.company }}{% endif %}</td>
|
<td class="stack-title"><a href="{{ url_for('contact_view', cid=c.id) }}">{{ c.name }}</a><div class="muted">{{ c.email }}</div></td>
|
||||||
<td>{{ c.phone }}</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>{{ c.city }}</td>
|
<td data-label="Telefoon">{{ c.phone }}</td>
|
||||||
<td>{{ c.source }}</td>
|
<td data-label="Plaats">{{ c.city }}</td>
|
||||||
<td>{{ c.user_name or '—' }}</td>
|
<td data-label="Model">{{ c.design_title or '' }}</td>
|
||||||
<td>{% if not a.type %}<span class="chip">{{ TYPE.get(c.type) }}</span> {% endif %}<span class="chip {{ c.status }}">{{ STATUS.get(c.status, c.status) }}</span></td>
|
<td data-label="Bron">{{ c.source }}</td>
|
||||||
<td class="muted">{{ c.created_at|dt('%d-%m-%Y') }}</td>
|
<td data-label="Toegewezen">{{ c.user_name or '—' }}</td>
|
||||||
|
<td data-label="Status"><span>{% if not a.type %}<span class="chip">{{ TYPE.get(c.type) }}</span> {% endif %}<span class="chip {{ c.status }}">{{ STATUS.get(c.status, c.status) }}</span></span></td>
|
||||||
|
<td data-label="Aangemaakt" class="muted">{{ c.created_at|dt('%d-%m-%Y') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</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>
|
||||||
|
{% 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 %}
|
||||||
|
|||||||
+101
-84
@@ -3,101 +3,118 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<div><div class="kicker">Overzicht</div><h1>Dashboard</h1></div>
|
<div><div class="kicker">Overzicht</div><h1>Dashboard</h1></div>
|
||||||
<a class="btn" href="{{ url_for('contact_new') }}">Nieuw contact</a>
|
<div class="actions"><a class="btn btn-primary" href="{{ url_for('contact_new') }}">Nieuw contact</a></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="kpis">
|
<div class="row g-3 mb-4">
|
||||||
<div class="kpi"><div class="label">Nieuwe leads · 30 dagen</div><div class="value">{{ stats.new30 }}</div><div class="muted">{{ stats.customers }} klantrelaties · {{ stats.partners }} partners</div></div>
|
<div class="col-sm-6 col-xl-3"><div class="kpi"><div class="label">Nieuwe leads · 30 dagen</div><div class="value">{{ stats.new30 }}</div><div class="muted">{{ stats.customers }} klantrelaties · {{ stats.partners }} partners</div></div></div>
|
||||||
<div class="kpi"><div class="label">Open leads</div><div class="value">{{ stats.open_leads }}</div>
|
<div class="col-sm-6 col-xl-3"><div class="kpi"><div class="label">Open leads</div><div class="value">{{ stats.open_leads }}</div>
|
||||||
<div class="muted">{{ stats.pipeline_value|euro }} verwacht{% if stats.unassigned %} · <a href="{{ url_for('contacts', type='lead', owner='none') }}">{{ stats.unassigned }} niet toegewezen</a>{% endif %}</div></div>
|
<div class="muted">{{ stats.pipeline_value|euro }} verwacht{% if stats.unassigned %} · <a href="{{ url_for('contacts', type='lead', owner='none') }}">{{ stats.unassigned }} niet toegewezen</a>{% endif %}</div></div></div>
|
||||||
<div class="kpi"><div class="label">Open offertes</div><div class="value">{{ stats.open_quotes }}</div><div class="muted">{{ stats.open_value|euro }} excl. btw</div></div>
|
<div class="col-sm-6 col-xl-3"><div class="kpi"><div class="label">Open offertes</div><div class="value">{{ stats.open_quotes }}</div><div class="muted">{{ stats.open_value|euro }} excl. btw</div></div></div>
|
||||||
<div class="kpi"><div class="label">Gewonnen · 30 dagen</div><div class="value">{{ stats.won_value|euro }}</div><div class="muted">excl. btw</div></div>
|
<div class="col-sm-6 col-xl-3"><div class="kpi"><div class="label">Gewonnen · 30 dagen</div><div class="value">{{ stats.won_value|euro }}</div><div class="muted">excl. btw</div></div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="row g-4 mb-4 card-grid">
|
||||||
<div class="card">
|
<div class="col-md-6 col-xxl-4">
|
||||||
<h2>Leads per status</h2>
|
<div class="card"><div class="card-body">
|
||||||
<div class="bars">
|
<h2>Leads per status</h2>
|
||||||
{% for key, label, n in pipeline %}
|
<div class="bars">
|
||||||
<a class="bar" href="{{ url_for('contacts', type='lead', status=key) }}">
|
{% for key, label, n in pipeline %}
|
||||||
<span>{{ label }}</span>
|
<a class="bar" href="{{ url_for('contacts', type='lead', status=key) }}">
|
||||||
<span class="track"><span class="fill" style="width: {{ (n / pipe_max * 100)|round(1) }}%"></span></span>
|
<span>{{ label }}</span>
|
||||||
<span class="num">{{ n }}</span>
|
<span class="track"><span class="fill" style="width: {{ (n / pipe_max * 100)|round(1) }}%"></span></span>
|
||||||
</a>
|
<span class="num">{{ n }}</span>
|
||||||
{% endfor %}
|
</a>
|
||||||
</div>
|
{% endfor %}
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h2>Leads per week</h2>
|
|
||||||
<div class="cols">
|
|
||||||
{% for wk, n in weekly %}
|
|
||||||
<div title="Week {{ wk }}: {{ n }} lead(s)">
|
|
||||||
<span>{{ n or '' }}</span>
|
|
||||||
<span class="c" style="height: {{ (n / week_max * 75)|round(1) }}%"></span>
|
|
||||||
<span>{{ wk }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
</div></div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="col-md-6 col-xxl-4">
|
||||||
<h2>Bronnen</h2>
|
<div class="card"><div class="card-body">
|
||||||
{% if sources %}
|
<h2>Leads per week</h2>
|
||||||
<div class="bars">
|
<div class="cols">
|
||||||
{% for s in sources %}
|
{% for wk, n in weekly %}
|
||||||
<div class="bar">
|
<div title="Week {{ wk }}: {{ n }} lead(s)">
|
||||||
<span title="{{ s.s }}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ s.s }}</span>
|
<span>{{ n or '' }}</span>
|
||||||
<span class="track"><span class="fill" style="width: {{ (s.n / source_max * 100)|round(1) }}%"></span></span>
|
<span class="c" style="height: {{ (n / week_max * 75)|round(1) }}%"></span>
|
||||||
<span class="num">{{ s.n }}</span>
|
<span>{{ wk }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
</div></div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}<p class="empty">Nog geen leads.</p>{% endif %}
|
<div class="col-md-12 col-xxl-4">
|
||||||
|
<div class="card"><div class="card-body">
|
||||||
|
<h2>Bronnen</h2>
|
||||||
|
{% if sources %}
|
||||||
|
<div class="bars">
|
||||||
|
{% for s in sources %}
|
||||||
|
<div class="bar">
|
||||||
|
<span title="{{ s.s }}">{{ s.s }}</span>
|
||||||
|
<span class="track"><span class="fill" style="width: {{ (s.n / source_max * 100)|round(1) }}%"></span></span>
|
||||||
|
<span class="num">{{ s.n }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}<p class="empty">Nog geen leads.</p>{% endif %}
|
||||||
|
</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="two">
|
<div class="row gx-4">
|
||||||
<div class="card">
|
<div class="col-xl-8">
|
||||||
<h2>Laatste leads</h2>
|
<div class="card"><div class="card-body">
|
||||||
{% if recent %}
|
<h2>Laatste leads</h2>
|
||||||
<div class="table-wrap"><table>
|
{% if recent %}
|
||||||
<thead><tr><th>Naam</th><th>Bron</th><th>Toegewezen</th><th>Status</th><th>Binnen</th></tr></thead>
|
<div class="table-responsive"><table class="table table-hover table-stack">
|
||||||
<tbody>
|
<thead><tr><th>Naam</th><th>Bron</th><th>Toegewezen</th><th>Status</th><th>Binnen</th></tr></thead>
|
||||||
{% for c in recent %}
|
<tbody>
|
||||||
<tr class="link" onclick="location='{{ url_for('contact_view', cid=c.id) }}'">
|
{% for c in recent %}
|
||||||
<td><a href="{{ url_for('contact_view', cid=c.id) }}">{{ c.name }}</a><div class="muted">{{ c.email }}</div></td>
|
<tr class="link" onclick="location='{{ url_for('contact_view', cid=c.id) }}'">
|
||||||
<td>{{ c.source }}</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>{{ c.user_name or '—' }}</td>
|
<td data-label="Bron">{{ c.source }}</td>
|
||||||
<td><span class="chip {{ c.status }}">{{ STATUS.get(c.status, c.status) }}</span></td>
|
<td data-label="Toegewezen">{{ c.user_name or '—' }}</td>
|
||||||
<td class="muted">{{ c.created_at|dt }}</td>
|
<td data-label="Status"><span class="chip {{ c.status }}">{{ STATUS.get(c.status, c.status) }}</span></td>
|
||||||
</tr>
|
<td data-label="Binnen" class="muted">{{ c.created_at|dt }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table></div>
|
||||||
|
{% else %}<p class="empty">Nog geen contacten. Voeg er een toe of koppel een formulier via Instellingen.</p>{% endif %}
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-4">
|
||||||
|
<div class="card"><div class="card-body">
|
||||||
|
<h2>Mijn taken</h2>
|
||||||
|
{% for t in tasks %}
|
||||||
|
<form method="post" action="{{ url_for('task_done', tid=t.id) }}" class="task">
|
||||||
|
<button class="check" aria-label="Afronden"></button>
|
||||||
|
<div><div>{{ t.title }}</div>
|
||||||
|
<div class="muted">{% if t.contact_id %}<a href="{{ url_for('contact_view', cid=t.contact_id) }}">{{ t.contact_name }}</a>{% endif %}
|
||||||
|
{% if t.due_date %}· <span class="{{ 'overdue' if t.due_date < today }}">{{ t.due_date|dt('%d-%m-%Y') }}</span>{% endif %}</div></div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty">Geen openstaande taken.</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</div></div>
|
||||||
</table></div>
|
<div class="card"><div class="card-body">
|
||||||
{% else %}<p class="empty">Nog geen contacten. Voeg er een toe of koppel een formulier via Instellingen.</p>{% endif %}
|
<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>
|
||||||
</div>
|
{% for i in agenda %}
|
||||||
<div>
|
<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 class="card">
|
<div><a href="{{ i.url }}">{{ i.title }}</a>{% if i.contact %}<div class="muted">{{ i.contact }}</div>{% endif %}</div></div>
|
||||||
<h2>Mijn taken</h2>
|
{% else %}
|
||||||
{% for t in tasks %}
|
<p class="empty">Geen afspraken de komende 7 dagen.</p>
|
||||||
<form method="post" action="{{ url_for('task_done', tid=t.id) }}" class="task">
|
{% endfor %}
|
||||||
<button class="check" aria-label="Afronden"></button>
|
</div></div>
|
||||||
<div><div>{{ t.title }}</div>
|
<div class="card"><div class="card-body">
|
||||||
<div class="muted">{% if t.contact_id %}<a href="{{ url_for('contact_view', cid=t.contact_id) }}">{{ t.contact_name }}</a>{% endif %}
|
<h2>Ongelezen e-mail</h2>
|
||||||
{% if t.due_date %}· <span class="{{ 'overdue' if t.due_date < today }}">{{ t.due_date|dt('%d-%m-%Y') }}</span>{% endif %}</div></div>
|
{% for e in mails %}
|
||||||
</form>
|
<p><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a><br><span class="muted">{{ e.from_name or e.from_addr }} · {{ e.date|dt }}</span></p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="empty">Geen openstaande taken.</p>
|
<p class="empty">Geen ongelezen berichten.</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
<a href="{{ url_for('inbox') }}">Naar inbox</a>
|
||||||
<div class="card">
|
</div></div>
|
||||||
<h2>Ongelezen e-mail</h2>
|
|
||||||
{% for e in mails %}
|
|
||||||
<p><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a><br><span class="muted">{{ e.from_name or e.from_addr }} · {{ e.date|dt }}</span></p>
|
|
||||||
{% else %}
|
|
||||||
<p class="empty">Geen ongelezen berichten.</p>
|
|
||||||
{% endfor %}
|
|
||||||
<a href="{{ url_for('inbox') }}">Naar inbox</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+22
-21
@@ -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">
|
<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 ghost" 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 ghost">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 %}
|
||||||
|
<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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card"><div class="mailbody">{{ e.body }}</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"><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></div>
|
||||||
<div class="fields">
|
|
||||||
<div><label>Aan</label><input type="email" name="to" value="{{ e.from_addr }}" required></div>
|
|
||||||
<div><label>Onderwerp</label><input name="subject" value="{{ e.subject if e.subject.lower().startswith('re:') else 'Re: ' ~ e.subject }}" required></div>
|
|
||||||
<div class="full"><label>Bericht</label><textarea name="body" rows="8" required></textarea></div>
|
|
||||||
</div>
|
|
||||||
<div class="actions"><button class="btn">Versturen</button></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% 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 %}
|
||||||
+24
-20
@@ -2,27 +2,31 @@
|
|||||||
{% block title %}Importeren{% endblock %}
|
{% block title %}Importeren{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="head"><div><div class="kicker">Gegevens</div><h1>Importeren</h1></div></div>
|
<div class="head"><div><div class="kicker">Gegevens</div><h1>Importeren</h1></div></div>
|
||||||
<div class="two">
|
<div class="row g-4 card-grid">
|
||||||
<form method="post" enctype="multipart/form-data" class="card">
|
<div class="col-xl-7">
|
||||||
<h2>Bestand kiezen</h2>
|
<form method="post" enctype="multipart/form-data" class="card"><div class="card-body">
|
||||||
<div class="fields">
|
<h2>Bestand kiezen</h2>
|
||||||
<div class="full"><label for="kind">Wat importeer je?</label>
|
<div class="row g-3">
|
||||||
<select id="kind" name="kind">
|
<div class="col-12"><label class="form-label" for="kind">Wat importeer je?</label>
|
||||||
<option value="contacts">Leads / klanten</option>
|
<select class="form-select" id="kind" name="kind">
|
||||||
<option value="relations" {{ 'selected' if request.args.get('kind') == 'relations' }}>Relaties (klanten of partners)</option>
|
<option value="contacts">Leads / klanten</option>
|
||||||
<option value="products" {{ 'selected' if request.args.get('kind') == 'products' }}>Producten</option>
|
<option value="relations" {{ 'selected' if request.args.get('kind') == 'relations' }}>Relaties (klanten of partners)</option>
|
||||||
</select>
|
<option value="products" {{ 'selected' if request.args.get('kind') == 'products' }}>Producten</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12"><label class="form-label" for="file">CSV- of JSON-bestand</label><input class="form-control" id="file" type="file" name="file" accept=".csv,.json,.txt" required></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="full"><label for="file">CSV- of JSON-bestand</label><input id="file" type="file" name="file" accept=".csv,.json,.txt" required></div>
|
<div class="actions"><button class="btn btn-primary">Volgende: kolommen controleren</button></div>
|
||||||
</div>
|
</div></form>
|
||||||
<div class="actions"><button class="btn">Volgende: kolommen controleren</button></div>
|
</div>
|
||||||
</form>
|
<div class="col-xl-5">
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Formaten</h2>
|
<h2>Formaten</h2>
|
||||||
<p><strong>CSV</strong>: scheidingsteken <code>;</code>, <code>,</code> of tab, de eerste regel met kolomnamen. Regels die beginnen met <code>#</code> worden overgeslagen. Bedragen als <code>6,00</code>, <code>1.234,50</code> of <code>€ 6</code>.</p>
|
<p><strong>CSV</strong>: scheidingsteken <code>;</code>, <code>,</code> of tab, de eerste regel met kolomnamen. Regels die beginnen met <code>#</code> worden overgeslagen. Bedragen als <code>6,00</code>, <code>1.234,50</code> of <code>€ 6</code>.</p>
|
||||||
<p><strong>JSON</strong>: een lijst met objecten, of een object met een lijst erin, zoals <code>{"leads": [{"naam": "…", "email": "…"}]}</code>.</p>
|
<p><strong>JSON</strong>: een lijst met objecten, of een object met een lijst erin, zoals <code>{"leads": [{"naam": "…", "email": "…"}]}</code>.</p>
|
||||||
<p><strong>Configurator</strong>: <code>kosten/leveranciers.csv</code> en <code>intern.csv</code> worden als producten herkend (code, omschrijving, eenheid, prijs, leverancier). Een woningontwerp (<code>house-design</code>-JSON) upload je bij een contact onder Bestanden.</p>
|
<p><strong>Configurator</strong>: <code>kosten/leveranciers.csv</code> en <code>intern.csv</code> worden als producten herkend (code, omschrijving, eenheid, prijs, leverancier). Een woningontwerp (<code>house-design</code>-JSON) upload je bij een contact onder Bestanden.</p>
|
||||||
<p class="muted" style="margin-bottom:0">Je ziet eerst een voorbeeld en kiest per kolom waar die heen moet. Bestaande contacten (zelfde e-mail) en producten (zelfde SKU) worden bijgewerkt in plaats van dubbel aangemaakt. Elke productwijziging komt in de mutatiehistorie; offertes houden hun eigen prijs.</p>
|
<p class="muted">Je ziet eerst een voorbeeld en kiest per kolom waar die heen moet. Bestaande contacten (zelfde e-mail) en producten (zelfde SKU) worden bijgewerkt in plaats van dubbel aangemaakt. Elke productwijziging komt in de mutatiehistorie; offertes houden hun eigen prijs.</p>
|
||||||
|
</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -7,17 +7,18 @@
|
|||||||
<div><a class="label" href="{{ url_for('import_start') }}">← Ander bestand</a>
|
<div><a class="label" href="{{ url_for('import_start') }}">← Ander bestand</a>
|
||||||
<h1>{{ filename }}</h1>
|
<h1>{{ filename }}</h1>
|
||||||
<div class="muted">{{ total }} rij(en) · {{ {'contacts': 'leads / contactpersonen', 'relations': 'relaties', 'products': 'producten'}[kind] }}</div></div>
|
<div class="muted">{{ total }} rij(en) · {{ {'contacts': 'leads / contactpersonen', 'relations': 'relaties', 'products': 'producten'}[kind] }}</div></div>
|
||||||
<div class="actions"><button class="btn">{{ total }} rij(en) importeren</button></div>
|
<div class="actions"><button class="btn btn-primary">{{ total }} rij(en) importeren</button></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Kolommen koppelen</h2>
|
<h2>Kolommen koppelen</h2>
|
||||||
<div class="table-wrap"><table>
|
<p class="hint d-md-none" style="margin:-.4rem 0 .75rem">Schuif de tabel opzij voor de andere kolommen.</p>
|
||||||
|
<div class="table-responsive"><table class="table">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
{% for col in cols %}
|
{% for col in cols %}
|
||||||
<th style="min-width:170px">
|
<th style="min-width:170px">
|
||||||
<div style="margin-bottom:.4rem">{{ col }}</div>
|
<div style="margin-bottom:.4rem">{{ col }}</div>
|
||||||
<select name="map_{{ loop.index0 }}" aria-label="Doelveld voor {{ col }}">
|
<select class="form-select form-select-sm" name="map_{{ loop.index0 }}" aria-label="Doelveld voor {{ col }}">
|
||||||
{% for k, l in targets %}<option value="{{ k }}" {{ 'selected' if mapping[loop.index0] == k }}>{{ l }}</option>{% endfor %}
|
{% for k, l in targets %}<option value="{{ k }}" {{ 'selected' if mapping[loop.index0] == k }}>{{ l }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</th>
|
</th>
|
||||||
@@ -30,30 +31,30 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table></div>
|
</table></div>
|
||||||
{% if total > rows|length %}<p class="hint">Eerste {{ rows|length }} van {{ total }} rijen.</p>{% endif %}
|
{% if total > rows|length %}<p class="hint">Eerste {{ rows|length }} van {{ total }} rijen.</p>{% endif %}
|
||||||
</div>
|
</div></div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Opties</h2>
|
<h2>Opties</h2>
|
||||||
<div class="fields">
|
<div class="row g-3">
|
||||||
{% if kind == 'contacts' %}
|
{% if kind == 'contacts' %}
|
||||||
<div><label>Importeren als</label><select name="type">{% for k, l in TYPES %}<option value="{{ k }}">{{ l }}</option>{% endfor %}</select></div>
|
<div class="col-md-6"><label class="form-label" for="o-type">Importeren als</label><select class="form-select" id="o-type" name="type">{% for k, l in TYPES %}<option value="{{ k }}">{{ l }}</option>{% endfor %}</select></div>
|
||||||
<div><label>Status (als de kolom ontbreekt)</label><select name="status">{% for k, l in STATUSES %}<option value="{{ k }}">{{ l }}</option>{% endfor %}</select></div>
|
<div class="col-md-6"><label class="form-label" for="o-status">Status (als de kolom ontbreekt)</label><select class="form-select" id="o-status" name="status">{% for k, l in STATUSES %}<option value="{{ k }}">{{ l }}</option>{% endfor %}</select></div>
|
||||||
<div><label>Toewijzen aan</label><select name="assigned_user_id"><option value="">— Niemand —</option>{% for u in USERS %}<option value="{{ u.id }}">{{ u.name }}</option>{% endfor %}</select></div>
|
<div class="col-md-6"><label class="form-label" for="o-user">Toewijzen aan</label><select class="form-select" id="o-user" name="assigned_user_id"><option value="">— Niemand —</option>{% for u in USERS %}<option value="{{ u.id }}">{{ u.name }}</option>{% endfor %}</select></div>
|
||||||
<div><label>Bron (als de kolom ontbreekt)</label><input name="source" value="Import"></div>
|
<div class="col-md-6"><label class="form-label" for="o-source">Bron (als de kolom ontbreekt)</label><input class="form-control" id="o-source" name="source" value="Import"></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if kind == 'relations' %}
|
{% if kind == 'relations' %}
|
||||||
<div><label>Soort</label><select name="relation_kind" id="rkind">{% for k, l in RELATION_KINDS %}<option value="{{ k }}" {{ 'selected' if request.args.get('rkind') == k }}>{{ l }}</option>{% endfor %}</select></div>
|
<div class="col-md-6"><label class="form-label" for="rkind">Soort</label><select class="form-select" name="relation_kind" id="rkind">{% for k, l in RELATION_KINDS %}<option value="{{ k }}" {{ 'selected' if request.args.get('rkind') == k }}>{{ l }}</option>{% endfor %}</select></div>
|
||||||
<div><label>Type (als de kolom ontbreekt)</label>
|
<div class="col-md-6"><label class="form-label" for="o-rtype">Type (als de kolom ontbreekt)</label>
|
||||||
<select name="relation_type"><option value="">—</option>
|
<select class="form-select" id="o-rtype" name="relation_type"><option value="">—</option>
|
||||||
{% for k, l in RELATION_KINDS %}<optgroup label="{{ l }}">{% for t in relation_types[k] %}<option>{{ t }}</option>{% endfor %}</optgroup>{% endfor %}
|
{% for k, l in RELATION_KINDS %}<optgroup label="{{ l }}">{% for t in relation_types[k] %}<option>{{ t }}</option>{% endfor %}</optgroup>{% endfor %}
|
||||||
</select></div>
|
</select></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if kind == 'products' %}
|
{% if kind == 'products' %}
|
||||||
<div class="full"><label><input type="checkbox" name="deactivate_missing" value="1">Producten met een SKU die niet in dit bestand staan op inactief zetten (volledige prijslijst)</label>
|
<div class="col-12"><div class="form-check"><input class="form-check-input" type="checkbox" id="o-deact" name="deactivate_missing" value="1"><label class="form-check-label" for="o-deact">Producten met een SKU die niet in dit bestand staan op inactief zetten (volledige prijslijst)</label></div>
|
||||||
<p class="hint">Alleen als er een kolom aan SKU gekoppeld is. Inactieve producten blijven bestaan, met hun mutaties en offertes.</p></div>
|
<p class="hint">Alleen als er een kolom aan SKU gekoppeld is. Inactieve producten blijven bestaan, met hun mutaties en offertes.</p></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="full"><label><input type="checkbox" name="update" value="1" checked>Bestaande {{ 'contacten (zelfde e-mail) aanvullen' if kind == 'contacts' else 'relaties (zelfde naam of KvK) aanvullen' if kind == 'relations' else 'producten bijwerken (zelfde SKU, of zelfde naam als er geen SKU is); alleen gekoppelde kolommen worden gewijzigd' }}; uit = overslaan</label></div>
|
<div class="col-12"><div class="form-check"><input class="form-check-input" type="checkbox" id="o-update" name="update" value="1" checked><label class="form-check-label" for="o-update">Bestaande {{ 'contacten (zelfde e-mail) aanvullen' if kind == 'contacts' else 'relaties (zelfde naam of KvK) aanvullen' if kind == 'relations' else 'producten bijwerken (zelfde SKU, of zelfde naam als er geen SKU is); alleen gekoppelde kolommen worden gewijzigd' }}; uit = overslaan</label></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div></div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+41
-17
@@ -1,39 +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></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">
|
||||||
{% if last_sync %}<span class="muted">Laatst opgehaald: {{ last_sync|dt }}</span>{% endif %}
|
<form class="search" method="get">
|
||||||
<form method="post" action="{{ url_for('inbox_sync') }}" class="inline"><button class="btn">Nieuwe e-mail ophalen</button></form>
|
<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"><div class="card-body">
|
||||||
{% if rows %}
|
{% if rows %}
|
||||||
<div class="table-wrap"><table>
|
<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>{% if not e.is_read %}<span class="dot" aria-label="Ongelezen"></span>{% endif %}{{ e.from_name or e.from_addr }}</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><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>{{ e.contact_name or '' }}</td>
|
<td data-label="Contact">{{ e.contact_name or '' }}</td>
|
||||||
<td class="muted num">{{ e.date|dt }}</td>
|
{% if not mb and mailboxes|length > 1 %}<td data-label="Mailbox" class="muted">{{ e.mailbox_name or '' }}</td>{% endif %}
|
||||||
|
<td data-label="Datum" class="muted num">{{ e.date|dt }}</td>
|
||||||
</tr>
|
</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>
|
||||||
|
{% 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 %}
|
||||||
+45
-41
@@ -1,70 +1,74 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% block title %}Klantportaal{% endblock %}
|
{% block title %}Klantportaal{% endblock %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<script>document.body.classList.add('portal')</script>
|
|
||||||
<header class="portal-top">
|
<header class="portal-top">
|
||||||
<div class="wrap">
|
<div class="container d-flex justify-content-between align-items-center gap-3">
|
||||||
<img src="{{ url_for('static', filename='logo.svg') }}" alt="{{ s.company_name or 'Van Emous' }}">
|
<img src="{{ url_for('static', filename='logo.svg') }}" alt="{{ s.company_name or 'Van Emous' }}">
|
||||||
<a href="{{ url_for('portal_logout') }}">Uitloggen</a>
|
<a href="{{ url_for('portal_logout') }}">Uitloggen</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="portal-main">
|
<div class="portal-main container">
|
||||||
{% with msgs = get_flashed_messages(with_categories=true) %}
|
{% with msgs = get_flashed_messages(with_categories=true) %}
|
||||||
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
|
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
<div class="head"><div><div class="kicker">Klantportaal</div><h1>Welkom, {{ c.name }}</h1></div></div>
|
<div class="head"><div><div class="kicker">Klantportaal</div><h1>Welkom, {{ c.name }}</h1></div></div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Uw offertes</h2>
|
<h2>Uw offertes</h2>
|
||||||
{% if quotes %}
|
{% if quotes %}
|
||||||
<div class="table-wrap"><table>
|
<div class="table-responsive"><table class="table table-hover table-stack">
|
||||||
<thead><tr><th>Nummer</th><th>Onderwerp</th><th>Datum</th><th>Status</th><th class="num">Totaal incl. btw</th></tr></thead>
|
<thead><tr><th>Nummer</th><th>Onderwerp</th><th>Datum</th><th>Status</th><th class="num">Totaal incl. btw</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for qt in quotes %}
|
{% for qt in quotes %}
|
||||||
<tr class="link" onclick="location='{{ url_for('portal_quote', qid=qt.id) }}'">
|
<tr class="link" onclick="location='{{ url_for('portal_quote', qid=qt.id) }}'">
|
||||||
<td><a href="{{ url_for('portal_quote', qid=qt.id) }}">{{ qt.number }}</a></td>
|
<td class="stack-title"><a href="{{ url_for('portal_quote', qid=qt.id) }}">{{ qt.number }}</a></td>
|
||||||
<td>{{ qt.title }}</td>
|
<td data-label="Onderwerp">{{ qt.title }}</td>
|
||||||
<td>{{ qt.date|dt('%d-%m-%Y') }}</td>
|
<td data-label="Datum">{{ qt.date|dt('%d-%m-%Y') }}</td>
|
||||||
<td><span class="chip {{ qt.status }}">{{ 'Wacht op uw reactie' if qt.status == 'verzonden' else QSTATUS.get(qt.status) }}</span></td>
|
<td data-label="Status"><span class="chip {{ qt.status }}">{{ 'Wacht op uw reactie' if qt.status == 'verzonden' else QSTATUS.get(qt.status) }}</span></td>
|
||||||
<td class="num">{{ qt.total|euro }}</td>
|
<td data-label="Totaal incl. btw" class="num">{{ qt.total|euro }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table></div>
|
</table></div>
|
||||||
{% else %}<p class="empty">Er staan nog geen offertes voor u klaar.</p>{% endif %}
|
{% else %}<p class="empty">Er staan nog geen offertes voor u klaar.</p>{% endif %}
|
||||||
</div>
|
</div></div>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="row g-4 mb-4 card-grid">
|
||||||
<div class="card">
|
<div class="col-md-6">
|
||||||
<h2>Bestanden</h2>
|
<div class="card"><div class="card-body">
|
||||||
{% if files %}
|
<h2>Bestanden</h2>
|
||||||
<div class="files">
|
{% if documents %}
|
||||||
{% for f in files %}
|
<div class="files">
|
||||||
<div class="file">
|
{% for d in documents %}{% set f = d.latest %}
|
||||||
<a href="{{ url_for('portal_file', name=f.stored) }}" target="_blank" class="thumb">
|
<div class="file">
|
||||||
{% if f.mime.startswith('image/') %}<img src="{{ url_for('portal_file', name=f.stored) }}" alt="{{ f.original }}" loading="lazy">
|
<a href="{{ url_for('portal_file', name=f.stored) }}" target="_blank" class="thumb">
|
||||||
{% else %}{{ f.original.rsplit('.', 1)[-1][:4] if '.' in f.original else 'bestand' }}{% endif %}
|
{% if f.mime.startswith('image/') %}<img src="{{ url_for('portal_file', name=f.stored) }}" alt="{{ f.original }}" loading="lazy">
|
||||||
</a>
|
{% else %}{{ f.original.rsplit('.', 1)[-1][:4] if '.' in f.original else 'bestand' }}{% endif %}
|
||||||
<div class="name" title="{{ f.original }}">{{ f.original }}</div>
|
</a>
|
||||||
</div>
|
<div class="name" title="{{ d.title }}">{{ d.title }}</div>
|
||||||
{% endfor %}
|
{% if d.versions|length > 1 %}<div class="muted">versie {{ f.version }}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endfor %}
|
||||||
<form method="post" action="{{ url_for('portal_upload') }}" enctype="multipart/form-data">
|
</div>
|
||||||
<label for="files">Foto's of documenten delen (bijv. van uw tuin of tekeningen)</label>
|
{% endif %}
|
||||||
<input id="files" type="file" name="files" multiple required>
|
<form method="post" action="{{ url_for('portal_upload') }}" enctype="multipart/form-data">
|
||||||
<div class="actions"><button class="btn ghost small">Uploaden</button></div>
|
<label class="form-label" for="files">Foto's of documenten delen (bijv. van uw tuin of tekeningen)</label>
|
||||||
</form>
|
<input class="form-control" id="files" type="file" name="files" multiple required>
|
||||||
|
<div class="actions"><button class="btn btn-outline-primary btn-sm">Uploaden</button></div>
|
||||||
|
</form>
|
||||||
|
</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="col-md-6">
|
||||||
<h2>Bericht sturen</h2>
|
<div class="card"><div class="card-body">
|
||||||
<form method="post" action="{{ url_for('portal_message') }}">
|
<h2>Bericht sturen</h2>
|
||||||
<textarea name="body" required aria-label="Bericht" placeholder="Uw vraag of opmerking"></textarea>
|
<form method="post" action="{{ url_for('portal_message') }}">
|
||||||
<div class="actions"><button class="btn">Versturen</button></div>
|
<textarea class="form-control" name="body" required aria-label="Bericht" placeholder="Uw vraag of opmerking"></textarea>
|
||||||
</form>
|
<div class="actions"><button class="btn btn-primary">Versturen</button></div>
|
||||||
{% for m in messages %}
|
</form>
|
||||||
<p class="muted" style="margin:.75rem 0 0">{{ m.created_at|dt }}<br><span style="color:var(--ink);white-space:pre-line">{{ m.body[8:] }}</span></p>
|
{% for m in messages %}
|
||||||
{% endfor %}
|
<p class="muted" style="margin:.75rem 0 0">{{ m.created_at|dt }}<br><span style="color:var(--ink);white-space:pre-line">{{ m.body[8:] }}</span></p>
|
||||||
|
{% endfor %}
|
||||||
|
</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if s.company_phone or s.company_email %}
|
{% if s.company_phone or s.company_email %}
|
||||||
|
|||||||
+31
-31
@@ -4,61 +4,61 @@
|
|||||||
<div class="head">
|
<div class="head">
|
||||||
<div><a class="label" href="{{ url_for('products') }}">← Producten</a><h1>{{ 'Product bewerken' if p.id else 'Nieuw product' }}</h1></div>
|
<div><a class="label" href="{{ url_for('products') }}">← Producten</a><h1>{{ 'Product bewerken' if p.id else 'Nieuw product' }}</h1></div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" enctype="multipart/form-data" class="card">
|
<form method="post" enctype="multipart/form-data" class="card"><div class="card-body">
|
||||||
<div class="fields">
|
<div class="row g-3">
|
||||||
<div class="full"><label for="name">Naam *</label><input id="name" name="name" value="{{ p.name }}" required></div>
|
<div class="col-12"><label class="form-label" for="name">Naam *</label><input class="form-control" id="name" name="name" value="{{ p.name }}" required></div>
|
||||||
<div><label for="sku">SKU / artikelnummer</label><input id="sku" name="sku" value="{{ p.sku }}">
|
<div class="col-md-6"><label class="form-label" for="sku">SKU / artikelnummer</label><input class="form-control" id="sku" name="sku" value="{{ p.sku }}">
|
||||||
<p class="hint">Vaste sleutel: imports en de API werken het product met deze SKU bij.</p></div>
|
<p class="hint">Vaste sleutel: imports en de API werken het product met deze SKU bij.</p></div>
|
||||||
<div><label for="supplier">Leverancier</label><input id="supplier" name="supplier" value="{{ p.supplier }}" list="suppliers" autocomplete="off">
|
<div class="col-md-6"><label class="form-label" for="supplier">Leverancier</label><input class="form-control" id="supplier" name="supplier" value="{{ p.supplier }}" list="suppliers" autocomplete="off">
|
||||||
<datalist id="suppliers">{% for r in suppliers %}<option value="{{ r.name }}">{% endfor %}</datalist>
|
<datalist id="suppliers">{% for r in suppliers %}<option value="{{ r.name }}">{% endfor %}</datalist>
|
||||||
<p class="hint">{% if p.supplier_id %}<a href="{{ url_for('relation_view', rid=p.supplier_id) }}">Naar partner</a> · {% endif %}Een nieuwe naam wordt een partner van het type Leverancier.</p></div>
|
<p class="hint">{% if p.supplier_id %}<a href="{{ url_for('relation_view', rid=p.supplier_id) }}">Naar partner</a> · {% endif %}Een nieuwe naam wordt een partner van het type Leverancier.</p></div>
|
||||||
<div><label for="unit">Eenheid</label><input id="unit" name="unit" value="{{ p.unit }}" placeholder="stuk, m², uur"></div>
|
<div class="col-sm-4"><label class="form-label" for="unit">Eenheid</label><input class="form-control" id="unit" name="unit" value="{{ p.unit }}" placeholder="stuk, m², uur"></div>
|
||||||
<div><label for="price">Prijs excl. btw (€)</label><input id="price" type="number" step="0.01" name="price" value="{{ p.price }}" required></div>
|
<div class="col-sm-4"><label class="form-label" for="price">Prijs excl. btw (€)</label><input class="form-control" id="price" type="number" step="0.01" name="price" value="{{ p.price }}" required inputmode="decimal"></div>
|
||||||
<div><label for="vat">Btw %</label>
|
<div class="col-sm-4"><label class="form-label" for="vat">Btw %</label>
|
||||||
<select id="vat" name="vat">
|
<select class="form-select" id="vat" name="vat">
|
||||||
{% for v in [21, 9, 0] %}<option value="{{ v }}" {{ 'selected' if p.vat == v }}>{{ v }}%</option>{% endfor %}
|
{% for v in [21, 9, 0] %}<option value="{{ v }}" {{ 'selected' if p.vat == v }}>{{ v }}%</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="full"><label for="description">Omschrijving</label><textarea id="description" name="description">{{ p.description }}</textarea>
|
<div class="col-12"><label class="form-label" for="description">Omschrijving</label><textarea class="form-control" id="description" name="description">{{ p.description }}</textarea>
|
||||||
<p class="hint">Wordt standaard onder de productnaam op de offerte gezet.</p></div>
|
<p class="hint">Wordt standaard onder de productnaam op de offerte gezet.</p></div>
|
||||||
<div class="full">
|
<div class="col-12">
|
||||||
<label for="image">Afbeelding</label>
|
<label class="form-label" for="image">Afbeelding</label>
|
||||||
{% if p.image %}<img src="{{ url_for('upload_file', name=p.image) }}" alt="" style="max-width:220px;border-radius:var(--radius-md);display:block;margin-bottom:.75rem">{% endif %}
|
{% if p.image %}<img src="{{ url_for('upload_file', name=p.image) }}" alt="" class="d-block mb-3 img-fluid" style="max-width:220px;border-radius:var(--radius-md)">{% endif %}
|
||||||
<input id="image" type="file" name="image" accept="image/*">
|
<input class="form-control" id="image" type="file" name="image" accept="image/*">
|
||||||
</div>
|
</div>
|
||||||
<div class="full"><label><input type="checkbox" name="active" value="1" {{ 'checked' if p.active }}>Actief (beschikbaar in offertes)</label></div>
|
<div class="col-12"><div class="form-check"><input class="form-check-input" type="checkbox" id="active" name="active" value="1" {{ 'checked' if p.active }}><label class="form-check-label" for="active">Actief (beschikbaar in offertes)</label></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn">Opslaan</button>
|
<button class="btn btn-primary">Opslaan</button>
|
||||||
<a class="btn quiet" href="{{ url_for('products') }}">Annuleren</a>
|
<a class="btn btn-outline-secondary" href="{{ url_for('products') }}">Annuleren</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div></form>
|
||||||
{% if p.id %}
|
{% if p.id %}
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Mutaties</h2>
|
<h2>Mutaties</h2>
|
||||||
<p class="hint" style="margin-top:-.4rem">Bestaande offertes houden hun eigen prijs; wijzigingen gelden voor nieuwe offerteregels.{% if used %} Gebruikt in {{ used }} offerte(s).{% endif %}</p>
|
<p class="hint" style="margin:-.4rem 0 1rem">Bestaande offertes houden hun eigen prijs; wijzigingen gelden voor nieuwe offerteregels.{% if used %} Gebruikt in {{ used }} offerte(s).{% endif %}</p>
|
||||||
{% if history %}
|
{% if history %}
|
||||||
<div class="table-wrap"><table>
|
<div class="table-responsive"><table class="table table-stack">
|
||||||
<thead><tr><th>Datum</th><th>Veld</th><th>Oud</th><th>Nieuw</th><th>Bron</th></tr></thead>
|
<thead><tr><th>Datum</th><th>Veld</th><th>Oud</th><th>Nieuw</th><th>Bron</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for h in history %}
|
{% for h in history %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="muted">{{ h.created_at|dt }}</td>
|
<td class="stack-title muted">{{ h.created_at|dt }}</td>
|
||||||
<td>{{ LABELS.get(h.field, h.field) }}</td>
|
<td data-label="Veld">{{ LABELS.get(h.field, h.field) }}</td>
|
||||||
{% if h.field == 'price' %}<td class="num">{{ h.old_value|float|euro }}</td><td class="num">{{ h.new_value|float|euro }}
|
{% if h.field == 'price' %}<td data-label="Oud" class="num">{{ h.old_value|float|euro }}</td><td data-label="Nieuw" class="num"><span>{{ h.new_value|float|euro }}
|
||||||
{% set diff = h.new_value|float - h.old_value|float %}{% if h.old_value|float %}<span class="muted">({{ '+' if diff > 0 }}{{ (diff / h.old_value|float * 100)|round(1)|nr }}%)</span>{% endif %}</td>
|
{% set diff = h.new_value|float - h.old_value|float %}{% if h.old_value|float %}<span class="muted">({{ '+' if diff > 0 }}{{ (diff / h.old_value|float * 100)|round(1)|nr }}%)</span>{% endif %}</span></td>
|
||||||
{% elif h.field == 'created' %}<td></td><td class="num">{{ h.new_value|float|euro }}</td>
|
{% elif h.field == 'created' %}<td data-label="Oud"></td><td data-label="Nieuw" class="num">{{ h.new_value|float|euro }}</td>
|
||||||
{% elif h.field == 'active' %}<td>{{ 'ja' if h.old_value == '1' else 'nee' }}</td><td>{{ 'ja' if h.new_value == '1' else 'nee' }}</td>
|
{% elif h.field == 'active' %}<td data-label="Oud">{{ 'ja' if h.old_value == '1' else 'nee' }}</td><td data-label="Nieuw">{{ 'ja' if h.new_value == '1' else 'nee' }}</td>
|
||||||
{% else %}<td>{{ h.old_value|truncate(60) }}</td><td>{{ h.new_value|truncate(60) }}</td>{% endif %}
|
{% else %}<td data-label="Oud">{{ h.old_value|truncate(60) }}</td><td data-label="Nieuw">{{ h.new_value|truncate(60) }}</td>{% endif %}
|
||||||
<td class="muted">{{ h.source }}{% if h.user_name %} · {{ h.user_name }}{% endif %}</td>
|
<td data-label="Bron" class="muted">{{ h.source }}{% if h.user_name %} · {{ h.user_name }}{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table></div>
|
</table></div>
|
||||||
{% else %}<p class="empty">Nog geen mutaties.</p>{% endif %}
|
{% else %}<p class="empty">Nog geen mutaties.</p>{% endif %}
|
||||||
</div>
|
</div></div>
|
||||||
<form method="post" action="{{ url_for('product_delete', pid=p.id) }}" onsubmit="return confirm('{{ 'Dit product staat in offertes en wordt op inactief gezet.' if used else 'Product verwijderen?' }}')">
|
<form method="post" action="{{ url_for('product_delete', pid=p.id) }}" onsubmit="return confirm('{{ 'Dit product staat in offertes en wordt op inactief gezet.' if used else 'Product verwijderen?' }}')">
|
||||||
<button class="btn quiet small">{{ 'Archiveren' if used else 'Product verwijderen' }}</button>
|
<button class="btn btn-outline-secondary btn-sm">{{ 'Archiveren' if used else 'Product verwijderen' }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+22
-17
@@ -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">
|
||||||
@@ -6,11 +7,11 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<form class="search" method="get">
|
<form class="search" method="get">
|
||||||
<input type="hidden" name="show" value="{{ show }}">
|
<input type="hidden" name="show" value="{{ show }}">
|
||||||
<input 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 ghost" 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 ghost" 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" href="{{ url_for('product_edit') }}">Nieuw product</a>
|
<a class="btn btn-primary" href="{{ url_for('product_edit') }}">Nieuw product</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
@@ -19,26 +20,30 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
<span class="muted">{{ rows|length }} product(en)</span>
|
<span class="muted">{{ rows|length }} product(en)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
{% if rows %}
|
{% if rows %}
|
||||||
<div class="table-wrap"><table>
|
<div class="table-responsive"><table class="table table-hover table-stack">
|
||||||
<thead><tr><th></th><th>Naam</th><th>SKU</th><th>Leverancier</th><th>Eenheid</th><th class="num">Prijs excl.</th><th class="num">Btw</th><th>Bijgewerkt</th><th>Status</th></tr></thead>
|
<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) }}'">
|
||||||
<td style="width:64px">{% if p.image %}<img class="thumb-sm" src="{{ url_for('upload_file', name=p.image) }}" alt="">{% else %}<span class="thumb-sm"></span>{% endif %}</td>
|
{{ bulk_cell(p.id, p.name) }}
|
||||||
<td><a href="{{ url_for('product_edit', pid=p.id) }}">{{ p.name }}</a><div class="muted">{{ p.description|truncate(90) }}</div></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><code>{{ p.sku }}</code></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>{% if p.supplier_id %}<a href="{{ url_for('relation_view', rid=p.supplier_id) }}" onclick="event.stopPropagation()">{{ p.supplier }}</a>{% else %}{{ p.supplier }}{% endif %}</td>
|
<td data-label="SKU"><code>{{ p.sku }}</code></td>
|
||||||
<td>{{ p.unit }}</td>
|
<td data-label="Leverancier">{% if p.supplier_id %}<a href="{{ url_for('relation_view', rid=p.supplier_id) }}" onclick="event.stopPropagation()">{{ p.supplier }}</a>{% else %}{{ p.supplier }}{% endif %}</td>
|
||||||
<td class="num">{{ p.price|euro }}</td>
|
<td data-label="Eenheid">{{ p.unit }}</td>
|
||||||
<td class="num">{{ p.vat|nr }}%</td>
|
<td data-label="Prijs excl." class="num">{{ p.price|euro }}</td>
|
||||||
<td class="muted">{{ (p.updated_at or p.created_at)|dt('%d-%m-%Y') }}</td>
|
<td data-label="Btw" class="num">{{ p.vat|nr }}%</td>
|
||||||
<td>{% if not p.active %}<span class="chip verloren">Inactief</span>{% endif %}</td>
|
<td data-label="Bijgewerkt" class="muted">{{ (p.updated_at or p.created_at)|dt('%d-%m-%Y') }}</td>
|
||||||
|
<td data-label="Status">{% if not p.active %}<span class="chip verloren">Inactief</span>{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</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>
|
||||||
|
{% 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 %}
|
||||||
|
|||||||
+54
-31
@@ -2,8 +2,8 @@
|
|||||||
{% block title %}Offerte {{ qt.number }}{% endblock %}
|
{% block title %}Offerte {{ qt.number }}{% endblock %}
|
||||||
{% macro row(l) %}
|
{% macro row(l) %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td data-label="Product">
|
||||||
<select name="l_product" class="prod" aria-label="Product">
|
<select name="l_product" class="prod form-select form-select-sm" aria-label="Product">
|
||||||
<option value="">Vrije regel</option>
|
<option value="">Vrije regel</option>
|
||||||
{% for p in products %}
|
{% for p in products %}
|
||||||
<option value="{{ p.id }}" {{ 'selected' if l.product_id == p.id }}
|
<option value="{{ p.id }}" {{ 'selected' if l.product_id == p.id }}
|
||||||
@@ -12,14 +12,14 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td><textarea name="l_desc" rows="1" aria-label="Omschrijving">{{ l.description }}</textarea></td>
|
<td data-label="Omschrijving"><textarea class="form-control form-control-sm" name="l_desc" rows="1" aria-label="Omschrijving">{{ l.description }}</textarea></td>
|
||||||
<td><input type="number" step="any" name="l_qty" value="{{ l.qty|default(1) }}" aria-label="Aantal"></td>
|
<td data-label="Aantal"><input class="form-control form-control-sm" type="number" step="any" inputmode="decimal" name="l_qty" value="{{ l.qty|default(1) }}" aria-label="Aantal"></td>
|
||||||
<td><input name="l_unit" value="{{ l.unit }}" aria-label="Eenheid"></td>
|
<td data-label="Eenheid"><input class="form-control form-control-sm" name="l_unit" value="{{ l.unit }}" aria-label="Eenheid"></td>
|
||||||
<td><input type="number" step="0.01" name="l_price" value="{{ l.price }}" aria-label="Prijs">
|
<td data-label="Prijs excl."><input class="form-control form-control-sm" type="number" step="0.01" inputmode="decimal" name="l_price" value="{{ l.price }}" aria-label="Prijs">
|
||||||
{% if l.product_id and prices.get(l.product_id) is not none and prices[l.product_id] != l.price %}<p class="hint" style="margin:.2rem 0 0">Actueel: {{ prices[l.product_id]|euro }}</p>{% endif %}</td>
|
{% if l.product_id and prices.get(l.product_id) is not none and prices[l.product_id] != l.price %}<p class="hint" style="margin:.2rem 0 0">Actueel: {{ prices[l.product_id]|euro }}</p>{% endif %}</td>
|
||||||
<td><select name="l_vat" aria-label="Btw">{% for v in [21, 9, 0] %}<option value="{{ v }}" {{ 'selected' if l.vat|default(21) == v }}>{{ v }}%</option>{% endfor %}</select></td>
|
<td data-label="Btw"><select class="form-select form-select-sm" name="l_vat" aria-label="Btw">{% for v in [21, 9, 0] %}<option value="{{ v }}" {{ 'selected' if l.vat|default(21) == v }}>{{ v }}%</option>{% endfor %}</select></td>
|
||||||
<td class="num amt"></td>
|
<td class="num amt" data-label="Totaal"></td>
|
||||||
<td><button type="button" class="linkbtn del" aria-label="Regel verwijderen">✕</button></td>
|
<td class="del-cell"><button type="button" class="linkbtn del" aria-label="Regel verwijderen">✕</button></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -27,52 +27,75 @@
|
|||||||
<div class="head">
|
<div class="head">
|
||||||
<div><a class="label" href="{{ url_for('quotes') }}">← Offertes</a><h1>Offerte {{ qt.number }}</h1></div>
|
<div><a class="label" href="{{ url_for('quotes') }}">← Offertes</a><h1>Offerte {{ qt.number }}</h1></div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn ghost" name="then" value="print">Opslaan en afdrukken</button>
|
<button class="btn btn-outline-primary" name="then" value="print">Opslaan en afdrukken</button>
|
||||||
<button class="btn">Opslaan</button>
|
<button class="btn btn-primary">Opslaan</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
<div class="fields">
|
<div class="row g-3">
|
||||||
<div><label for="contact">Klant</label>
|
<div class="col-md-6"><label class="form-label" for="contact">Klant</label>
|
||||||
<select id="contact" name="contact_id">
|
<select class="form-select" id="contact" name="contact_id">
|
||||||
<option value="">— Kies een contact —</option>
|
<option value="">— Kies een contact —</option>
|
||||||
{% for c in contacts %}<option value="{{ c.id }}" {{ 'selected' if c.id == qt.contact_id }}>{{ c.name }}{% if c.company %} ({{ c.company }}){% endif %}</option>{% endfor %}
|
{% for c in contacts %}<option value="{{ c.id }}" {{ 'selected' if c.id == qt.contact_id }}>{{ c.name }}{% if c.company %} ({{ c.company }}){% endif %}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div><label for="status">Status</label>
|
<div class="col-md-6"><label class="form-label" for="status">Status</label>
|
||||||
<select id="status" name="status">{% for k, l in QUOTE_STATUSES %}<option value="{{ k }}" {{ 'selected' if qt.status == k }}>{{ l }}</option>{% endfor %}</select>
|
<select class="form-select" id="status" name="status">{% for k, l in QUOTE_STATUSES %}<option value="{{ k }}" {{ 'selected' if qt.status == k }}>{{ l }}</option>{% endfor %}</select>
|
||||||
<p class="hint">Op "Verzonden" zetten = zichtbaar in het klantportaal.</p>
|
<p class="hint">Op "Verzonden" zetten = zichtbaar in het klantportaal.</p>
|
||||||
</div>
|
</div>
|
||||||
<div><label for="date">Datum</label><input id="date" type="date" name="date" value="{{ qt.date }}"></div>
|
<div class="col-6"><label class="form-label" for="date">Datum</label><input class="form-control" id="date" type="date" name="date" value="{{ qt.date }}"></div>
|
||||||
<div><label for="valid">Geldig tot</label><input id="valid" type="date" name="valid_until" value="{{ qt.valid_until or '' }}"></div>
|
<div class="col-6"><label class="form-label" for="valid">Geldig tot</label><input class="form-control" id="valid" type="date" name="valid_until" value="{{ qt.valid_until or '' }}"></div>
|
||||||
<div class="full"><label for="title">Onderwerp</label><input id="title" name="title" value="{{ qt.title }}" placeholder="Bijv. Mantelzorgwoning model Amstel"></div>
|
<div class="col-12"><label class="form-label" for="title">Onderwerp</label><input class="form-control" id="title" name="title" value="{{ qt.title }}" placeholder="Bijv. Mantelzorgwoning model Amstel"></div>
|
||||||
<div class="full"><label for="intro">Inleiding</label><textarea id="intro" name="intro">{{ qt.intro }}</textarea></div>
|
<div class="col-12"><label class="form-label" for="intro">Inleiding</label><textarea class="form-control" id="intro" name="intro">{{ qt.intro }}</textarea></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div></div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Regels</h2>
|
<h2>Regels</h2>
|
||||||
<div class="table-wrap"><table class="lines">
|
<div class="table-responsive"><table class="table lines">
|
||||||
<thead><tr><th style="min-width:170px">Product</th><th style="min-width:240px">Omschrijving</th><th style="width:90px">Aantal</th><th style="width:100px">Eenheid</th><th style="width:130px">Prijs excl.</th><th style="width:90px">Btw</th><th class="num" style="width:120px">Totaal</th><th></th></tr></thead>
|
<thead><tr><th style="min-width:170px">Product</th><th style="min-width:240px">Omschrijving</th><th style="width:90px">Aantal</th><th style="width:100px">Eenheid</th><th style="width:130px">Prijs excl.</th><th style="width:90px">Btw</th><th class="num" style="width:120px">Totaal</th><th></th></tr></thead>
|
||||||
<tbody id="rows">{% for l in lines %}{{ row(l) }}{% endfor %}</tbody>
|
<tbody id="rows">{% for l in lines %}{{ row(l) }}{% endfor %}</tbody>
|
||||||
</table></div>
|
</table></div>
|
||||||
<div class="actions"><button type="button" class="btn ghost small" id="add">Regel toevoegen</button></div>
|
<div class="actions"><button type="button" class="btn btn-outline-primary btn-sm" id="add">Regel toevoegen</button></div>
|
||||||
<table class="totals">
|
<table class="totals">
|
||||||
<tr><td>Subtotaal</td><td class="num" id="t-sub"></td></tr>
|
<tr><td>Subtotaal</td><td class="num" id="t-sub"></td></tr>
|
||||||
<tbody id="t-vat"></tbody>
|
<tbody id="t-vat"></tbody>
|
||||||
<tr class="grand"><td>Totaal</td><td class="num" id="t-total"></td></tr>
|
<tr class="grand"><td>Totaal</td><td class="num" id="t-total"></td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div></div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
<label for="notes">Opmerkingen en voorwaarden</label>
|
<label class="form-label" for="notes">Opmerkingen en voorwaarden</label>
|
||||||
<textarea id="notes" name="notes">{{ qt.notes }}</textarea>
|
<textarea class="form-control" id="notes" name="notes">{{ qt.notes }}</textarea>
|
||||||
</div>
|
</div></div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div class="card"><div class="card-body">
|
||||||
|
<div class="card-top">
|
||||||
|
<h2>Bestek uit ontwerp</h2>
|
||||||
|
<div class="actions">
|
||||||
|
{% if bestek %}
|
||||||
|
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('quote_print', qid=qt.id) }}#bestek">Bekijken</a>
|
||||||
|
{% if designs %}<a class="btn btn-outline-primary btn-sm" href="{{ url_for('quote_bestek', qid=qt.id) }}">Opnieuw berekenen</a>{% endif %}
|
||||||
|
<form method="post" action="{{ url_for('quote_bestek_delete', qid=qt.id) }}" class="inline" onsubmit="return confirm('Bestek loskoppelen? De offerteregels blijven staan.')"><button class="btn btn-outline-secondary btn-sm">Loskoppelen</button></form>
|
||||||
|
{% elif designs %}
|
||||||
|
<a class="btn btn-primary btn-sm" href="{{ url_for('quote_bestek', qid=qt.id) }}">Bestek maken</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if bestek %}
|
||||||
|
<p class="muted" style="margin:0">{{ bestek.design.title }} · berekend {{ bestek.computedAt[:19]|dt }} · {{ bestek.chapters|length }} hoofdstukken ·
|
||||||
|
{{ bestek.totals.exBtw|euro }} excl. btw ({{ bestek.totals.inclBtw|euro }} incl.). Het bestek staat als bijlage bij de offerte, ook in het klantportaal.</p>
|
||||||
|
{% elif designs %}
|
||||||
|
<p class="muted" style="margin:0">Hoeveelheden en prijzen uit het gekozen model van de klant (gevel, dak, kozijnen, stopcontacten, leidingen, …) als offerteregels en bijlage.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty" style="padding:0">Kies (en sla op) een klant met een gekozen model om een bestek uit het ontwerp te maken.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div></div>
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('quote_delete', qid=qt.id) }}" onsubmit="return confirm('Offerte verwijderen?')">
|
<form method="post" action="{{ url_for('quote_delete', qid=qt.id) }}" onsubmit="return confirm('Offerte verwijderen?')">
|
||||||
<button class="btn quiet small">Offerte verwijderen</button>
|
<button class="btn btn-outline-secondary btn-sm">Offerte verwijderen</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<template id="tpl">{{ row({}) }}</template>
|
<template id="tpl">{{ row({}) }}</template>
|
||||||
|
|||||||
@@ -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
-127
@@ -1,129 +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; }
|
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
{% include '_quote_doc.html' %}
|
||||||
</html>
|
{% endblock %}
|
||||||
|
|||||||
+20
-11
@@ -1,31 +1,40 @@
|
|||||||
{% 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>
|
||||||
<form method="post" action="{{ url_for('quote_new') }}" class="inline"><button class="btn">Nieuwe offerte</button></form>
|
<div class="actions">
|
||||||
|
<a class="btn btn-outline-primary" href="{{ url_for('quotes_export', **request.args) }}" title="Alle velden van de offertes in deze lijst">Exporteer CSV</a>
|
||||||
|
<form method="post" action="{{ url_for('quote_new') }}" class="inline"><button class="btn btn-primary">Nieuwe offerte</button></form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<a href="{{ url_for('quotes') }}" class="{{ 'on' if not status }}">Alle</a>
|
<a href="{{ url_for('quotes') }}" class="{{ 'on' if not status }}">Alle</a>
|
||||||
{% for k, l in QUOTE_STATUSES %}<a href="{{ url_for('quotes', status=k) }}" class="{{ 'on' if status == k }}">{{ l }}</a>{% endfor %}
|
{% for k, l in QUOTE_STATUSES %}<a href="{{ url_for('quotes', status=k) }}" class="{{ 'on' if status == k }}">{{ l }}</a>{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
{% if rows %}
|
{% if rows %}
|
||||||
<div class="table-wrap"><table>
|
<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) }}'">
|
||||||
<td><a href="{{ url_for('quote_edit', qid=qt.id) }}">{{ qt.number }}</a></td>
|
{{ bulk_cell(qt.id, qt.number) }}
|
||||||
<td>{{ qt.contact_name or '—' }}</td>
|
<td class="stack-title"><a href="{{ url_for('quote_edit', qid=qt.id) }}">{{ qt.number }}</a></td>
|
||||||
<td>{{ qt.title }}</td>
|
<td data-label="Klant">{{ qt.contact_name or '—' }}</td>
|
||||||
<td>{{ qt.date|dt('%d-%m-%Y') }}</td>
|
<td data-label="Onderwerp">{{ qt.title }}</td>
|
||||||
<td><span class="chip {{ qt.status }}">{{ QSTATUS.get(qt.status, qt.status) }}</span></td>
|
<td data-label="Datum">{{ qt.date|dt('%d-%m-%Y') }}</td>
|
||||||
<td class="num">{{ qt.total|euro }}</td>
|
<td data-label="Status"><span class="chip {{ qt.status }}">{{ QSTATUS.get(qt.status, qt.status) }}</span></td>
|
||||||
|
<td data-label="Totaal incl." class="num">{{ qt.total|euro }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</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>
|
||||||
|
{% 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 %}
|
||||||
|
|||||||
+64
-32
@@ -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">
|
||||||
@@ -11,16 +13,16 @@
|
|||||||
{% if r.kind == 'klant' and members %}
|
{% if r.kind == 'klant' and members %}
|
||||||
<form method="post" action="{{ url_for('quote_new') }}" class="inline">
|
<form method="post" action="{{ url_for('quote_new') }}" class="inline">
|
||||||
<input type="hidden" name="contact_id" value="{{ members[0].id }}">
|
<input type="hidden" name="contact_id" value="{{ members[0].id }}">
|
||||||
<button class="btn ghost">Nieuwe offerte</button>
|
<button class="btn btn-outline-primary">Nieuwe offerte</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a class="btn" href="{{ url_for('relation_edit', rid=r.id) }}">Bewerken</a>
|
<a class="btn btn-primary" href="{{ url_for('relation_edit', rid=r.id) }}">Bewerken</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="two">
|
<div class="row gx-4 stack">
|
||||||
<div>
|
<div class="col-lg-7 col-xl-8">
|
||||||
<div class="card">
|
<div class="card o-2"><div class="card-body">
|
||||||
<h2>Contactpersonen</h2>
|
<h2>Contactpersonen</h2>
|
||||||
<div class="members">
|
<div class="members">
|
||||||
{% for m in members %}
|
{% for m in members %}
|
||||||
@@ -28,9 +30,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<a href="{{ url_for('contact_view', cid=m.id) }}">{{ m.name }}</a>
|
<a href="{{ url_for('contact_view', cid=m.id) }}">{{ m.name }}</a>
|
||||||
{% if m.relation_role %}<span class="muted">· {{ m.relation_role }}</span>{% endif %}
|
{% if m.relation_role %}<span class="muted">· {{ m.relation_role }}</span>{% endif %}
|
||||||
<div class="muted">{{ [m.email, m.phone]|select|join(' · ') }}</div>
|
<div class="muted" style="overflow-wrap:anywhere">{{ [m.email, m.phone]|select|join(' · ') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions" style="margin:0">
|
<div class="d-flex align-items-center gap-3">
|
||||||
{% if m.type != 'partner' %}<span class="chip {{ m.status }}">{{ STATUS.get(m.status) }}</span>{% endif %}
|
{% if m.type != 'partner' %}<span class="chip {{ m.status }}">{{ STATUS.get(m.status) }}</span>{% endif %}
|
||||||
<form method="post" action="{{ url_for('relation_member_remove', rid=r.id, cid=m.id) }}" class="inline" onsubmit="return confirm('{{ m.name }} loskoppelen van {{ r.name }}?')">
|
<form method="post" action="{{ url_for('relation_member_remove', rid=r.id, cid=m.id) }}" class="inline" onsubmit="return confirm('{{ m.name }} loskoppelen van {{ r.name }}?')">
|
||||||
<button class="linkbtn">Loskoppelen</button>
|
<button class="linkbtn">Loskoppelen</button>
|
||||||
@@ -41,41 +43,66 @@
|
|||||||
<p class="empty">Nog geen contactpersonen.</p>
|
<p class="empty">Nog geen contactpersonen.</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="{{ url_for('relation_member', rid=r.id) }}" class="actions">
|
<form method="post" action="{{ url_for('relation_member', rid=r.id) }}" class="row g-2 align-items-center mt-3">
|
||||||
{% if candidates %}
|
{% if candidates %}
|
||||||
<select name="contact_id" style="width:auto;flex:1;min-width:180px" aria-label="Bestaand contact" required>
|
<div class="col-md">
|
||||||
<option value="">Bestaand contact toevoegen…</option>
|
<select class="form-select" name="contact_id" aria-label="Bestaand contact" required>
|
||||||
{% for c in candidates %}<option value="{{ c.id }}">{{ c.name }}{% if c.email %} ({{ c.email }}){% endif %}</option>{% endfor %}
|
<option value="">Bestaand contact toevoegen…</option>
|
||||||
</select>
|
{% for c in candidates %}<option value="{{ c.id }}">{{ c.name }}{% if c.email %} ({{ c.email }}){% endif %}</option>{% endfor %}
|
||||||
<input name="relation_role" placeholder="Rol" style="width:160px" aria-label="Rol">
|
</select>
|
||||||
<button class="btn ghost small">Toevoegen</button>
|
</div>
|
||||||
|
<div class="col-8 col-md-3"><input class="form-control" name="relation_role" placeholder="Rol" aria-label="Rol"></div>
|
||||||
|
<div class="col-4 col-md-auto"><button class="btn btn-outline-primary w-100">Toevoegen</button></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a class="btn small" href="{{ url_for('contact_new', relation_id=r.id, company=r.name if r.kind == 'partner' else None) }}">Nieuwe contactpersoon</a>
|
<div class="col-12"><a class="btn btn-primary btn-sm" href="{{ url_for('contact_new', relation_id=r.id, company=r.name if r.kind == 'partner' else None) }}">Nieuwe contactpersoon</a></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div></div>
|
||||||
|
|
||||||
|
{% if designs %}
|
||||||
|
<div class="card"><div class="card-body">
|
||||||
|
<h2>Woningen en tekeningen</h2>
|
||||||
|
<div class="table-responsive"><table class="table table-stack">
|
||||||
|
<thead><tr><th>Woning</th><th>Contact</th><th>Versie</th><th>Tekeningen</th><th>Gewijzigd</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in designs %}{% set p = pdfs.get(d.id, {}) %}
|
||||||
|
<tr>
|
||||||
|
<td class="stack-title"><a href="{{ url_for('design_viewer', fid=d.latest.id) }}" target="_blank">{{ d.title }}</a>{% if d.chosen %} <span class="chip">Gekozen woning</span>{% endif %}</td>
|
||||||
|
<td data-label="Contact">{% if d.contact_id %}<a href="{{ url_for('contact_view', cid=d.contact_id) }}">{{ d.contact_name }}</a>{% else %}<span class="muted">relatie</span>{% endif %}</td>
|
||||||
|
<td data-label="Versie">{{ d.latest.version }}</td>
|
||||||
|
<td data-label="Tekeningen"><span>{% for k, label in PDF_KINDS.items() %}{% if p.get(k) %}<a href="{{ p[k].url }}" target="_blank">{{ (label[:1]|upper) ~ label[1:] }}</a>{% if not p[k].current %} <span class="muted">(v{{ p[k].version }}, ouder)</span>{% endif %}{% if not loop.last %} · {% endif %}{% endif %}{% endfor %}
|
||||||
|
{% if not p %}<span class="muted">Wordt gemaakt bij openen van de woning</span>{% endif %}</span></td>
|
||||||
|
<td data-label="Gewijzigd" class="muted">{{ d.latest.created_at|dt('%d-%m-%Y') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table></div>
|
||||||
|
</div></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if kaart %}{{ kaart_block(kaart) }}{% endif %}
|
||||||
|
|
||||||
{% if r.kind == 'partner' %}
|
{% if r.kind == 'partner' %}
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Producten van deze leverancier</h2>
|
<h2>Producten van deze leverancier</h2>
|
||||||
{% if products %}
|
{% if products %}
|
||||||
<div class="table-wrap"><table>
|
<div class="table-responsive"><table class="table table-hover table-stack">
|
||||||
<thead><tr><th>SKU</th><th>Naam</th><th class="num">Prijs excl.</th><th>Bijgewerkt</th></tr></thead>
|
<thead><tr><th>SKU</th><th>Naam</th><th class="num">Prijs excl.</th><th>Bijgewerkt</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for p in products %}
|
{% for p in products %}
|
||||||
<tr class="link" onclick="location='{{ url_for('product_edit', pid=p.id) }}'">
|
<tr class="link" onclick="location='{{ url_for('product_edit', pid=p.id) }}'">
|
||||||
<td><code>{{ p.sku }}</code></td>
|
<td data-label="SKU"><code>{{ p.sku }}</code></td>
|
||||||
<td><a href="{{ url_for('product_edit', pid=p.id) }}">{{ p.name }}</a>{% if not p.active %} <span class="chip verloren">Inactief</span>{% endif %}</td>
|
<td class="stack-title"><a href="{{ url_for('product_edit', pid=p.id) }}">{{ p.name }}</a>{% if not p.active %} <span class="chip verloren">Inactief</span>{% endif %}</td>
|
||||||
<td class="num">{{ p.price|euro }}</td>
|
<td data-label="Prijs excl." class="num">{{ p.price|euro }}</td>
|
||||||
<td class="muted">{{ (p.updated_at or p.created_at)|dt('%d-%m-%Y') }}</td>
|
<td data-label="Bijgewerkt" class="muted">{{ (p.updated_at or p.created_at)|dt('%d-%m-%Y') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table></div>
|
</table></div>
|
||||||
{% else %}<p class="empty">Nog geen producten gekoppeld. Vul deze naam in als leverancier bij een product of in een import.</p>{% endif %}
|
{% else %}<p class="empty">Nog geen producten gekoppeld. Vul deze naam in als leverancier bij een product of in een import.</p>{% endif %}
|
||||||
</div>
|
</div></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Recente activiteit</h2>
|
<h2>Recente activiteit</h2>
|
||||||
<ul class="timeline">
|
<ul class="timeline">
|
||||||
{% for a in activities %}
|
{% for a in activities %}
|
||||||
@@ -87,11 +114,11 @@
|
|||||||
<li class="empty">Nog geen activiteit.</li>
|
<li class="empty">Nog geen activiteit.</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="col-lg-5 col-xl-4">
|
||||||
<div class="card">
|
<div class="card o-1"><div class="card-body">
|
||||||
<h2>Gegevens</h2>
|
<h2>Gegevens</h2>
|
||||||
<dl class="props">
|
<dl class="props">
|
||||||
{% if r.type %}<dt>Type</dt><dd>{{ r.type }}</dd>{% endif %}
|
{% if r.type %}<dt>Type</dt><dd>{{ r.type }}</dd>{% endif %}
|
||||||
@@ -103,10 +130,15 @@
|
|||||||
<dt>Aangemaakt</dt><dd>{{ r.created_at|dt('%d-%m-%Y') }}</dd>
|
<dt>Aangemaakt</dt><dd>{{ r.created_at|dt('%d-%m-%Y') }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
{% 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 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"><div class="card-body">
|
||||||
<h2>Open taken</h2>
|
<h2>Open taken</h2>
|
||||||
{% for t in tasks %}
|
{% for t in tasks %}
|
||||||
<form method="post" action="{{ url_for('task_done', tid=t.id) }}" class="task">
|
<form method="post" action="{{ url_for('task_done', tid=t.id) }}" class="task">
|
||||||
@@ -114,11 +146,11 @@
|
|||||||
<div><div>{{ t.title }}</div><div class="muted">{{ t.contact_name }}{% if t.due_date %} · <span class="{{ 'overdue' if t.due_date < today }}">{{ t.due_date|dt('%d-%m-%Y') }}</span>{% endif %}</div></div>
|
<div><div>{{ t.title }}</div><div class="muted">{{ t.contact_name }}{% if t.due_date %} · <span class="{{ 'overdue' if t.due_date < today }}">{{ t.due_date|dt('%d-%m-%Y') }}</span>{% endif %}</div></div>
|
||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if r.kind == 'klant' %}
|
{% if r.kind == 'klant' %}
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Offertes</h2>
|
<h2>Offertes</h2>
|
||||||
{% for qt in quotes %}
|
{% for qt in quotes %}
|
||||||
<p style="margin:0 0 .6rem"><a href="{{ url_for('quote_edit', qid=qt.id) }}">{{ qt.number }}</a> <span class="chip {{ qt.status }}">{{ QSTATUS.get(qt.status) }}</span><br>
|
<p style="margin:0 0 .6rem"><a href="{{ url_for('quote_edit', qid=qt.id) }}">{{ qt.number }}</a> <span class="chip {{ qt.status }}">{{ QSTATUS.get(qt.status) }}</span><br>
|
||||||
@@ -126,7 +158,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<p class="empty">Nog geen offertes.</p>
|
<p class="empty">Nog geen offertes.</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,33 +7,33 @@
|
|||||||
<h1>{{ 'Relatie bewerken' if r.id else ('Nieuwe ' ~ RKIND.get(r.kind, 'relatie')|lower) }}</h1>
|
<h1>{{ 'Relatie bewerken' if r.id else ('Nieuwe ' ~ RKIND.get(r.kind, 'relatie')|lower) }}</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" class="card">
|
<form method="post" class="card"><div class="card-body">
|
||||||
<div class="fields">
|
<div class="row g-3">
|
||||||
<div class="full"><label for="name">Naam *</label><input id="name" name="name" value="{{ r.name }}" required placeholder="{{ 'Bijv. Familie Jansen' if r.kind == 'klant' else 'Bijv. Houthandel De Vries' }}"></div>
|
<div class="col-12"><label class="form-label" for="name">Naam *</label><input class="form-control" id="name" name="name" value="{{ r.name }}" required placeholder="{{ 'Bijv. Familie Jansen' if r.kind == 'klant' else 'Bijv. Houthandel De Vries' }}"></div>
|
||||||
<div><label for="kind">Soort</label>
|
<div class="col-md-6"><label class="form-label" for="kind">Soort</label>
|
||||||
<select id="kind" name="kind">{% for k, l in RELATION_KINDS %}<option value="{{ k }}" {{ 'selected' if r.kind == k }}>{{ l }}</option>{% endfor %}</select>
|
<select class="form-select" id="kind" name="kind">{% for k, l in RELATION_KINDS %}<option value="{{ k }}" {{ 'selected' if r.kind == k }}>{{ l }}</option>{% endfor %}</select>
|
||||||
</div>
|
</div>
|
||||||
<div><label for="type">Type</label>
|
<div class="col-md-6"><label class="form-label" for="type">Type</label>
|
||||||
<input id="type" name="type" value="{{ r.type }}" list="types" autocomplete="off">
|
<input class="form-control" id="type" name="type" value="{{ r.type }}" list="types" autocomplete="off">
|
||||||
<datalist id="types">{% for t in types %}<option value="{{ t }}">{% endfor %}</datalist>
|
<datalist id="types">{% for t in types %}<option value="{{ t }}">{% endfor %}</datalist>
|
||||||
</div>
|
</div>
|
||||||
<div><label for="email">E-mail</label><input id="email" type="email" name="email" value="{{ r.email }}"></div>
|
<div class="col-md-6"><label class="form-label" for="email">E-mail</label><input class="form-control" id="email" type="email" name="email" value="{{ r.email }}"></div>
|
||||||
<div><label for="phone">Telefoon</label><input id="phone" name="phone" value="{{ r.phone }}"></div>
|
<div class="col-md-6"><label class="form-label" for="phone">Telefoon</label><input class="form-control" id="phone" type="tel" name="phone" value="{{ r.phone }}"></div>
|
||||||
<div class="full"><label for="address">Adres</label><input id="address" name="address" value="{{ r.address }}"></div>
|
<div class="col-12"><label class="form-label" for="address">Adres</label><input class="form-control" id="address" name="address" value="{{ r.address }}"></div>
|
||||||
<div><label for="postcode">Postcode</label><input id="postcode" name="postcode" value="{{ r.postcode }}"></div>
|
<div class="col-sm-4"><label class="form-label" for="postcode">Postcode</label><input class="form-control" id="postcode" name="postcode" value="{{ r.postcode }}"></div>
|
||||||
<div><label for="city">Plaats</label><input id="city" name="city" value="{{ r.city }}"></div>
|
<div class="col-sm-8"><label class="form-label" for="city">Plaats</label><input class="form-control" id="city" name="city" value="{{ r.city }}"></div>
|
||||||
<div><label for="website">Website</label><input id="website" name="website" value="{{ r.website }}"></div>
|
<div class="col-md-6"><label class="form-label" for="website">Website</label><input class="form-control" id="website" name="website" value="{{ r.website }}"></div>
|
||||||
<div><label for="kvk">KvK-nummer</label><input id="kvk" name="kvk" value="{{ r.kvk }}"></div>
|
<div class="col-md-6"><label class="form-label" for="kvk">KvK-nummer</label><input class="form-control" id="kvk" name="kvk" value="{{ r.kvk }}"></div>
|
||||||
<div class="full"><label for="notes">Notities</label><textarea id="notes" name="notes">{{ r.notes }}</textarea></div>
|
<div class="col-12"><label class="form-label" for="notes">Notities</label><textarea class="form-control" id="notes" name="notes">{{ r.notes }}</textarea></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn">Opslaan</button>
|
<button class="btn btn-primary">Opslaan</button>
|
||||||
<a class="btn quiet" href="{{ url_for('relation_view', rid=r.id) if r.id else url_for('relations', kind=r.kind) }}">Annuleren</a>
|
<a class="btn btn-outline-secondary" href="{{ url_for('relation_view', rid=r.id) if r.id else url_for('relations', kind=r.kind) }}">Annuleren</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div></form>
|
||||||
{% if r.id %}
|
{% if r.id %}
|
||||||
<form method="post" action="{{ url_for('relation_delete', rid=r.id) }}" onsubmit="return confirm('Relatie verwijderen? Contactpersonen en producten blijven bestaan.')">
|
<form method="post" action="{{ url_for('relation_delete', rid=r.id) }}" onsubmit="return confirm('Relatie verwijderen? Contactpersonen en producten blijven bestaan.')">
|
||||||
<button class="btn quiet small">Relatie verwijderen</button>
|
<button class="btn btn-outline-secondary btn-sm">Relatie verwijderen</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+21
-13
@@ -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">
|
||||||
@@ -11,10 +12,11 @@
|
|||||||
<form class="search" method="get">
|
<form class="search" method="get">
|
||||||
<input type="hidden" name="kind" value="{{ kind }}">
|
<input type="hidden" name="kind" value="{{ kind }}">
|
||||||
{% if type %}<input type="hidden" name="type" value="{{ type }}">{% endif %}
|
{% if type %}<input type="hidden" name="type" value="{{ type }}">{% endif %}
|
||||||
<input type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, plaats, persoon…" aria-label="Zoeken">
|
<input class="form-control" type="search" name="q" value="{{ term }}" placeholder="Zoek op naam, plaats, persoon…" aria-label="Zoeken">
|
||||||
</form>
|
</form>
|
||||||
<a class="btn ghost" href="{{ url_for('import_start', kind='relations') }}">Importeren</a>
|
<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" href="{{ url_for('relation_edit', kind=kind, type=type or None) }}">{{ 'Nieuwe klant' if kind == 'klant' else 'Nieuwe partner' }}</a>
|
<a class="btn btn-outline-primary" href="{{ url_for('import_start', kind='relations') }}">Importeren</a>
|
||||||
|
<a class="btn btn-primary" href="{{ url_for('relation_edit', kind=kind, type=type or None) }}">{{ 'Nieuwe klant' if kind == 'klant' else 'Nieuwe partner' }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -25,23 +27,29 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
{% if rows %}
|
{% if rows %}
|
||||||
<div class="table-wrap"><table>
|
<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) }}'">
|
||||||
<td><a href="{{ url_for('relation_view', rid=r.id) }}">{{ r.name }}</a></td>
|
{{ bulk_cell(r.id, r.name) }}
|
||||||
<td>{% if r.type %}<span class="chip">{{ r.type }}</span>{% endif %}</td>
|
<td class="stack-title"><a href="{{ url_for('relation_view', rid=r.id) }}">{{ r.name }}</a></td>
|
||||||
<td>{{ r.member_names or '—' }}{% if r.members > 4 %} <span class="muted">+{{ r.members - 4 }}</span>{% endif %}</td>
|
<td data-label="Type">{% if r.type %}<span class="chip">{{ r.type }}</span>{% endif %}</td>
|
||||||
<td>{{ r.city }}</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 class="muted">{{ [r.phone, r.email]|select|join(' · ') }}</td>
|
<td data-label="Plaats">{{ r.city }}</td>
|
||||||
{% if kind == 'partner' %}<td class="num">{{ r.products or '' }}</td>{% endif %}
|
<td data-label="Contact" class="muted">{{ [r.phone, r.email]|select|join(' · ') }}</td>
|
||||||
|
{% if kind == 'partner' %}<td data-label="Producten" class="num">{{ r.products or '' }}</td>{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</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>
|
||||||
|
{% 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 %}
|
||||||
+51
-47
@@ -1,16 +1,42 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% block title %}Instellingen{% endblock %}
|
{% block title %}Instellingen{% endblock %}
|
||||||
{% macro field(key, label, type='text', placeholder='', full=false) %}
|
{% macro field(key, label, type='text', placeholder='', full=false) %}
|
||||||
<div class="{{ 'full' if full }}"><label for="{{ key }}">{{ label }}</label><input id="{{ key }}" type="{{ type }}" name="{{ key }}" value="{{ s.get(key, '') }}" placeholder="{{ placeholder }}"></div>
|
<div class="{{ 'col-12' if full else 'col-md-6 col-xl-4' }}"><label class="form-label" for="{{ key }}">{{ label }}</label><input class="form-control" id="{{ key }}" type="{{ type }}" name="{{ key }}" value="{{ s.get(key, '') }}" placeholder="{{ placeholder }}"></div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
{% 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"><div class="card-body">
|
||||||
<h2>Bedrijfsgegevens</h2>
|
<h2>Bedrijfsgegevens</h2>
|
||||||
<p class="hint" style="margin-top:-.4rem;margin-bottom:1rem">Deze gegevens staan op de offertes.</p>
|
<p class="hint" style="margin:-.4rem 0 1rem">Deze gegevens staan op de offertes.</p>
|
||||||
<div class="fields">
|
<div class="row g-3">
|
||||||
{{ field('company_name', 'Bedrijfsnaam', full=true) }}
|
{{ field('company_name', 'Bedrijfsnaam', full=true) }}
|
||||||
{{ field('company_address', 'Adres') }}
|
{{ field('company_address', 'Adres') }}
|
||||||
{{ field('company_postcode_city', 'Postcode en plaats') }}
|
{{ field('company_postcode_city', 'Postcode en plaats') }}
|
||||||
@@ -21,66 +47,44 @@
|
|||||||
{{ field('company_btw', 'Btw-nummer') }}
|
{{ field('company_btw', 'Btw-nummer') }}
|
||||||
{{ field('company_iban', 'IBAN') }}
|
{{ field('company_iban', 'IBAN') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div></div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Offertes</h2>
|
<h2>Offertes</h2>
|
||||||
<div class="fields">
|
<div class="row g-3">
|
||||||
{{ field('quote_prefix', 'Voorvoegsel nummer', placeholder='OFF') }}
|
{{ field('quote_prefix', 'Voorvoegsel nummer', placeholder='OFF') }}
|
||||||
{{ field('quote_valid_days', 'Standaard geldig (dagen)', 'number') }}
|
{{ field('quote_valid_days', 'Standaard geldig (dagen)', 'number') }}
|
||||||
<div class="full"><label for="quote_intro">Standaard inleiding</label><textarea id="quote_intro" name="quote_intro">{{ s.quote_intro }}</textarea></div>
|
<div class="col-12"><label class="form-label" for="quote_intro">Standaard inleiding</label><textarea class="form-control" id="quote_intro" name="quote_intro">{{ s.quote_intro }}</textarea></div>
|
||||||
<div class="full"><label for="quote_footer">Voettekst (bijv. betalingsvoorwaarden)</label><textarea id="quote_footer" name="quote_footer">{{ s.quote_footer }}</textarea></div>
|
<div class="col-12"><label class="form-label" for="quote_footer">Voettekst (bijv. betalingsvoorwaarden)</label><textarea class="form-control" id="quote_footer" name="quote_footer">{{ s.quote_footer }}</textarea></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div></div>
|
||||||
|
|
||||||
<div class="card" id="mail">
|
<div class="card"><div class="card-body">
|
||||||
<h2>E-mail koppelen</h2>
|
|
||||||
<p class="hint" style="margin-top:-.4rem;margin-bottom:1rem">Gebruik voor Gmail of Microsoft 365 een app-wachtwoord. Wachtwoorden staan in de database: houd de map <code>data/</code> privé.</p>
|
|
||||||
<h3>Ontvangen (IMAP)</h3>
|
|
||||||
<div class="fields">
|
|
||||||
{{ field('imap_host', 'Server', placeholder='imap.gmail.com') }}
|
|
||||||
{{ field('imap_port', 'Poort', 'number', '993') }}
|
|
||||||
{{ field('imap_user', 'Gebruikersnaam', placeholder='info@vanemous.nl') }}
|
|
||||||
<div><label for="imap_pass">Wachtwoord</label><input id="imap_pass" type="password" name="imap_pass" placeholder="{{ '•••••••• (ingesteld)' if s.imap_pass else '' }}" autocomplete="new-password"></div>
|
|
||||||
{{ field('imap_folder', 'Map', placeholder='INBOX') }}
|
|
||||||
{{ field('imap_days', 'Aantal dagen terug ophalen', 'number', '30') }}
|
|
||||||
</div>
|
|
||||||
<h3>Versturen (SMTP)</h3>
|
|
||||||
<div class="fields">
|
|
||||||
{{ field('smtp_host', 'Server', placeholder='smtp.gmail.com') }}
|
|
||||||
{{ field('smtp_port', 'Poort', 'number', '587') }}
|
|
||||||
{{ field('smtp_user', 'Gebruikersnaam', placeholder='leeg = zelfde als IMAP') }}
|
|
||||||
<div><label for="smtp_pass">Wachtwoord</label><input id="smtp_pass" type="password" name="smtp_pass" placeholder="{{ '•••••••• (ingesteld)' if s.smtp_pass else 'leeg = zelfde als IMAP' }}" autocomplete="new-password"></div>
|
|
||||||
{{ field('smtp_from', 'Afzender', placeholder='Van Emous <info@vanemous.nl>', full=true) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Formulieren en API</h2>
|
<h2>Formulieren en API</h2>
|
||||||
<div class="fields">
|
<div class="row g-3">
|
||||||
{{ field('allowed_origins', 'Toegestane websites (komma-gescheiden)', placeholder='https://www.vanemous.nl', full=true) }}
|
{{ field('allowed_origins', 'Toegestane websites (komma-gescheiden)', placeholder='https://www.vanemous.nl', full=true) }}
|
||||||
<div><label for="api_assign_user">Nieuwe leads toewijzen aan</label>
|
<div class="col-md-6 col-xl-4"><label class="form-label" for="api_assign_user">Nieuwe leads toewijzen aan</label>
|
||||||
<select id="api_assign_user" name="api_assign_user">
|
<select class="form-select" id="api_assign_user" name="api_assign_user">
|
||||||
<option value="">— Niemand —</option>
|
<option value="">— Niemand —</option>
|
||||||
{% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if s.api_assign_user == u.id|string }}>{{ u.name }}</option>{% endfor %}
|
{% for u in USERS %}<option value="{{ u.id }}" {{ 'selected' if s.api_assign_user == u.id|string }}>{{ u.name }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div></div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
<h2>Relatietypes</h2>
|
<h2>Relatietypes</h2>
|
||||||
<p class="hint" style="margin-top:-.4rem;margin-bottom:1rem">Komma-gescheiden. Bestaande relaties houden hun type als je er een weghaalt.</p>
|
<p class="hint" style="margin:-.4rem 0 1rem">Komma-gescheiden. Bestaande relaties houden hun type als je er een weghaalt.</p>
|
||||||
<div class="fields">
|
<div class="row g-3">
|
||||||
<div class="full"><label for="klant_types">Klanten (bundels)</label><input id="klant_types" name="klant_types" value="{{ s.klant_types or 'Huishouden / familie, Particulier, Zakelijk, Zorginstelling' }}"></div>
|
<div class="col-12"><label class="form-label" for="klant_types">Klanten (bundels)</label><input class="form-control" id="klant_types" name="klant_types" value="{{ s.klant_types or 'Huishouden / familie, Particulier, Zakelijk, Zorginstelling' }}"></div>
|
||||||
<div class="full"><label for="partner_types">Partners</label><input id="partner_types" name="partner_types" value="{{ s.partner_types or 'Leverancier, Aannemer, Installateur, Architect / adviseur, Gemeente, Overig' }}"></div>
|
<div class="col-12"><label class="form-label" for="partner_types">Partners</label><input class="form-control" id="partner_types" name="partner_types" value="{{ s.partner_types or 'Leverancier, Aannemer, Installateur, Architect / adviseur, Gemeente, Overig' }}"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div></div>
|
||||||
|
|
||||||
<div class="actions" style="margin-bottom:1.5rem"><button class="btn">Instellingen opslaan</button></div>
|
<div class="actions" style="margin-bottom:1.5rem"><button class="btn btn-primary">Instellingen opslaan</button></div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
<h2>API-koppeling</h2>
|
<h2>API-koppeling</h2>
|
||||||
<dl class="props">
|
<dl class="props">
|
||||||
<dt>Endpoint</dt><dd><code>POST {{ api_url }}</code></dd>
|
<dt>Endpoint</dt><dd><code>POST {{ api_url }}</code></dd>
|
||||||
@@ -123,7 +127,7 @@ const { ok, id } = await res.json();{% endfilter %}</pre>
|
|||||||
|
|
||||||
<form method="post" onsubmit="return confirm('Nieuwe sleutel maken? Bestaande formulieren werken dan niet meer.')">
|
<form method="post" onsubmit="return confirm('Nieuwe sleutel maken? Bestaande formulieren werken dan niet meer.')">
|
||||||
<input type="hidden" name="action" value="apikey">
|
<input type="hidden" name="action" value="apikey">
|
||||||
<button class="btn quiet small">Nieuwe API-sleutel maken</button>
|
<button class="btn btn-outline-secondary btn-sm">Nieuwe API-sleutel maken</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div></div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
+14
-12
@@ -4,21 +4,23 @@
|
|||||||
<div class="head">
|
<div class="head">
|
||||||
<div><a class="label" href="{{ url_for('users') }}">← Gebruikers</a><h1>{{ 'Gebruiker bewerken' if u.id else 'Nieuwe gebruiker' }}</h1></div>
|
<div><a class="label" href="{{ url_for('users') }}">← Gebruikers</a><h1>{{ 'Gebruiker bewerken' if u.id else 'Nieuwe gebruiker' }}</h1></div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" class="card">
|
<form method="post" class="card"><div class="card-body">
|
||||||
<div class="fields">
|
<div class="row g-3">
|
||||||
<div><label for="name">Naam *</label><input id="name" name="name" value="{{ u.name }}" required></div>
|
<div class="col-md-6"><label class="form-label" for="name">Naam *</label><input class="form-control" id="name" name="name" value="{{ u.name }}" required></div>
|
||||||
<div><label for="email">E-mailadres (inlognaam) *</label><input id="email" type="email" name="email" value="{{ u.email }}" required></div>
|
<div class="col-md-6"><label class="form-label" for="email">E-mailadres (inlognaam) *</label><input class="form-control" id="email" type="email" name="email" value="{{ u.email }}" required></div>
|
||||||
<div><label for="role">Rol</label>
|
<div class="col-md-6"><label class="form-label" for="role">Rol</label>
|
||||||
<select id="role" name="role">{% for k, l in ROLES %}<option value="{{ k }}" {{ 'selected' if u.role == k }}>{{ l }}</option>{% endfor %}</select>
|
<select class="form-select" id="role" name="role">{% for k, l in ROLES %}<option value="{{ k }}" {{ 'selected' if u.role == k }}>{{ l }}</option>{% endfor %}</select>
|
||||||
</div>
|
</div>
|
||||||
<div><label for="password">{{ 'Nieuw wachtwoord' if u.id else 'Wachtwoord *' }}</label>
|
<div class="col-md-6"><label class="form-label" for="password">{{ 'Nieuw wachtwoord' if u.id else 'Wachtwoord *' }}</label>
|
||||||
<input id="password" type="password" name="password" minlength="8" {{ 'required' if not u.id }} autocomplete="new-password">
|
<input class="form-control" id="password" type="password" name="password" minlength="8" {{ 'required' if not u.id }} autocomplete="new-password">
|
||||||
{% if u.id %}<p class="hint">Leeg laten om het huidige wachtwoord te houden.</p>{% endif %}</div>
|
{% if u.id %}<p class="hint">Leeg laten om het huidige wachtwoord te houden.</p>{% endif %}</div>
|
||||||
<div class="full"><label><input type="checkbox" name="active" value="1" {{ 'checked' if u.active }}>Actief (mag inloggen)</label></div>
|
<div class="col-12"><div class="form-check"><input class="form-check-input" type="checkbox" id="active" name="active" value="1" {{ 'checked' if u.active }}><label class="form-check-label" for="active">Actief (mag inloggen)</label></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn">Opslaan</button>
|
<button class="btn btn-primary">Opslaan</button>
|
||||||
<a class="btn quiet" 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>
|
||||||
</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 %}
|
||||||
|
|||||||
+14
-10
@@ -1,25 +1,29 @@
|
|||||||
{% 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">
|
||||||
<div><div class="kicker">Team</div><h1>Gebruikers</h1></div>
|
<div><div class="kicker">Team</div><h1>Gebruikers</h1></div>
|
||||||
<a class="btn" href="{{ url_for('user_edit') }}">Nieuwe gebruiker</a>
|
<div class="actions"><a class="btn btn-primary" href="{{ url_for('user_edit') }}">Nieuwe gebruiker</a></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card"><div class="card-body">
|
||||||
<div class="table-wrap"><table>
|
<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) }}'">
|
||||||
<td><a href="{{ url_for('user_edit', uid=u.id) }}">{{ u.name }}</a></td>
|
{{ bulk_cell(u.id, u.name) }}
|
||||||
<td>{{ u.email }}</td>
|
<td class="stack-title"><a href="{{ url_for('user_edit', uid=u.id) }}">{{ u.name }}</a></td>
|
||||||
<td>{{ ROLE.get(u.role, u.role) }}</td>
|
<td data-label="E-mail">{{ u.email }}</td>
|
||||||
<td class="num">{{ u.n }}</td>
|
<td data-label="Rol">{{ ROLE.get(u.role, u.role) }}</td>
|
||||||
<td>{% if not u.active %}<span class="chip verloren">Inactief</span>{% endif %}</td>
|
<td data-label="Contacten" class="num">{{ u.n }}</td>
|
||||||
|
<td data-label="Status">{% if not u.active %}<span class="chip verloren">Inactief</span>{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table></div>
|
</table></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 %}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="nl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
{# De engine verwijst relatief naar assets/ en kosten/: base = de gekopieerde configurator. #}
|
||||||
|
<base href="{{ url_for('static', filename='configurator/') }}">
|
||||||
|
<title>{{ name }} · Van Emous CRM</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Mukta:wght@400;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--orange: #b95913; --orange-dark: #a24e11; --orange-tint: rgba(185, 89, 19, 0.1);
|
||||||
|
--ink: #212121; --ink-muted: #5c6670; --white: #ffffff; --ultra-light: #f9faf5; --line: rgba(0, 0, 0, 0.08);
|
||||||
|
--radius-sm: 0.5rem; --radius-pill: 50px;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; height: 100%; }
|
||||||
|
body { font: 15px/1.5 "Mukta", system-ui, sans-serif; color: var(--ink); background: var(--white); font-variant-numeric: tabular-nums; }
|
||||||
|
.vw { height: 100%; display: flex; flex-direction: column; background: var(--white); }
|
||||||
|
.vw-stage { position: relative; flex: 1; min-height: 0; background: #dfe6ec; overflow: hidden; }
|
||||||
|
.vw-3d, .vw-plan { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||||
|
.vw-3d canvas { display: block; touch-action: none; }
|
||||||
|
.vw-plan { display: none; background: #fff; }
|
||||||
|
.view-plan .vw-plan { display: block; }
|
||||||
|
.view-plan .vw-3d { visibility: hidden; }
|
||||||
|
.vw-bar { position: absolute; top: 12px; left: 12px; right: 12px; display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-start; justify-content: space-between; pointer-events: none; }
|
||||||
|
.vw-bar > * { pointer-events: auto; }
|
||||||
|
.vw-viewbar, .vw-tools { display: flex; background: rgba(255, 255, 255, 0.94); border: 1px solid var(--line); border-radius: var(--radius-pill); padding: 3px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); }
|
||||||
|
.vw button { font: 600 14px/1 "Mukta", system-ui, sans-serif; color: var(--ink); background: none; border: 0; border-radius: var(--radius-pill); padding: 8px 14px; cursor: pointer; }
|
||||||
|
.vw button:hover { background: var(--orange-tint); }
|
||||||
|
.vw button.on { background: var(--orange); color: var(--white); }
|
||||||
|
.vw button:focus-visible { outline: 3px solid var(--orange); outline-offset: 1px; }
|
||||||
|
.vw-tools button { padding: 8px 10px; display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.vw-tools-wrap { position: relative; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
|
||||||
|
.vw-pop { position: absolute; top: calc(100% + 6px); right: 0; z-index: 2; width: min(320px, calc(100vw - 24px)); max-height: calc(100vh - 90px); overflow: auto;
|
||||||
|
background: var(--white); border: 1px solid var(--line); border-radius: 14px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14); padding: 12px 14px; font-size: 14px; }
|
||||||
|
.vw-pop[hidden] { display: none; }
|
||||||
|
.vw-pop h3 { margin: 0 0 8px; font-size: 12px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); }
|
||||||
|
.vw-pop select { width: 100%; font: inherit; padding: 6px 8px; border: 1px solid #8f969c; border-radius: var(--radius-sm); margin-bottom: 10px; }
|
||||||
|
.vw-checks { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 10px; }
|
||||||
|
.vw-checks label { display: flex; gap: 6px; align-items: center; cursor: pointer; }
|
||||||
|
.vw-checks input { accent-color: var(--orange); }
|
||||||
|
.vw-pdf { display: flex; justify-content: space-between; gap: 10px; padding: 8px 0; border-top: 1px solid var(--line); }
|
||||||
|
.vw-pdf:first-child { border-top: 0; padding-top: 0; }
|
||||||
|
.vw-pdf span { display: block; color: var(--ink-muted); font-size: 12px; line-height: 1.35; }
|
||||||
|
.vw-pdf > div:last-child { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; white-space: nowrap; }
|
||||||
|
.vw-pdf a { color: var(--orange); font-weight: 600; text-decoration: none; }
|
||||||
|
.vw .vw-pdf button { padding: 4px 10px; border: 1px solid rgba(185, 89, 19, 0.35); color: var(--orange); font-size: 13px; }
|
||||||
|
.vw-tools svg { width: 16px; height: 16px; display: block; }
|
||||||
|
.vw-cutbar { position: absolute; left: 12px; top: 58px; display: none; align-items: center; gap: 10px; background: rgba(255, 255, 255, 0.94); border: 1px solid var(--line); border-radius: var(--radius-pill); padding: 6px 14px; font-size: 13px; }
|
||||||
|
.view-cut .vw-cutbar { display: flex; }
|
||||||
|
.vw-cutbar input { accent-color: var(--orange); width: 140px; }
|
||||||
|
.vw-cutbar output { min-width: 3.5em; font-weight: 600; }
|
||||||
|
.vw-hint { position: absolute; left: 12px; bottom: 10px; font-size: 12px; color: var(--ink-muted); background: rgba(255, 255, 255, 0.85); padding: 2px 10px; border-radius: var(--radius-pill); }
|
||||||
|
.vw-toast { position: absolute; left: 50%; bottom: 44px; transform: translateX(-50%); background: var(--ink); color: var(--white); font-size: 13px; padding: 6px 14px; border-radius: var(--radius-pill); opacity: 0; transition: opacity 0.2s; pointer-events: none; white-space: nowrap; }
|
||||||
|
.vw-toast.on { opacity: 0.92; }
|
||||||
|
.vw-busy, .vw-msg { position: absolute; inset: 0; display: none; place-items: center; text-align: center; padding: 2rem; color: var(--ink-muted); }
|
||||||
|
.is-busy .vw-busy, .is-error .vw-msg { display: grid; }
|
||||||
|
.vw-facts { display: flex; flex-wrap: wrap; gap: 6px 22px; padding: 10px 16px; border-top: 1px solid var(--line); background: var(--ultra-light); font-size: 13px; }
|
||||||
|
.vw-facts span { display: block; color: var(--ink-muted); font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; }
|
||||||
|
.vw-facts b { font-weight: 600; }
|
||||||
|
@media (max-width: 560px) { .vw-hint { display: none; } .vw button { padding: 7px 10px; } .vw-tools span { display: none; } .vw-viewbar button { padding: 7px 9px; font-size: 13px; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="viewer" class="vw is-busy">
|
||||||
|
<div class="vw-stage">
|
||||||
|
<div class="vw-3d"></div>
|
||||||
|
<canvas class="vw-plan" aria-label="Plattegrond, noord boven"></canvas>
|
||||||
|
<div class="vw-bar">
|
||||||
|
<div class="vw-viewbar" role="group" aria-label="Weergave">
|
||||||
|
<button type="button" data-view="3d" class="on">3D</button>
|
||||||
|
<button type="button" data-view="cut" title="Wanden afgesneden op een hoogte boven de vloer">Doorsnede</button>
|
||||||
|
<button type="button" data-view="plan">Plattegrond</button>
|
||||||
|
</div>
|
||||||
|
<div class="vw-tools-wrap">
|
||||||
|
<div class="vw-tools">
|
||||||
|
<button type="button" class="vw-layers-btn" data-pop=".vw-pop-layers" title="Bouwfase en lagen (regelwerk, elektra, …)" aria-label="Lagen">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 1 0.8 4.6 8 8.2l7.2-3.6L8 1Zm-5.1 3.6L8 2.3l5.1 2.3L8 6.9 2.9 4.6Z"/><path d="m1.6 7.3-.8.4L8 11.3l7.2-3.6-.8-.4L8 10.5 1.6 7.3Z"/><path d="m1.6 10.4-.8.4L8 14.4l7.2-3.6-.8-.4L8 13.6l-6.4-3.2Z"/></svg><span>Lagen</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-pop=".vw-pop-pdf" title="PDF: werktekening en klant-PDF" aria-label="PDF">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M4 1h5.5L13 4.5V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1Zm.2 1.2v11.6h7.6V5.2H9V2.2H4.2Z"/><path d="M5.5 8h5v1.1h-5V8Zm0 2.4h5v1.1h-5v-1.1Z"/></svg><span>PDF</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="vw-shot" title="Afbeelding opslaan" aria-label="Afbeelding opslaan">
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 11.5a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm0-1.2a1.8 1.8 0 1 1 0-3.6 1.8 1.8 0 0 1 0 3.6Z"/><path d="M5.6 2h4.8l1 1.5H14a1 1 0 0 1 1 1V13a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1h2.6l1-1.5Zm.6 1.2-1 1.5H2.2v8.1h11.6V4.7h-2.9l-1-1.5H6.2Z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="vw-full" title="Schermvullend" aria-label="Schermvullend" hidden>
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1.5 1h4v1.2H2.7v2.8H1.5V1Zm9 0h4v4h-1.2V2.2h-2.8V1ZM1.5 11h1.2v2.8h2.8V15h-4v-4Zm11.8 0h1.2v4h-4v-1.2h2.8V11Z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="vw-pop vw-pop-layers" hidden>
|
||||||
|
<h3>Bouwfase</h3>
|
||||||
|
<select aria-label="Bouwfase"></select>
|
||||||
|
<h3>Lagen</h3>
|
||||||
|
<div class="vw-checks"></div>
|
||||||
|
</div>
|
||||||
|
<div class="vw-pop vw-pop-pdf" hidden><div class="vw-pdfs"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="vw-cutbar"><span>Snijhoogte</span>
|
||||||
|
<input type="range" min="300" max="3000" step="50" value="1500" aria-label="Snijhoogte boven de vloer">
|
||||||
|
<output>1,50 m</output></label>
|
||||||
|
<div class="vw-hint"></div>
|
||||||
|
<div class="vw-toast" role="status" aria-live="polite"></div>
|
||||||
|
<div class="vw-busy" aria-live="polite">Ontwerp opbouwen…</div>
|
||||||
|
<div class="vw-msg"></div>
|
||||||
|
</div>
|
||||||
|
<div class="vw-facts"></div>
|
||||||
|
</div>
|
||||||
|
{% if engine %}
|
||||||
|
<script type="application/json" id="design">{{ design|tojson }}</script>
|
||||||
|
<script type="application/json" id="viewer-cfg">{{ cfg|tojson }}</script>
|
||||||
|
{% for src in scripts %}<script src="{{ src }}"></script>
|
||||||
|
{% endfor %}<script src="{{ url_for('static', filename='viewer.js') }}"></script>
|
||||||
|
{% else %}
|
||||||
|
<script>
|
||||||
|
document.getElementById('viewer').className = 'vw is-error';
|
||||||
|
document.querySelector('.vw-msg').textContent = 'De 3D-engine van de configurator ontbreekt. Draai tools/configurator-sync.sh in de CRM-map.';
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Kopieert de 3D-engine van de Tectum-configurator (../configurator) naar static/configurator/, voor de
|
||||||
|
# viewer van gekozen modellen (templates/viewer.html, static/viewer.js). Zelfde werkwijze als
|
||||||
|
# tools/configurator-sync.sh van de website; alleen wat de engine nodig heeft (js, assets, kosten).
|
||||||
|
# Gebruik: tools/configurator-sync.sh [pad-naar-configurator] [doelmap]
|
||||||
|
cd "$(dirname "$0")/.." || exit 1
|
||||||
|
SRC="${1:-../configurator}"
|
||||||
|
DEST="${2:-static/configurator}"
|
||||||
|
[ -f "$SRC/js/house/house-model.js" ] || { echo "Geen configurator gevonden in $SRC" >&2; exit 1; }
|
||||||
|
mkdir -p "$DEST"
|
||||||
|
rsync -a --delete \
|
||||||
|
--include='/LICENTIE.md' --include='/assets/***' --include='/js/***' --include='/kosten/***' \
|
||||||
|
--exclude='*' \
|
||||||
|
"$SRC/" "$DEST/"
|
||||||
|
echo "Configurator-engine gekopieerd naar $DEST/ ($(du -sh "$DEST" | cut -f1))"
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Exporteert de standaardmodellen van de configurator (HouseModel.MODELS) als house-design-JSON,
|
||||||
|
// precies zoals Bestand › Opslaan in de configurator ze wegschrijft.
|
||||||
|
// Gebruik: node tools/export-models.js [pad-naar-configurator] [uitvoermap]
|
||||||
|
// Standaard: ../configurator → demo/modellen/
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
const root = path.dirname(__dirname);
|
||||||
|
const SRC = path.resolve(process.argv[2] || path.join(root, '..', 'configurator'));
|
||||||
|
const OUT = path.resolve(process.argv[3] || path.join(root, 'demo', 'modellen'));
|
||||||
|
|
||||||
|
// De rekenmodules in de volgorde van light.html; alleen wat nodig is om een model op te bouwen.
|
||||||
|
const SCRIPTS = [
|
||||||
|
'js/vendor/three.min.js', 'js/core/geometry.js', 'js/core/view-layers.js', 'js/wall/kozijn-model.js',
|
||||||
|
'js/core/ui-kit.js', 'js/wall/wall-model.js', 'js/floor/floor-model.js', 'js/roof/roof-model.js', 'js/roof/post-model.js',
|
||||||
|
'js/lighting/lighting-model.js', 'js/elektra/elektra-model.js', 'js/sandwich/sandwich-model.js', 'js/wall/wall.js', 'js/floor/floor.js',
|
||||||
|
'js/unit/unit-model.js', 'js/room/room-model.js', 'js/interior/fixture-model.js',
|
||||||
|
'js/house/chimney-model.js', 'js/house/house-model.js'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Minimale browseromgeving: de modules raken het DOM alleen bij het tekenen, niet bij het opbouwen.
|
||||||
|
const noop = () => {};
|
||||||
|
const el = () => new Proxy(function () {}, { get: (t, k) => (k === Symbol.toPrimitive ? () => '' : el()), apply: () => el(), construct: () => el() });
|
||||||
|
const ctx = { console, Math, JSON, Date, setTimeout, clearTimeout, URL, Blob: function () {}, Image: function () { return el(); } };
|
||||||
|
ctx.window = ctx; ctx.self = ctx; ctx.globalThis = ctx;
|
||||||
|
ctx.document = { createElement: () => el(), getElementById: () => null, querySelector: () => null, querySelectorAll: () => [], addEventListener: noop, head: el(), body: el(), currentScript: null };
|
||||||
|
ctx.navigator = { userAgent: 'node', platform: 'node', maxTouchPoints: 0 };
|
||||||
|
ctx.location = { href: 'http://localhost/', search: '' };
|
||||||
|
ctx.fetch = () => Promise.reject(new Error('geen fetch'));
|
||||||
|
ctx.addEventListener = noop;
|
||||||
|
vm.createContext(ctx);
|
||||||
|
for (const s of SCRIPTS) {
|
||||||
|
const file = path.join(SRC, s);
|
||||||
|
// const/let op het hoogste niveau blijven in het script; zet de modulenamen expliciet op het globale object.
|
||||||
|
let code = fs.readFileSync(file, 'utf8');
|
||||||
|
const names = [...code.matchAll(/^(?:const|let|var|class)\s+([A-Z][A-Za-z0-9_]*)\s*=?/gm)].map(m => m[1]);
|
||||||
|
code += '\n' + names.map(n => `try { globalThis.${n} = ${n}; } catch (e) {}`).join('\n');
|
||||||
|
vm.runInContext(code, ctx, { filename: file });
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = (fs.readFileSync(path.join(SRC, 'js/app.js'), 'utf8').match(/APP_VERSION\s*=\s*['"]([^'"]+)/) || [])[1] || '';
|
||||||
|
fs.mkdirSync(OUT, { recursive: true });
|
||||||
|
const index = [];
|
||||||
|
for (const [key, def] of Object.entries(ctx.HouseModel.MODELS)) {
|
||||||
|
const state = ctx.HouseModel.normalize(ctx.HouseModel.clone(def.create()));
|
||||||
|
state.project = Object.assign({}, state.project, { naam: state.project.naam || def.label });
|
||||||
|
const design = { type: 'house-design', version: 1, exportedAt: new Date().toISOString(), model: key, configurator: version, ...state };
|
||||||
|
fs.writeFileSync(path.join(OUT, `${key}.json`), JSON.stringify(design));
|
||||||
|
index.push({ key, label: def.label });
|
||||||
|
console.log(`${key}.json ${def.label} (${state.units.length} units)`);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path.join(OUT, 'index.json'), JSON.stringify({ configurator: version, models: index }, null, 2) + '\n');
|
||||||
Executable
+44
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Een getagde versie van de CRM naar de server brengen (eerst eenmalig server/setup.sh op de server).
|
||||||
|
#
|
||||||
|
# tools/release.sh crm@crm.voorbeeld.nl nieuwste tag
|
||||||
|
# tools/release.sh crm@crm.voorbeeld.nl v0.0.2 een bepaalde versie (ook om terug te rollen)
|
||||||
|
#
|
||||||
|
# De code komt uit `git archive <tag>` (precies wat getagd is, niet de werkkopie); de 3D-engine van de
|
||||||
|
# configurator ($CONFIGURATOR, standaard ../configurator) gaat er met rsync bij, met hardlinks naar de vorige
|
||||||
|
# versie zodat alleen wijzigingen over de lijn gaan. Op de server maakt server/activate.sh hem actief.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
HOST="${1:-}"
|
||||||
|
CONFIGURATOR="${CONFIGURATOR:-$REPO/../configurator}"
|
||||||
|
ROOT=/srv/vanemous-crm
|
||||||
|
|
||||||
|
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/release.sh crm@server [vX.Y.Z]"
|
||||||
|
TAG="${2:-$(git -C "$REPO" 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 "$REPO" rev-parse -q --verify "refs/tags/$TAG" >/dev/null || fail "tag $TAG bestaat niet"
|
||||||
|
code_version="$(git -C "$REPO" show "$TAG:app.py" | sed -nE "s/^__version__ = '([^']+)'.*/\1/p")"
|
||||||
|
[ "v$code_version" = "$TAG" ] || fail "tag $TAG bevat __version__ = '$code_version' in app.py"
|
||||||
|
git -C "$REPO" cat-file -e "$TAG:server/activate.sh" 2>/dev/null || fail "$TAG heeft nog geen server/activate.sh; tag een nieuwere versie"
|
||||||
|
[ -f "$CONFIGURATOR/js/house/house-model.js" ] || fail "geen configurator in $CONFIGURATOR (zet CONFIGURATOR=pad)"
|
||||||
|
[ -z "$(git -C "$REPO" status --porcelain)" ] || say "let op: niet-gecommitte wijzigingen zitten niet in $TAG"
|
||||||
|
|
||||||
|
target="$ROOT/releases/$TAG"
|
||||||
|
if ssh "$HOST" "test -f $target/.installed"; then
|
||||||
|
say "$TAG staat al op de server"
|
||||||
|
else
|
||||||
|
say "$TAG uploaden naar $HOST"
|
||||||
|
git -C "$REPO" archive "$TAG" | ssh "$HOST" "rm -rf $target && mkdir -p $target && tar -x -C $target"
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "3D-engine bijwerken uit $CONFIGURATOR"
|
||||||
|
ssh "$HOST" "mkdir -p $target/static/configurator"
|
||||||
|
rsync -az --delete --link-dest="$ROOT/current/static/configurator/" \
|
||||||
|
--include='/LICENTIE.md' --include='/assets/***' --include='/js/***' --include='/kosten/***' --exclude='*' \
|
||||||
|
"$CONFIGURATOR/" "$HOST:$target/static/configurator/"
|
||||||
|
|
||||||
|
ssh "$HOST" "bash $target/server/activate.sh $TAG"
|
||||||
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